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:
- Base image: the official node on alpine, with an exact tag.
- All the following commands run from
/appinside the image. - Copy only the dependency manifests.
- Install dependencies.
- Copy the rest of the code.
- Switch from root to the
nodeuser, who already exists in the official image. - By default,
node server.jsruns.
What each instruction does
| Instruction | What it does |
|---|---|
FROM image:tag | base image. The first instruction (only ARG and comments may come before it). There can be several, that's multi-stage. |
WORKDIR /path | working directory for all the following instructions and for running the container. If the directory doesn't exist, it gets created. |
COPY src dest | copies files from the build context into the image. |
ADD | like COPY, but it also unpacks local tar archives and can download from a URL. For plain file copying, use COPY. |
RUN cmd | a command at build time. The result is committed as a new layer. |
ENV KEY=VAL | an environment variable. Visible both at build time and in the running container. |
ARG NAME | a variable only for the duration of the build, set with --build-arg. |
EXPOSE 8080 | documentation: "the container listens on 8080." It doesn't publish the port. |
USER user | the 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. |
HEALTHCHECK | a command to check liveness. |
VOLUME /path | marks 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.
- Only
CMD ["node", "server.js"]. Convenient and easy to override:docker run myimg shruns a shell instead of node. - Only
ENTRYPOINT ["node", "server.js"]. The command is fixed:docker run myimg --port 3000appends--port 3000as arguments tonode server.js. - Both together, the typical pattern:
ENTRYPOINT ["node"]andCMD ["server.js"]. With no arguments,node server.jsruns.docker run myimg --inspectreplaces onlyCMD, andnode --inspectruns.
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.
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:
- pass build-time secrets with
RUN --mount=type=secret(BuildKit), see Build secrets; - pass runtime secrets at startup (
docker run -e KEY=VAL,--env-file) or through Docker secrets and an external secret store.
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
docker build -t <name>:<tag> <dir>; in the output, look forCACHEDon cached stepsdocker history <image>anddocker image inspect <image>docker run --rm <image> ls -la /appto see what made it into the imagedocker exec <name> ps -o pid,args, who sits on PID 1docker stop <name>,docker logs <name>,docker inspect -f '{{.State.ExitCode}}' <name>.dockerignore, the order ofCOPYandRUN, the wordexecin a startup script
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.
Interview-style questions
Interview questions on this topic: 4
Included with practice access, $49 one-time. Get access
Answer out loud or in writing, then reveal the answer and honestly mark whether you knew it.
Done reading?
Mark the lesson as read to move your track progress forward.
Lesson marked as done.
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.
Tell the agent: use MCP izi, give me lesson docker-basics-04-dockerfile
Progress saved in this browser
Clear your history or open this on your phone, and the marks will be gone. Leave your email and they'll move to your account. No password needed.
Done. moved to your account. You can see your progress on any device. My progress