Skip to content

How to Write Dockerfile


So far you’ve been working with images someone else already built — pulling nginx, postgres, node, and running containers off them. At some point that stops being enough. You have your own application, and you need to turn it into an image that runs the same way on your laptop, your teammate’s laptop, and the server in production.

That’s what a Dockerfile is for. It’s not a config file in the usual sense — it’s closer to a recipe, written as a sequence of steps, that Docker follows to assemble an image layer by layer. You’re not describing the final state directly; you’re describing how to get there, one instruction at a time.

This lesson is about learning to read and write that recipe. Not every ingredient Docker offers — that’s what the official reference is for — just enough that you can look at a blank file, understand what question each line is answering, and write one yourself with confidence.

What Writing a Dockerfile Actually Means

Every line in a Dockerfile is an instruction, and every instruction answers one of a small number of questions:

  • What am I starting from?
  • What files does my app need inside the image?
  • What setup needs to happen before it can run?
  • What command actually starts it?

That’s genuinely most of it. A Dockerfile is just those four questions, answered in order, using Docker’s instruction keywords.

    flowchart LR
    A["FROM\nwhat am I starting from?"] --> B["COPY\nwhat files go in?"]
    B --> C["RUN\nwhat setup happens?"]
    C --> D["CMD\nwhat starts the app?"]
  

Each instruction you write becomes a new layer stacked on top of the last one — which is exactly what you saw with docker image history in the previous lessons. Writing a Dockerfile and reading a layer stack are really the same skill, just from opposite directions.

Note

As you can write Dockerfile without the idea of layered architecture of image and build your app. But it becomes easier when you have some general idea about layers. If you don’t know about layers yet, I advise you first to learn that from previous lessons.

Starting From Something: FROM

Start with creating a simple Dockerfile, yes name it exactly same (D uppercase, ockerfile lowercase) — Docker and every Docker tool automatically understand it; community, your teammates everyone understand that Dockerfile is a Docker file. Name anything else (and regret later) and only you will know, not even future you.

Every Dockerfile begins by picking a base — an existing image you’re going to build on top of, instead of starting from nothing:

FROM ubuntu:22.04

This single line saves you from installing an operating system by hand. You’re standing on someone else’s already-built layers and adding your own on top. We’re using plain Ubuntu here on purpose — no language runtime, no framework, nothing you’d need prior experience with. Just a Linux system with apt on it, which you’ve almost certainly touched before if you’ve used Ubuntu or Debian at all.

Picking a good base is less about syntax and more about judgment: a full OS image like ubuntu:22.04 gives you more tools to debug with, while a minimal one gives you a smaller, leaner image. You don’t need to optimize this on your first attempt — just know that the choice exists, and that we’ll return to it when we talk about best practices.

Setting the Stage: WORKDIR

Before copying files in (COPY) or running (RUN) commands, it helps to tell Docker where inside the image things should happen:

WORKDIR /var/www/html

This does two things at once — it creates the directory if it doesn’t exist, and it makes every instruction after it run relative to that path. We’re pointing it at /var/www/html specifically because that’s the folder a web server looks in by default — the directory itself is telling you what this image is going to do. Without a WORKDIR, you’d be scattering files across the filesystem root and typing full paths everywhere, which gets messy fast.

Getting Your Files In: COPY

Your application’s files don’t exist inside the image until you explicitly put them there:

COPY index.html .

COPY takes something from your build context (the folder you’re building from) and places it inside the image at the path you specify — in this case, a single HTML file (see below), landing right inside the /var/www/html we just set as the working directory.

This is deliberately simple: any file you have sitting next to your Dockerfile can be pulled in this same way. You can create this index.html next to your Dockerfile:

index.hml
<html>
	<h1>COPY in Dockerfile</h1>
</html>

Doing the Setup: RUN

RUN executes a command while the image is being built, and whatever it changes becomes part of the image permanently:

RUN apt-get update && apt-get install -y nginx

This is the instruction that installs software, compiles code, or does any other setup your image needs before it can run. If you’ve ever set up a fresh Ubuntu server by hand, this line will look familiar — it’s the exact same apt-get update && apt-get install pattern, just executed by Docker instead of you typing it over SSH. Anything you’d normally run in a terminal to prepare a machine belongs here.

