Sign in
← Docker
18 min · medium · Lesson 4 of 8

Dockerfile

FROM, RUN, COPY, CMD, ENTRYPOINT, WORKDIR. Layer cache, .dockerignore, USER non-root, multi-stage build.

In the previous lesson you examined ready-made images: layers, tags, digests. Here you'll build your own. We'll cover Dockerfile instructions, the build cache, and why the order of lines decides whether a rebuild takes a minute or a second. We'll also look at what accidentally ends up in an image (secrets, .env), and why a container sometimes stops for exactly 10 seconds.

Why a Dockerfile

A Dockerfile is the recipe for building an image. It's a text file, the instructions are read top to bottom, and RUN, COPY, ADD each add a layer.

docker build -t myapp:1.0 .

The dot at the end sets the build context: the directory whose contents get sent to the builder. Usually that's the current directory. -t myapp:1.0 tags the resulting image. By default Docker looks for the Dockerfile at the root of the context; a different path is set with the -f flag.

Minimal example

FROM node:22.11.0-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
USER node
CMD ["node", "server.js"]

Top to bottom:

  1. Base image: the official node on alpine, with an exact tag.
  2. All the following commands run from /app inside the image.
  3. Copy only the dependency manifests.
  4. Install dependencies.
  5. Copy the rest of the code.
  6. Switch from root to the node user, who already exists in the official image.
  7. By default, node server.js runs.

What each instruction does

InstructionWhat it does
FROM image:tagbase image. The first instruction (only ARG and comments may come before it). There can be several, that's multi-stage.
WORKDIR /pathworking directory for all the following instructions and for running the container. If the directory doesn't exist, it gets created.
COPY src destcopies files from the build context into the image.
ADDlike COPY, but it also unpacks local tar archives and can download from a URL. For plain file copying, use COPY.
RUN cmda command at build time. The result is committed as a new layer.
ENV KEY=VALan environment variable. Visible both at build time and in the running container.
ARG NAMEa variable only for the duration of the build, set with --build-arg.
EXPOSE 8080documentation: "the container listens on 8080." It doesn't publish the port.
USER userthe user for the following RUN instructions and for the container's process.
CMD [...]the default command. Arguments after the image name in docker run replace it.
ENTRYPOINT [...]fixes what gets run. CMD turns into its arguments.
HEALTHCHECKa command to check liveness.
VOLUME /pathmarks a path as a volume. Docker creates an anonymous volume on every docker run, and they pile up, so this is rarely set in application images.

EXPOSE is easy to verify yourself: nginx:1.27.3-alpine's image has port 80/tcp recorded, but a container started without -p publishes nothing to the host; docker port prints nothing. Only -p at startup publishes a port.

CMD and ENTRYPOINT

Almost everyone trips up here. There are three working patterns.

How this looks in practice, on an image with ENTRYPOINT ["echo","run:"] and CMD ["default-arg"]:

docker run --rm myimg
run: default-arg
docker run --rm myimg --inspect
run: --inspect

You can replace ENTRYPOINT itself with the docker run --entrypoint flag.

Exec form and signals

CMD and ENTRYPOINT instructions are written in two forms. The exec form looks like a JSON array: CMD ["node", "server.js"]. The shell form is just a string: CMD node server.js. With a string, Docker records ["/bin/sh", "-c", "node server.js"] in the image, meaning it runs a shell.

Why this matters. docker stop sends SIGTERM to the process with PID 1 in the container, waits 10 seconds, and finishes it off with SIGKILL. If PID 1 is sh, your application never gets SIGTERM and doesn't get a chance to close connections and flush data. The container takes a long time to stop, with exit code 137 (killed by SIGKILL).

A subtlety: many shells, including sh on alpine, replace themselves with the last command in the line. So CMD node server.js in the simple case still leaves node on PID 1. Don't rely on this, though: Docker's documentation promises the opposite. But a startup script catches it for sure:

#!/bin/sh
echo "preparing directory"
mkdir -p /tmp/app
python app.py

With CMD ["sh", "start.sh"], PID 1 is sh start.sh, and python is its child:

PID   COMMAND
    1 sh start.sh
    7 python app.py

The fix is the word exec before the script's last command: exec python app.py. The shell replaces itself with the application, and SIGTERM goes straight to it.

One more detail. The process with PID 1 in a container doesn't die from SIGTERM on its own, unless it has a handler attached for it. The kernel doesn't apply the default signal action to PID 1. So the application needs a SIGTERM handler, or it needs to run with docker run --init, which puts a small init on PID 1.

Layer cache and why order matters

Docker caches layers. If an instruction and its input haven't changed since the last build, the result is taken from cache. For COPY, the input is the content of the copied files. As soon as one instruction misses the cache, everything below it gets rebuilt.

Hence the rule: put what changes rarely above, what changes often below.

Compare two versions.

Bad:

FROM node:22.11.0-alpine
WORKDIR /app
# changes on every code edit
COPY . .
# so npm ci runs again every time
RUN npm ci
CMD ["node", "server.js"]

Good:

FROM node:22.11.0-alpine
WORKDIR /app
# changes rarely
COPY package.json package-lock.json ./
# from cache, as long as the manifests are the same
RUN npm ci
# changes often, copies fast
COPY . .
CMD ["node", "server.js"]

The comments here sit on their own lines, and not just for looks. Dockerfile only treats a line that starts with # as a comment. A trailing # ... after COPY would go into the instruction's arguments and break the build.

