Dockerfile Basics
Measured on Docker Desktop 4.81.0 / Engine 29.6.1, Buildx v0.35.0-desktop.2, BuildKit v0.31.1 (macOS, linux/arm64), 2026-09-30. Build transcripts use --progress=plain (what CI logs look like); an interactive terminal shows the same steps as a live [+] Building table.
Core Dockerfile instructions
Section titled “Core Dockerfile instructions”A Dockerfile is a sequence of instructions. Docker reads them top to bottom and executes each one. Here are the instructions you will use in almost every Dockerfile.
FROM — choose your base
Section titled “FROM — choose your base”Every Dockerfile must start with FROM (only ARG and comments may come before it). It sets the starting point — the base image all subsequent instructions build on.
FROM node:24-alpinenode:24-alpine is the official Node.js image built on Alpine Linux. Always pick the most specific, smallest base image that meets your needs, and pin a version tag (not latest).
WORKDIR — set the working directory
Section titled “WORKDIR — set the working directory”WORKDIR sets the current working directory for all subsequent instructions and creates it if it does not exist. Using it avoids hard-coding paths everywhere.
WORKDIR /appProven: WORKDIR /a/b/c followed by RUN pwd printed /a/b/c, although none of the three directories existed in alpine:3.24. All COPY, RUN, and CMD instructions that follow operate relative to it.
COPY — bring files into the image
Section titled “COPY — bring files into the image”COPY copies files or directories from the build context (your project folder on the host) into the image filesystem.
COPY package.json package-lock.json ./COPY src/ ./src/The first argument is the source (relative to the build context). The second is the destination inside the image (relative to WORKDIR).
RUN — execute commands during build
Section titled “RUN — execute commands during build”RUN executes a command and commits the result as a new layer. Use it to install dependencies, compile code, or run any one-time setup.
RUN npm ci --omit=devRUN has two forms. Shell form (RUN echo "$HOME") runs through /bin/sh -c, so variables, && and pipes work. Exec form (RUN ["echo", "$HOME"]) runs the program directly with no shell. Proven with the same message in both:
#5 0.111 exec form: $HOME#6 0.109 shell form: /rootThe exec form did not expand $HOME. Use the shell form for RUN unless you need a specific executable without a shell.
ENV and ARG — configuration
Section titled “ENV and ARG — configuration”ENV sets environment variables that are baked into the image and available at both build time and runtime.
ENV NODE_ENV=productionENV PORT=3000ARG defines a build-time variable, set with --build-arg and not present at runtime:
# @buildFROM alpine:3.24ARG APP_VERSION=devENV APP_ENV=productionRUN echo "building $APP_VERSION" > /version.txtCMD ["sh", "-c", "echo APP_ENV=$APP_ENV APP_VERSION=${APP_VERSION:-unset}; cat /version.txt"]$ docker build --build-arg APP_VERSION=1.4.2 -t args .$ docker run --rm argsAPP_ENV=production APP_VERSION=unset # ENV survives into the container, ARG does notbuilding 1.4.2 # but the ARG was visible during the build$ docker run --rm -e APP_ENV=staging argsAPP_ENV=staging APP_VERSION=unset # an ENV default can be overridden at run timeNeither is a place for secrets: docker history printed ENV APP_ENV=production and even ARG APP_VERSION=1.4.2 (see the security lesson in the optimization module and the bug below).
EXPOSE — document the port
Section titled “EXPOSE — document the port”EXPOSE is documentation — it tells readers and tools which port the application listens on. It does not actually publish the port; you still need -p on docker run.
EXPOSE 3000Proven with the image built below: after docker run -d myapp:1.0, docker port printed nothing (exit 0), docker ps showed 3000/tcp with no arrow, and curl http://localhost:3000 from the Mac got 000 (connection refused). Only -p 127.0.0.1:18030:3000 made curl return Hello from Docker! NODE_ENV=production. EXPOSE matters only for -P (publish all exposed ports) and as documentation.
COPY or ADD?
Section titled “COPY or ADD?”ADD does everything COPY does plus two extras: it can fetch URLs and it auto-extracts local tar archives. That surprise is why the docs recommend COPY unless you need the extras. Proven with a pack.tgz containing a.txt:
FROM alpine:3.24COPY pack.tgz /copy/pack.tgzADD pack.tgz /add/RUN ls -R /copy /add#8 0.102 /add:#8 0.102 a.txt # ADD unpacked the archive#8 0.102 /copy:#8 0.102 pack.tgz # COPY left it alone(That fence needs a pack.tgz next to it, so the check for it is docker buildx build --check only; see the build & tag lesson for the build context.)
Building a real app image
Section titled “Building a real app image”Here is a complete, self-contained project for a minimal Node.js HTTP server. Three small files:
{ "name": "myapp", "version": "1.0.0", "private": true, "scripts": { "start": "node src/server.js" }}{ "name": "myapp", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "myapp", "version": "1.0.0" } }}const http = require('http');const port = Number(process.env.PORT) || 3000;http .createServer((req, res) => res.end(`Hello from Docker! NODE_ENV=${process.env.NODE_ENV}\n`)) .listen(port, '0.0.0.0', () => console.log(`Listening on ${port}`));(The server listens on 0.0.0.0 so a published port can reach it — see the ports lesson.) And the Dockerfile:
# Dockerfile# @buildFROM node:24-alpineWORKDIR /appCOPY package.json package-lock.json ./RUN npm ci --omit=devCOPY src/ ./src/ENV NODE_ENV=productionENV PORT=3000EXPOSE 3000CMD ["node", "src/server.js"]Build it:
docker build -t myapp:1.0 .Real build output (cold cache, --no-cache --progress=plain, trimmed to the step lines):
#4 [1/5] FROM docker.io/library/node:24-alpine#5 [2/5] WORKDIR /app#7 [3/5] COPY package.json package-lock.json ./#8 [4/5] RUN npm ci --omit=dev#8 0.367 up to date, audited 1 package in 201ms#8 0.367 found 0 vulnerabilities#8 DONE 0.4s#9 [5/5] COPY src/ ./src/#10 exporting to image#10 naming to docker.io/library/myapp:1.0 doneThere are five numbered steps, not six: ENV, EXPOSE and CMD only change image metadata and do not appear as steps. docker history shows them as zero-size entries between the real ones:
$ docker history myapp:1.0CREATED BY SIZECMD ["node" "src/server.js"] 0BEXPOSE [3000/tcp] 0BENV PORT=3000 0BENV NODE_ENV=production 0BCOPY src/ ./src/ # buildkit 249BRUN /bin/sh -c npm ci --omit=dev # buildkit 1.39MBCOPY package.json package-lock.json ./ # bui… 284BWORKDIR /app 0BCMD ["node"] ... # (from the node:24-alpine base)...Count the actual filesystem layers instead of history lines: docker image inspect node:24-alpine myapp:1.0 --format '{{len .RootFS.Layers}}' gave 4 for the base and 8 for the app image. The four additions are WORKDIR (it created /app), the first COPY, RUN npm ci, and the second COPY. ENV, EXPOSE and CMD added none (WORKDIR shows 0B because an empty directory is tiny — it is still a layer).
Run the container:
docker run --rm -p 127.0.0.1:3000:3000 myapp:1.0curl http://127.0.0.1:3000 prints Hello from Docker! NODE_ENV=production. With -e PORT=4000 the same image logs Listening on 4000, because the app reads PORT at start-up and an ENV is only a default.
Hands-on practice
Section titled “Hands-on practice”The snippet below includes a self-contained Dockerfile and a tiny inline server.js written with a shell echo — no external files needed. Paste it in Play with Docker and run it.
# syntax=docker/dockerfile:1
FROM node:24-alpine
WORKDIR /app
# Inline a minimal server so no host files are needed
RUN echo 'const h=require("http");h.createServer((_,r)=>{r.writeHead(200);r.end("Hello from Docker!\n");}).listen(3000,()=>console.log("Listening on 3000"));' > server.js
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "server.js"]
# --- build & run ---
# docker build -t myapp .
# docker run --rm -p 3000:3000 myapp
# curl http://localhost:3000Cleaning up in the same layer: measured
Section titled “Cleaning up in the same layer: measured”The classic example is apt-get. The package index and caches are deleted only from the layer they were written in, so the rm must be in the same RUN. Two Dockerfiles from debian:bookworm (139 MB base) that install curl and ca-certificates:
# @buildFROM debian:bookwormRUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificatesRUN rm -rf /var/lib/apt/lists/*# @buildFROM debian:bookwormRUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \ && rm -rf /var/lib/apt/lists/*debian:bookworm 139MBapt-one (one RUN) 156MB # the install layer is 16.6MBapt-two (two RUNs) 175MB # the install layer is 36.2MB and the rm layer hides 19.6MB of listsSame final filesystem, 19 MB apart. (Both build lines need network access to the Debian mirrors, and the numbers are arm64 packages.)
Reading AI-generated code
Section titled “Reading AI-generated code”An AI-written Dockerfile for an app that “needs an API key”:
🐛 Spot the bug
FROM alpine:3.24# AI: "bake the key in so the container works out of the box"ENV API_KEY=<your-api-key>CMD ["sh", "-c", "echo calling api with $API_KEY"]Show the answer
ENV bakes the value into the image config, where every consumer of the image can read it without running anything. Proven (the placeholder is fake on purpose):
$ docker history --no-trunc secret-demoCMD ["sh" "-c" "echo calling api with $API_KEY"]ENV API_KEY=<your-api-key>$ docker image inspect secret-demo --format '{{json .Config.Env}}'["PATH=/usr/local/sbin:...:/bin","API_KEY=<your-api-key>"]The static check catches this class of mistake too: docker buildx build --check reports WARNING: SecretsUsedInArgOrEnv - Do not use ARG or ENV instructions for sensitive data (ENV "API_KEY"), which is exactly what the course harness treats as a failure. Anyone who can pull the image gets the key, and it is stuck in the layer history even if you later ENV API_KEY= it away. Do not bake it in; require it at run time and fail loudly when it is missing:
# @buildFROM alpine:3.24CMD ["sh", "-c", "echo calling api with ${API_KEY:?API_KEY must be set}"]$ docker run --rm secret-fixedsh: API_KEY: API_KEY must be set # exit code 2$ docker run --rm -e API_KEY='<your-api-key>' secret-fixedcalling api with <your-api-key>$ docker image inspect secret-fixed --format '{{json .Config.Env}}'["PATH=/usr/local/sbin:...:/bin"] # nothing baked inBuild-time secrets use RUN --mount=type=secret, covered in the optimization module. Run-time secrets belong in a secret store, not in -e on shared hosts (see the ports & env lesson).
Mental Model
Section titled “Mental Model”Read a Dockerfile as two lists interleaved: instructions that change the filesystem (RUN, COPY, ADD, sometimes WORKDIR) and instructions that change the image config (ENV, EXPOSE, CMD, ENTRYPOINT, ARG, LABEL). Only the first list makes numbered build steps and layers. The config list is what a container starts with: default environment, default command, documented ports. Nothing in the config list opens a port or hides a secret.
Trade-offs
Section titled “Trade-offs”| Option | Benefit | Cost |
|---|---|---|
Combine commands with && in one RUN | Fewer layers; cleanup really shrinks the image (156 MB vs 175 MB) | Long lines, harder to read, harder to tell which part failed |
Split into multiple RUN lines | Easy to read, each step is clear and cached separately | More layers, larger image than necessary when cleanup is in a later step |
COPY | Predictable: copies exactly what you name | Cannot extract archives or download |
ADD | Auto-extracts local tarballs, can fetch URLs | Surprising behaviour; remote fetches are not cache- or checksum-friendly |
ENV default + -e override | Sensible defaults, still configurable | Value is visible in the image; not for secrets |
Common mistakes
Section titled “Common mistakes”- Splitting cleanup into a separate
RUNfrom the install, so the image keeps the deleted files (19 MB of apt lists here). - Not setting
WORKDIRand hard-coding the full path repeatedly in every instruction, so changing the path means changing it in many places. - Assuming
EXPOSEactually opens the port, when it is just documentation — you still need-pondocker run. - Putting credentials in
ENVorARG; both are readable indocker historyanddocker inspect. - Using the exec form of
RUNand expecting$VARIABLEto expand.
The Dockerfile best practices page covers pinning base images, ordering for cache, multi-stage builds, and COPY over ADD; its recommended patterns are what this lesson’s Dockerfile follows.
Common Misconceptions
Section titled “Common Misconceptions”- “Each
FROM,RUN,COPYandENVcreates a layer.” OnlyRUN/COPY/ADD(andWORKDIRcreating a directory) do. The build above had 5 steps and 4 new layers for 9 instructions. - “The build log shows one step per instruction.”
ENV,EXPOSE,CMDare not numbered steps; the log said[5/5]for the lastCOPY. - “
EXPOSE 3000means the host can reach port 3000.”docker portprinted nothing andcurlwas refused until-pwas used. - “
ARGis a safe way to pass a token.” It is stored in the image history (ARG APP_VERSION=1.4.2was visible). - “
ADDis just a fancierCOPY.” It extractedpack.tgz;COPYdid not.