Skip to content

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.

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.

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-alpine

node: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 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 /app

Proven: 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 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 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=dev

RUN 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: /root

The exec form did not expand $HOME. Use the shell form for RUN unless you need a specific executable without a shell.

ENV sets environment variables that are baked into the image and available at both build time and runtime.

ENV NODE_ENV=production
ENV PORT=3000

ARG defines a build-time variable, set with --build-arg and not present at runtime:

Dockerfile.args
# @build
FROM alpine:3.24
ARG APP_VERSION=dev
ENV APP_ENV=production
RUN echo "building $APP_VERSION" > /version.txt
CMD ["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 args
APP_ENV=production APP_VERSION=unset # ENV survives into the container, ARG does not
building 1.4.2 # but the ARG was visible during the build
$ docker run --rm -e APP_ENV=staging args
APP_ENV=staging APP_VERSION=unset # an ENV default can be overridden at run time

Neither 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 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 3000

Proven 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.

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:

Dockerfile.add
FROM alpine:3.24
COPY pack.tgz /copy/pack.tgz
ADD 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.)

Here is a complete, self-contained project for a minimal Node.js HTTP server. Three small files:

package.json
{
"name": "myapp",
"version": "1.0.0",
"private": true,
"scripts": { "start": "node src/server.js" }
}
package-lock.json
{
"name": "myapp",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "myapp",
"version": "1.0.0"
}
}
}
src/server.js
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
# @build
FROM node:24-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY src/ ./src/
ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000
CMD ["node", "src/server.js"]

Build it:

Terminal window
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 done

There 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.0
CREATED BY SIZE
CMD ["node" "src/server.js"] 0B
EXPOSE [3000/tcp] 0B
ENV PORT=3000 0B
ENV NODE_ENV=production 0B
COPY src/ ./src/ # buildkit 249B
RUN /bin/sh -c npm ci --omit=dev # buildkit 1.39MB
COPY package.json package-lock.json ./ # bui… 284B
WORKDIR /app 0B
CMD ["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:

Terminal window
docker run --rm -p 127.0.0.1:3000:3000 myapp:1.0

curl 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.

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:3000

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:

Dockerfile.apt-two
# @build
FROM debian:bookworm
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates
RUN rm -rf /var/lib/apt/lists/*
Dockerfile.apt-one
# @build
FROM debian:bookworm
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
debian:bookworm 139MB
apt-one (one RUN) 156MB # the install layer is 16.6MB
apt-two (two RUNs) 175MB # the install layer is 36.2MB and the rm layer hides 19.6MB of lists

Same final filesystem, 19 MB apart. (Both build lines need network access to the Debian mirrors, and the numbers are arm64 packages.)

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-demo
CMD ["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:

Dockerfile.secret-fixed
# @build
FROM alpine:3.24
CMD ["sh", "-c", "echo calling api with ${API_KEY:?API_KEY must be set}"]
$ docker run --rm secret-fixed
sh: API_KEY: API_KEY must be set # exit code 2
$ docker run --rm -e API_KEY='<your-api-key>' secret-fixed
calling api with <your-api-key>
$ docker image inspect secret-fixed --format '{{json .Config.Env}}'
["PATH=/usr/local/sbin:...:/bin"] # nothing baked in

Build-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).

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.

OptionBenefitCost
Combine commands with && in one RUNFewer 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 linesEasy to read, each step is clear and cached separatelyMore layers, larger image than necessary when cleanup is in a later step
COPYPredictable: copies exactly what you nameCannot extract archives or download
ADDAuto-extracts local tarballs, can fetch URLsSurprising behaviour; remote fetches are not cache- or checksum-friendly
ENV default + -e overrideSensible defaults, still configurableValue is visible in the image; not for secrets
  • Splitting cleanup into a separate RUN from the install, so the image keeps the deleted files (19 MB of apt lists here).
  • Not setting WORKDIR and hard-coding the full path repeatedly in every instruction, so changing the path means changing it in many places.
  • Assuming EXPOSE actually opens the port, when it is just documentation — you still need -p on docker run.
  • Putting credentials in ENV or ARG; both are readable in docker history and docker inspect.
  • Using the exec form of RUN and expecting $VARIABLE to 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.

  • “Each FROM, RUN, COPY and ENV creates a layer.” Only RUN/COPY/ADD (and WORKDIR creating 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, CMD are not numbered steps; the log said [5/5] for the last COPY.
  • “EXPOSE 3000 means the host can reach port 3000.” docker port printed nothing and curl was refused until -p was used.
  • “ARG is a safe way to pass a token.” It is stored in the image history (ARG APP_VERSION=1.4.2 was visible).
  • “ADD is just a fancier COPY.” It extracted pack.tgz; COPY did not.
Which instruction sets the working directory for all subsequent Dockerfile instructions?
What does EXPOSE actually do?
Why should you combine multiple shell commands in a single RUN instruction using &&?
Which instruction copies files from the host build context into the image?
A Dockerfile with WORKDIR, COPY, RUN, COPY, ENV, ENV, EXPOSE, CMD on top of a 4-layer base builds in how many numbered steps, and how many layers does the image have?
A Dockerfile contains ENV API_KEY=<your-api-key>. Who can read the value?