Telling It How to Start: CMD

Everything so far has been about building the image. CMD is different — it defines what happens when a container is finally started from it:

CMD ["nginx", "-g", "daemon off;"]

You can specify CMD instructions using shell or exec forms:

CMD ["executable","param1","param2"] (exec form)
CMD ["param1","param2"] (exec form, as default parameters to ENTRYPOINT)
CMD command param1 param2 (shell form)

This (CMD) doesn’t run during the build. It’s stored as the image’s default action, waiting for someone to run docker container run against it — at which point it starts the nginx web server and keeps it running in the foreground so the container stays alive.

Important

Only one CMD takes effect per image — if you write several, only the last one counts.

Putting It Together

Here’s a small, complete Dockerfile using only what we’ve covered so far:

FROM ubuntu:22.04

WORKDIR /var/www/html

RUN apt-get update && apt-get install -y nginx

COPY index.html .

CMD ["nginx", "-g", "daemon off;"]

Read top to bottom, this says: start from a plain Ubuntu system, work inside the folder nginx serves from, install nginx, bring in a web page, and start the server when a container runs. That’s a real, usable Dockerfile — nothing in it is decorative, and nothing in it required knowing a specific programming language.

We’re not running docker build on it yet — that’s the next lesson. For now, the goal is just being able to look at this and know exactly what each line is doing and why it’s in that order.

A Few Instructions You’ll Meet Soon

Beyond the four core ones above, you’ll quickly run into a handful of others as your Dockerfiles grow — EXPOSE to document which port your app listens on, ENV to set environment variables inside the image, ARG for build-time-only variables, ENTRYPOINT as a stricter sibling of CMD. You don’t need to memorize the full set right now, and this isn’t the place to list every instruction Docker supports — the Dockerfile reference covers all of them precisely. What matters at this stage is that they all still answer one of the same handful of questions: what goes in, what happens during build, or what happens at runtime.

Thinking in Layers While You Write

Look again at the order in the Dockerfile above: RUN apt-get install -y nginx comes before COPY index.html ., not after. That wasn’t arbitrary — it’s a preview of something you’ll lean on constantly once we get to caching and rebuild speed: instructions that change rarely should come before instructions that change often.

    flowchart TB
    A["FROM ubuntu:22.04\n(changes rarely)"] --> B["RUN apt-get install nginx\n(re-runs only if this line changes)"]
    B --> C["COPY index.html .\n(changes every time your page does)"]
    C --> D["CMD nginx -g daemon off\n(never re-runs at build time)"]
  

Installing nginx is something you’ll do once and rarely touch again. Your index.html, on the other hand, might change ten times today. By installing nginx before copying in the page, Docker can reuse that install layer on every future build as long as the RUN line itself hasn’t changed — even if you edit your HTML constantly. Flip the order, and Docker has no reason to trust that the install step is still valid once anything above it has changed, so it ends up redone more often than it needs to be.

You don’t need to master caching today. Just notice that order is a decision, not an afterthought — and that decision is something you’re making every time you write a Dockerfile, whether you realize it or not.

A Light Word on Best Practices

There’s a real, well-established set of best practices for writing lean, fast, secure Dockerfiles — multi-stage builds, minimizing layers, choosing the right base image, using .dockerignore, avoiding running as root, and more. We’re going to cover that properly in the next chapter, after you’ve actually built and run an image from what you write here. Optimizing something you haven’t built yet is hard to reason about — so for now, the only “best practice” that matters is this:

Note

Write a Dockerfile that’s correct and readable first. Make it small and clever later, once you can see what you’re actually optimizing.

Wrapping Up

A Dockerfile is a short story told in a handful of verbs — FROM, WORKDIR, COPY, RUN, CMD — each one answering a simple question about how your image comes together. You don’t need the full instruction set memorized to write a useful one; you need the mental model of layers building on layers, top to bottom.

Next up, we’ll take the Dockerfile you just learned to write and actually turn it into an image with docker build — and see, for the first time, your own layers show up in docker image history.

Last updated on