In the second version, as long as you haven't touched dependencies, npm ci is taken from cache. Installing dependencies takes minutes, while a cached layer is applied in a fraction of a second. In the docker build output, such steps are marked CACHED.

FROM node:22.11.0-alpinefrom cache WORKDIR /appfrom cache COPY package.json ./from cache RUN npm cifrom cache COPY . .code changed CMD ["node", "server.js"] miss: rebuild and everything below too
The cache holds until the first changed instruction. Everything below it gets rebuilt.

When you only change code (not package.json), the first four lines are taken from cache. COPY . . and everything below it get rebuilt.

A catch with updating a package list:

# will be cached as a separate layer
RUN apt-get update
# will install a package using a stale list from cache
RUN apt-get install -y curl

Combine them into one instruction:

RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*

That way the cache is honest (one line, one state), and the image isn't bloated with package lists from /var/lib/apt/lists. More in Best practices: apt-get.

In scripts and Dockerfiles, write apt-get, not apt. apt has no stable interface for scripts, and it warns about this itself:

WARNING: apt does not have a stable CLI interface. Use with caution in scripts.

apt is made for a human at the terminal, apt-get for automation.

.dockerignore

The build context is the entire directory you pass to docker build. By default all of its contents go to the builder. It's a problem if it has a gigabyte of node_modules/, half a gigabyte of .git/, and an .env with passwords: COPY . . will faithfully copy them into the image.

The fix: a .dockerignore file at the root of the context:

node_modules
.git
.env
.env.*
dist
*.log
coverage

The syntax is similar to .gitignore. Files on this list don't enter the context, so they can't be copied by COPY. Even an explicit COPY .env /app/ fails with an error:

failed to compute cache key: "/.env" not found: not found

USER: don't run as root

If an image has no USER, the container's process runs as root, uid 0. It's restricted by capabilities, seccomp, and AppArmor, so that's not the same as root on the host. But without user namespaces, uid 0 in the container matches uid 0 on the host, and with a kernel vulnerability or an unlucky mount, the host is closer than you'd like. A non-root process is harder to attack.

# if the base image already has a node user:
USER node
# or create your own (alpine commands):
RUN addgroup -S app && adduser -S -G app app
USER app

After USER, all RUN instructions and the container's process run as that user.

A non-root process can open ports below 1024 in Docker: starting with Docker 20.10, containers have net.ipv4.ip_unprivileged_port_start set to 0. In other environments (for example, Kubernetes with a different runtime) that's not guaranteed, so applications usually listen on a port above 1024, for example 8080.

Secrets: not in ENV and not in ARG

A typical temptation:

ARG DB_PASSWORD
ENV DB_PASSWORD=$DB_PASSWORD

Don't do this. An ARG value used in RUN shows up in docker history, and ENV also shows up in the image's settings and in every container. Anyone with the image can see it:

docker history --no-trunc myimage
docker inspect myimage --format '{{.Config.Env}}'
...
RUN |1 DB_PASSWORD=lab-only-password /bin/sh -c echo build >/dev/null # buildkit
...
[PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin DB_PASSWORD=lab-only-password]

The right way:

Multi-stage build

The main technique for thin images. You build in one image with everything heavy (a compiler, dev dependencies), and copy only the artifact into the final, light image.

# stage 1: builder
FROM node:22.11.0-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# stage 2: runtime
# pin by digest in production: node:22.11.0-alpine@sha256:...
# (the comment must be on its own line: on the FROM line it breaks the build)
FROM node:22.11.0-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]

A subtle point. In the builder, npm ci installs all dependencies, otherwise npm run build won't run: it needs TypeScript, a bundler, and other dev tools. In the runtime stage, dependencies are installed again, but this time npm ci --omit=dev, production only. If you copied node_modules from the builder, dev dependencies would ride along into the final image, exactly what multi-stage gets rid of.

The final image ends up with the built code and runtime dependencies. No TypeScript compiler, no dev dependencies, no .git. How much the image slims down depends on the project. For compiled languages (Go, Rust) the difference is usually several times over: a single binary ships in the final stage.

The comment on the FROM line isn't a joke. The build fails like this:

dockerfile parse error line 1: FROM requires either one or three arguments

Common mistakes

COPY . . before installing dependencies. Any code edit resets the cache, and dependencies get reinstalled on every build. First copy the manifests (package.json, requirements.txt), install dependencies, then copy the code.

No .dockerignore. .env, .git, local node_modules end up in the image. You can check what made it inside like this: docker run --rm myimage ls -la /app.

A startup script without exec. docker stop waits 10 seconds, the container exits with code 137, the application doesn't log anything about shutting down. PID 1 is sh, not the application. You can check with docker exec <name> ps -o pid,args.

apt-get update on its own line. The cache holds the old package list, and apt-get install on the next line installs stale versions or fails.

A password in ENV or ARG. It's visible in docker history and docker inspect to anyone who downloaded the image.

A tag with no version in FROM. FROM node or node:22-alpine will build a different image a month from now. Write an exact tag, and in production add a digest too (previous lesson).

What you'll need for the tasks

In the next lesson we'll look at how to store state: volumes, bind mounts, environment variables, and the DB_HOST trap that catches everyone.

Done reading?

Mark the lesson as read to move your track progress forward.

Go through it with an AI agent

The agent explains anything unclear in plain words, gives you 3 labs for this lesson and drills you on interview questions. $49 one-time, lifetime access.

Get access for $49

Tell the agent: use MCP izi, give me lesson docker-basics-04-dockerfile