docker run --rm -it handbook:latest

The Docker handbook

From "what even is an image" to a hardened multi-stage build. Every concept with a diagram and a runnable example, every flag you'll actually type, and a troubleshooting table organised by the error message you're staring at.

No prior Docker knowledge assumed. If you can use a terminal and have installed Docker Desktop or docker on Linux, you can run every command on this page. Examples build up one small Node + Postgres app, but the point is always the Docker concept — the app is just something concrete to point at.
Virtual machines Containers App A App B App C Guest OS ~1 GB each Guest OS ~1 GB each Guest OS ~1 GB each Hypervisor Host OS Hardware App A App B App C libs only ~10-100 MB libs only ~10-100 MB libs only ~10-100 MB Container runtime (containerd) Host OS — one shared kernel Hardware A VM virtualises the machine and repeats the whole OS. A container isolates a process on the OS you already booted — which is why it starts in milliseconds instead of minutes, and why a Linux container needs a Linux kernel.
Part 1 · The idea

Start here

Three sections. The second one — images and layers — is the model that makes every later confusion evaporate, so don't skip it.

Never used Docker?Read Part 1 and 2 in order, typing the commands as you go. Everything runs locally.
Know the basics?Jump to 07 · Dockerfile and 08 · Multi-stage, then Part 3.
Something's broken?Troubleshooting is organised by the exact error text.

CONCEPTWHAT IT ISWHY·

01 What Docker actually does

It packages an application with everything it needs to run, then starts it as an ordinary process that thinks it has the machine to itself.

"It works on my machine" is a dependency problem: your laptop has Node 20, Python 3.11 and a particular OpenSSL, and the server doesn't. Docker's answer is to ship the dependencies with the app as a single artefact — an image — and then run it in isolation so it can't collide with anything else on the host.

In plain English

An image is a frozen, read-only snapshot of a filesystem plus a note saying which command to run. It's a class.

A container is that image, running. It's an instance. You can start twenty containers from one image and they don't interfere with each other.

A registry (Docker Hub, GHCR, ECR) is where images are stored and shared. It's npm for filesystems.

A container is not a virtual machine

This is the misconception worth killing on day one. A VM boots a whole second operating system on emulated hardware. A container is just a process on your existing kernel, wrapped in three Linux features that make it feel alone:

FeatureWhat it hidesWhat you notice
namespacesGives the process its own view of process IDs, network interfaces, mounts, hostname and users.Your app is PID 1 inside. ps in the container shows only its own processes. It has its own IP.
cgroupsCaps how much CPU, memory and I/O the process may use.--memory=512m works. Exceeding it gets the container killed, not the host.
union filesystemStacks read-only image layers with one thin writable layer on top.Twenty containers from one image cost one copy of the image on disk. See section 02.
Gotcha — the one that surprises everyone

Because containers share the host kernel, a Linux container needs a Linux kernel. On macOS and Windows, Docker Desktop quietly runs a small Linux VM and your containers live inside that. This explains three things beginners find baffish: why bind-mounted file I/O is slower on a Mac, why localhost means something different in each place, and why a container built on an Apple Silicon Mac can fail on an x86 server with exec format error.

Your first two minutes

prove the whole idea in four commandsterminal
$ docker run hello-world              # pulls an image, runs it, prints, exits

$ docker run -it --rm alpine sh       # a whole Linux userland, in about a second
/ # cat /etc/os-release
NAME="Alpine Linux"                   # ...even if your host is Ubuntu or macOS
/ # ps
PID   USER     COMMAND
    1 root     sh                     # your shell is PID 1. it sees nothing else.
/ # exit                              # --rm means the container is deleted on exit

$ docker run -d -p 8080:80 --name web nginx
$ curl localhost:8080                 # a web server, no install, no config
What just happened

docker run did four things in order: looked for the image locally, pulled it from Docker Hub because it wasn't there, created a container from it, and started it. The -d detached it to the background; -p 8080:80 forwarded your laptop's port 8080 to port 80 inside. Nothing was installed on your machine — docker rm -f web leaves no trace.

LAYERSIZECACHEDSHARED

02 Images & layers

The model that explains build speed, image size, and why the order of your Dockerfile matters enormously.

An image isn't one blob. Each instruction in a Dockerfile creates a layer — a diff of the filesystem — and the image is those layers stacked. Layers are read-only, content-addressed, and shared: if ten images start FROM node:20-alpine, that base is stored once.

Image: myapp:1.0 — read-only layers COPY . . 12 MB RUN npm ci 180 MB COPY package*.json ./ 4 KB WORKDIR /app 0 B FROM node:20-alpine the base — shared by every image built on it 140 MB total on disk: 332 MB — stored once, however many containers run container A · writable layer logs, temp files, anything it writes — a few KB container B · writable layer separate. B cannot see A's files. container C · writable layer deleted with the container. this is why data needs volumes. all three read the same layers — copy-on-write means a file is only copied up if written to

Three containers, one image. Disk cost is 332 MB plus three thin writable layers — not 996 MB.

The build cache, and the one rule that follows from it

When you rebuild, Docker walks your Dockerfile top to bottom and reuses a cached layer as long as the instruction and its inputs are unchanged. The moment one layer misses, every layer after it is rebuilt. That single fact dictates how you order a Dockerfile.

the same build, ordered two waysDockerfile
# SLOW — re-installs every dependency on any source change
FROM node:20-alpine
WORKDIR /app
COPY . .                  # ← changes whenever ANY file changes...
RUN npm ci                # ← ...so this 90-second step never hits cache
CMD ["node", "server.js"]

# FAST — dependencies only reinstall when the lockfile changes
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./   # ← rarely changes
RUN npm ci                                # ← cached until the lockfile does
COPY . .                                  # ← cheap layer, changes constantly
CMD ["node", "server.js"]
The rule

Order instructions from least-likely-to-change to most-likely-to-change. Base image, then system packages, then dependency manifests, then dependency install, then your source code last. A 90-second rebuild becomes 3 seconds, and it costs nothing but line order.

Gotcha — layers are append-only

Deleting a file in a later layer doesn't shrink the image; it just marks it hidden. The bytes are still in the earlier layer, and anyone with the image can extract them. This is why the following is a real, common security bug:

RUN curl -H "Authorization: $TOKEN" ... && rm /root/.netrc   # token is STILL in the layer

Cleanup only helps within a single RUN: RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* works because the install and the delete happen in one layer. For real secrets, use build secrets (section 14).

seeing the layersterminal
$ docker history myapp:1.0
IMAGE          CREATED         CREATED BY                       SIZE
a1b2c3d4       2 minutes ago   CMD ["node" "server.js"]         0B
<missing>      2 minutes ago   COPY . . # buildkit              12MB
<missing>      5 minutes ago   RUN npm ci # buildkit            180MB    ← the big one
<missing>      5 minutes ago   COPY package*.json ./            4.1kB
<missing>      3 weeks ago     /bin/sh -c #(nop) CMD ["node"]   0B

$ docker image inspect myapp:1.0 --format '{{.RootFS.Layers}}'   # the content hashes
Tags are labels, not versions

A tag is a movable pointer, like a git branch. node:20 means something different this month than last. The immutable identifier is the digest: node:20@sha256:abc.... Use tags while developing; pin digests when a build must be reproducible. And latest is not special — it's just the tag applied when you don't name one.

STATECOMMANDDATA·

03 The container lifecycle

Where a container goes when it stops — and why a stopped container is not a deleted one.

Beginners routinely think docker stop removes things. It doesn't. A stopped container still exists, still holds its writable layer, and still occupies its name — which is why docker run --name web a second time fails with "name is already in use" even though nothing is running.

image read-only created exists, never ran running PID 1 alive exited still on disk gone create start stop rm — writable layer destroyed start again — the writable layer survives a stop docker run = create + start docker run --rm = ... + rm on exit

The green path is what makes stopped containers useful — you can restart one and its files are still there. It's also what silently fills your disk.

the lifecycle, hands onterminal
$ docker run -d --name web nginx     # create + start
$ docker ps                          # RUNNING containers only
$ docker stop web                    # SIGTERM, then SIGKILL after 10s
$ docker ps                          # empty — but the container still exists
$ docker ps -a                       # -a = ALL, including exited. here it is.
CONTAINER ID   IMAGE   STATUS                     NAMES
9f2c8ad1c41d   nginx   Exited (0) 12 seconds ago  web

$ docker start web                   # same container, same files, back up
$ docker rm -f web                   # -f stops it first. NOW it's gone.

$ docker container prune             # delete every stopped container
$ docker system df                   # what Docker is using your disk for
Gotcha — exit codes tell you what killed it
Exit codeMeaning
0Clean exit. For a server, this usually means the process finished when you expected it to keep running.
1 / 2The application itself errored. Read docker logs.
125The docker run command itself was wrong — a bad flag.
126The command was found but isn't executable. Missing chmod +x.
127Command not found inside the container. Usually a typo, or it isn't in that base image.
137SIGKILL — almost always out of memory. Raise --memory or fix the leak.
143SIGTERM. A normal docker stop.
A container lives exactly as long as its PID 1

There is no "container process" separate from your app. When the command in CMD exits, the container exits — which is why docker run ubuntu stops instantly (bash with no terminal has nothing to do) and why you cannot "start a container and then run things in it" as a two-step. Give it a long-running process, or use -it to hand it a terminal.

Part 2 · Daily driving

The eight things you'll do every day

Running, inspecting, building, storing, connecting. Every section has a copyable example that works as written.


FLAGDOESUSE WHEN·

04 docker run

One command with fifty flags. These twelve cover almost everything you'll ever type.

the shape of the commandsyntax
docker run [FLAGS] IMAGE [COMMAND] [ARGS...]
           └─ for docker    └─ what to run instead of the image's CMD

$ docker run -d --name api -p 3000:3000 -e NODE_ENV=production myapp:1.0
$ docker run --rm -it node:20-alpine node --version   # override the CMD
FlagWhat it doesNotes
-dDetach — run in the background and print the container ID.Without it your terminal is attached to the container's output.
-itInteractive + TTY. Gives you a usable shell.Two flags: -i keeps stdin open, -t allocates a terminal. Always used together.
--rmDelete the container when it exits.Use it for every throwaway command. Prevents the slow accumulation of dead containers.
--name XA stable name instead of boring_einstein.Also the DNS hostname other containers use on a user-defined network.
-p 8080:80Publish: host port 8080 → container port 80.host:container, in that order. -p 80 picks a random host port. -p 127.0.0.1:8080:80 binds to localhost only — do this for databases.
-v name:/pathMount a named volume for data that must survive.See section 09. -v ./src:/app/src instead bind-mounts a host directory.
-e KEY=valueSet an environment variable.--env-file .env for many. Never pass real secrets this way in production — they show in docker inspect.
--network XAttach to a specific network.Required for container-to-container DNS. See section 10.
--restart unless-stoppedRestart automatically after a crash or a reboot.Options: no (default), on-failure[:n], always, unless-stopped. The last is usually what you want.
--memory 512m
--cpus 1.5
Hard resource caps.Exceeding memory gets you exit code 137. See section 13.
-u 1000:1000Run as a specific uid:gid instead of root.Fixes most bind-mount permission problems on Linux.
-w /appWorking directory inside the container.Overrides the image's WORKDIR.
A realistic command, decoded
$ docker run -d \
    --name api \                        # addressable as "api" by other containers
    --network appnet \                  # a user-defined network, so DNS works
    -p 127.0.0.1:3000:3000 \            # only reachable from this machine
    -e NODE_ENV=production \
    --env-file .env \                   # the rest of the config
    -v applogs:/var/log/app \           # named volume — survives rm
    --restart unless-stopped \          # comes back after a reboot
    --memory 512m --cpus 1 \            # can't take the host down
    myapp:1.0
Gotcha — the three that cost the most time
  • Port order. -p 3000:8080 means "host 3000 → container 8080". Reversing it produces a port that answers nothing. Host first, always.
  • Your app must bind 0.0.0.0, not 127.0.0.1. A server listening on localhost inside the container is listening on the container's own loopback, and -p can never reach it. This is the single most common "the port is published but I get connection refused".
  • -e after the image name is not a flag. Everything after the image is the container's command. docker run myapp -e FOO=1 passes -e FOO=1 to your app.
COMMANDANSWERS··

05 Inspecting & debugging

Six commands answer nearly every "why isn't this working".

CommandAnswers
docker ps -aWhat exists and what state it's in. -a is the important part — without it you can't see the container that just died.
docker logs -f --tail 100 XWhat the app printed. This is stdout/stderr of PID 1 only — an app logging to a file inside the container shows nothing here.
docker exec -it X shA shell inside a running container. Use bash if the image has it, sh on Alpine.
docker inspect XEverything: mounts, networks, env, the resolved command, exit code, health. Verbose — filter it.
docker statsLive CPU, memory and network per container. Memory climbing toward the limit explains an incoming exit 137.
docker eventsA live feed of every create/start/die/kill. Run it in a second terminal while reproducing a problem.
inspect, without drowning in JSONterminal
$ docker inspect api --format '{{.State.Status}} {{.State.ExitCode}}'
exited 137                                       # OOM killed

$ docker inspect api --format '{{json .Mounts}}' | jq        # where is data going?
$ docker inspect api --format '{{.NetworkSettings.IPAddress}}'
$ docker inspect api --format '{{range .Config.Env}}{{println .}}{{end}}'
$ docker inspect api --format '{{.State.Health.Status}}'     # if a HEALTHCHECK exists

# which host port did Docker pick?
$ docker port api
3000/tcp -> 127.0.0.1:3000
debugging a container that won't stay upterminal
# 1. It exited. What did it say on the way out?
$ docker logs api

# 2. Nothing useful? Start it with a shell INSTEAD of its normal command.
$ docker run --rm -it --entrypoint sh myapp:1.0
/app # ls -la              # are the files where you think?
/app # cat package.json
/app # node server.js      # run it by hand and watch it fail properly

# 3. Image has no shell (distroless/scratch)? Attach one.
$ docker run --rm -it --pid=container:api --network=container:api \
      nicolaka/netshoot

# 4. Copy a file out of a dead container to look at it
$ docker cp api:/app/config.json ./config.json

# 5. What changed in the container's filesystem since it started?
$ docker diff api
C /app
A /app/tmp/upload-9f2c.tmp
exec vs run — they are not alternatives

docker run creates a new container from an image. docker exec runs a command in an existing, running one. If a container has exited, exec cannot help you — that's when you reach for --entrypoint sh on a fresh container, as above.

REPOSITORYTAGDIGESTSIZE

06 Images & registries

Building, naming, sharing — and reclaiming the 40 GB Docker quietly took.

the image name is a full addressreference
ghcr.io/acme/myapp:1.4.2
└──┬──┘ └─┬─┘ └─┬─┘ └─┬─┘
   │      │     │     └── tag — a movable pointer, NOT a version guarantee
   │      │     └──────── image name
   │      └────────────── namespace / user / org
   └───────────────────── registry. omitted = docker.io (Docker Hub)

nginx                    →  docker.io/library/nginx:latest
acme/myapp:1.0           →  docker.io/acme/myapp:1.0
ghcr.io/acme/myapp:1.0   →  GitHub Container Registry
myapp@sha256:9f2c8a...   →  a digest. immutable. the only truly reproducible form.
the image commands you needterminal
$ docker build -t myapp:1.0 .          # -t tags it; "." is the BUILD CONTEXT
$ docker build -t myapp:1.0 -f docker/Dockerfile.prod .
$ docker images                         # what's local
$ docker pull postgres:16-alpine        # fetch without running

# tag = add another name to the SAME image. it doesn't copy anything.
$ docker tag myapp:1.0 ghcr.io/acme/myapp:1.0
$ docker login ghcr.io
$ docker push ghcr.io/acme/myapp:1.0

$ docker rmi myapp:1.0                  # remove one image
$ docker save myapp:1.0 | gzip > myapp.tgz     # move an image without a registry
$ gunzip -c myapp.tgz | docker load
Reclaiming disk — the command everyone eventually needs
$ docker system df                      # what is actually using space
TYPE            TOTAL   ACTIVE   SIZE      RECLAIMABLE
Images          47      4        23.8GB    19.2GB (80%)
Containers      31      2        1.1GB     1.09GB (99%)
Local Volumes   12      3        8.4GB     6.2GB (73%)
Build Cache     284     0        11.3GB    11.3GB

$ docker system prune                   # stopped containers, unused networks, dangling images
$ docker system prune -a                # ALSO every image not used by a container
$ docker system prune -a --volumes      # ...and volumes. THIS DELETES DATA.
$ docker builder prune                  # just the build cache — usually the biggest win
Gotcha — the build context

The . at the end of docker build -t myapp . is not decoration. It's the directory Docker uploads to the daemon before building. Run it in a folder containing node_modules, .git and a few build artefacts and you'll wait thirty seconds for "sending build context to Docker daemon 1.4GB" before a single instruction executes.

The fix is a .dockerignore file, which works exactly like .gitignore and is the cheapest build speedup available:

node_modules
.git
dist
*.log
.env
**/__pycache__
Dockerfile
.dockerignore

It also protects you: without it, COPY . . happily bakes your .env and .git history into the image.

Dangling images

The <none>:<none> entries in docker images are old layers that lost their tag when you rebuilt with the same name. They're harmless but accumulate fast: docker image prune clears them.

INSTRUCTIONLAYERBUILD/RUN·

07 The Dockerfile

A recipe read top to bottom. Every line is a step, and most of them become a layer.

Dockerfile — annotated, and in the right orderDockerfile
# syntax=docker/dockerfile:1          # opt into the latest BuildKit features

FROM node:20-alpine                    # the base. every Dockerfile starts here.

WORKDIR /app                           # cd, and mkdir -p if it doesn't exist.
                                       # use this, never "RUN cd /app" — cd doesn't persist

RUN apk add --no-cache tini            # system packages. one RUN = one layer.

COPY package.json package-lock.json ./ # dependency manifests FIRST — see section 02
RUN npm ci --omit=dev

COPY . .                               # source last: the layer that always changes

ENV NODE_ENV=production \              # available at build AND run time
    PORT=3000

EXPOSE 3000                            # DOCUMENTATION ONLY. does not publish a port.

USER node                              # drop root. everything after runs as `node`.

HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD wget -qO- http://localhost:3000/health || exit 1

ENTRYPOINT ["/sbin/tini", "--", "node"]  # the executable
CMD ["server.js"]                        # its default argument, overridable

RUN vs CMD vs ENTRYPOINT

These three get confused more than anything else in Docker, and they do completely unrelated jobs.

When it runsHow manyPurpose
RUNBuild time. Its result is baked into a layer.As many as you likeInstall packages, compile, create directories.
CMDRun time. The default command, replaced by anything you type after the image name.Only the last one countsThe default thing this image does.
ENTRYPOINTRun time. The fixed executable. Arguments get appended to it.Only the last one countsMake the image behave like a single binary.
how they combinebehaviour
ENTRYPOINT ["node"]        CMD ["server.js"]
$ docker run myapp                    →  node server.js
$ docker run myapp --inspect          →  node --inspect        # CMD replaced
$ docker run --entrypoint sh myapp    →  sh                    # ENTRYPOINT overridden

# CMD alone — simplest, fully overridable. fine for most apps.
CMD ["node", "server.js"]
$ docker run myapp bash               →  bash

# ENTRYPOINT alone — the image IS the tool.
ENTRYPOINT ["curl"]
$ docker run mycurl https://example.com   →  curl https://example.com
Gotcha — shell form breaks Ctrl-C and graceful shutdown

Both instructions accept two forms, and they are not equivalent:

CMD node server.js            # SHELL form → runs /bin/sh -c "node server.js"
CMD ["node", "server.js"]     # EXEC form  → runs node directly

In shell form, PID 1 is sh, not your app. docker stop sends SIGTERM to PID 1, sh doesn't forward it, your app never hears it, and ten seconds later everything is SIGKILLed — dropping in-flight requests on every single deploy. Always use the exec form (JSON array, double quotes).

Exec form has one catch: no shell means no variable expansion or pipes. CMD ["echo", "$HOME"] prints a literal $HOME. If you genuinely need a shell, be explicit: CMD ["sh", "-c", "exec node server.js"] — the exec makes node replace the shell as PID 1.

ARG vs ENV

build-time vs run-time variablesDockerfile
ARG NODE_VERSION=20            # BUILD time only. gone at runtime.
FROM node:${NODE_VERSION}-alpine

ARG BUILD_ID                   # must be re-declared after FROM to be usable
ENV APP_VERSION=${BUILD_ID}    # promote it into the image if the app needs it

ENV LOG_LEVEL=info             # BUILD and RUN time. overridable with -e.

# $ docker build --build-arg BUILD_ID=abc123 -t myapp .
# $ docker run -e LOG_LEVEL=debug myapp        ← ENV can be overridden, ARG cannot
Gotcha — never put a secret in ARG or ENV

Both are stored in the image metadata in plaintext. docker history shows every ARG value, and docker inspect shows every ENV — to anyone who can pull the image. For build-time credentials use BuildKit secrets (section 14); for runtime credentials inject them at docker run time from a secret manager.

COPY vs ADD

Use COPY. It copies files, and that's all it does — which is what you want and what a reviewer can verify at a glance.

ADD also fetches URLs and silently auto-extracts local tar archives, which surprises people and makes builds harder to reason about. Its one legitimate use is unpacking a tarball you already have. To fetch a remote file, use RUN curl so you can verify a checksum in the same layer.

COPY, the flags worth knowingDockerfile
COPY src/ /app/src/                  # trailing slash = directory
COPY --chown=node:node . /app        # set ownership as you copy — saves a RUN chown layer
COPY --from=builder /app/dist ./dist # from another BUILD STAGE — see section 08
COPY --from=nginx:alpine /etc/nginx/nginx.conf ./   # or straight out of another image
Which base image?
BaseSizeUse when
node:20~1.1 GBYou need a full Debian toolchain. Fine for a build stage, wasteful for the final image.
node:20-slim~200 MBSensible default. Debian, glibc, most things work.
node:20-alpine~140 MBSmall and popular — but it uses musl libc, not glibc. Native modules may need rebuilding and some prebuilt binaries simply won't run.
gcr.io/distroless/nodejs20~110 MBNo shell, no package manager, minimal attack surface. Excellent for production, painful to debug.
scratch0 BCompletely empty. Only for a static binary — Go, Rust — with no libc at all.

Start with -slim. Move to alpine or distroless when image size actually becomes a problem you can measure.

STAGEKEPTDISCARDEDSIZE

08 Multi-stage builds

Build with a full toolchain, ship only the result. Usually a 10× size reduction for one extra line.

Compilers, dev dependencies, test fixtures and build caches are needed to produce your application and are dead weight — and extra attack surface — in the thing you deploy. A multi-stage build uses several FROM statements: earlier stages do the work, and the final stage copies out only the artefacts.

stage 1 · builder FROM node:20 AS builder gcc, make, python3 (build tools) devDependencies, test files source, .git, caches /app/dist ← the only thing that matters 1.1 GB — thrown away entirely COPY --from=builder stage 2 · final FROM node:20-alpine node runtime only /app/dist production node_modules 180 MB — this is what ships

No compiler in the shipped image means nothing to compile an exploit with, and 900 MB less to pull on every deploy.

Dockerfile — a real multi-stage buildDockerfile
# syntax=docker/dockerfile:1

# ---------- stage 1: install + build ----------
FROM node:20 AS builder
WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci                              # ALL deps, including dev — we need them to build

COPY . .
RUN npm run build                       # produces /app/dist
RUN npm prune --omit=dev                # strip devDependencies from node_modules

# ---------- stage 2: the image that ships ----------
FROM node:20-alpine AS final
WORKDIR /app
ENV NODE_ENV=production

COPY --from=builder --chown=node:node /app/node_modules ./node_modules
COPY --from=builder --chown=node:node /app/dist ./dist
COPY --from=builder --chown=node:node /app/package.json ./

USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
the same idea in Go — where it gets extremeDockerfile
FROM golang:1.23 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /bin/app ./cmd/server
     #  ↑ static binary, no libc needed at all

FROM scratch                            # literally empty
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /bin/app /app
USER 65534:65534                        # nobody
ENTRYPOINT ["/app"]
# final image: ~8 MB, containing one binary and a CA bundle. Nothing to exploit.
Extra powers you get for free
$ docker build --target builder -t myapp:dev .   # stop at a stage — great for CI test steps

# stages can be a test gate: if this fails, the build fails
FROM builder AS test
RUN npm run lint && npm test

# stages build in parallel when they don't depend on each other (BuildKit)
Gotcha

Only the last stage becomes the image unless you pass --target. And COPY --from copies files, not environment: ENV, WORKDIR and USER from the builder do not carry over — re-declare them in the final stage. Alpine and Debian also differ in libc, so a binary compiled in a node:20 builder can fail in an alpine final stage with "not found" even though the file is clearly there. Keep both stages on the same distro family, or build statically.

TYPELIVESSURVIVES rmUSE FOR

09 Volumes & data

A container's filesystem dies with it. Anything you want to keep has to live outside.

Everything a container writes goes into its thin writable layer, and docker rm deletes that layer. Run Postgres without a volume and every row vanishes the moment you recreate the container — which you do on every image update. There are three ways to keep data, and picking the right one is mostly about who owns the files.

container writable layer — dies with rm /var/lib/postgresql/data · /app/src · /tmp named volume -v pgdata:/var/lib/postgresql/data /var/lib/docker/volumes/pgdata/_data Docker owns it. Survives rm. databases, uploads, state bind mount -v ./src:/app/src a real folder on your machine You own it. Edits appear live. development, config files tmpfs --tmpfs /tmp host RAM, never touches disk Gone on stop. By design. secrets, scratch space a mount replaces whatever was at that path in the image — it does not merge

Same syntax, three very different lifetimes. Named volumes for data you'd cry about losing; bind mounts for code you're editing.

all three, in practiceterminal
# named volume — Docker manages the location. the default choice for data.
$ docker volume create pgdata
$ docker run -d --name db -v pgdata:/var/lib/postgresql/data \
      -e POSTGRES_PASSWORD=secret postgres:16-alpine

$ docker rm -f db                     # container gone
$ docker run -d --name db -v pgdata:/var/lib/postgresql/data ... postgres:16-alpine
                                      # ...and every row is still there

# bind mount — live editing. no rebuild between saves.
$ docker run -d --name web \
      -v "$(pwd)/src:/app/src" \      # absolute path required
      -v "$(pwd)/nginx.conf:/etc/nginx/nginx.conf:ro" \   # :ro = read-only
      -p 3000:3000 myapp:dev

# tmpfs — in RAM, never written to disk
$ docker run --tmpfs /tmp:rw,size=64m myapp

# managing volumes
$ docker volume ls
$ docker volume inspect pgdata        # where is it really?
$ docker volume rm pgdata             # refuses if a container uses it
$ docker volume prune                 # delete every unused volume. DESTROYS DATA.
Backing up a volume
# there is no `docker volume backup`. you mount it into a throwaway container.
$ docker run --rm -v pgdata:/data -v "$(pwd)":/backup alpine \
      tar czf /backup/pgdata-$(date +%F).tgz -C /data .

# restore
$ docker run --rm -v pgdata:/data -v "$(pwd)":/backup alpine \
      sh -c "rm -rf /data/* && tar xzf /backup/pgdata-2026-08-31.tgz -C /data"

# for a database, prefer its own tool — it knows about consistency:
$ docker exec db pg_dump -U postgres app > dump.sql
Gotcha — the four that actually happen
  • A mount hides what was underneath. Bind-mounting ./src onto /app/src replaces the image's copy entirely. Mount over /app and you hide node_modules too — which is why the classic dev setup adds an anonymous volume to shield it: -v $(pwd):/app -v /app/node_modules.
  • Permission denied on Linux. The container's uid must be able to write the host directory. Either chown the host folder to that uid, or run with -u $(id -u):$(id -g). On macOS and Windows, Docker Desktop translates ownership for you, which is why this bug only appears once you deploy.
  • Relative paths don't work with -v. -v ./src:/app historically created a named volume called ./src rather than mounting. Use $(pwd)/src, or the clearer --mount type=bind,source=...,target=....
  • Named volumes are seeded once. On first use, an empty named volume is populated from the image's contents at that path. After that it's never refreshed — so a new image with updated files won't appear until you delete the volume.
NETWORKDRIVERDNSSCOPE

10 Networking

Why containers can't find each other, why localhost lies, and the one command that fixes both.

Each container gets its own network namespace: its own interfaces, its own IP, its own localhost. That last point is the source of most confusion — localhost inside a container means that container, not your laptop and not the container next to it.

your browser localhost:8080 docker network: appnet (user-defined bridge) api 172.18.0.2:3000 published to host :8080 db 172.18.0.3:5432 not published — good -p 8080:3000 db:5432 resolved by container NAME — only on a user-defined network inside api, "localhost" is api itself — never the host, never db

Only api is published. db is reachable by name from inside the network and from nowhere else — which is exactly what you want for a database.

NetworkWhat it doesContainer-to-container DNS
bridge (default)The network containers join when you don't say otherwise.No. Containers can only reach each other by IP.
user-defined bridgeOne you create with docker network create.Yes — by container name. This is why you should always create one.
hostNo isolation; the container uses the host's network stack directly. -p is ignored.n/a — Linux only, and it gives up isolation.
noneNo networking at all.n/a — for batch jobs that must not phone home.
the correct setup, and whyterminal
$ docker network create appnet

$ docker run -d --name db --network appnet \
      -e POSTGRES_PASSWORD=secret postgres:16-alpine
      # NOTE: no -p. nothing outside the network can reach it.

$ docker run -d --name api --network appnet -p 8080:3000 \
      -e DATABASE_URL=postgres://postgres:secret@db:5432/app \
      myapp:1.0                        #                        ↑ the container NAME

$ docker exec api ping -c1 db          # resolves. this is the payoff.

# inspect and manage
$ docker network ls
$ docker network inspect appnet        # which containers, which IPs
$ docker network connect appnet other  # attach a running container to a second network
Gotcha — the four networking questions everyone asks
QuestionAnswer
"Connection refused, but the port is published"Your app is bound to 127.0.0.1 inside the container. Bind 0.0.0.0 instead. Check with docker exec X netstat -tlnp.
"api can't find db"They're on the default bridge, which has no DNS. Create a network and put both on it.
"How do I reach the host from inside?"host.docker.internal on Docker Desktop. On Linux add --add-host=host.docker.internal:host-gateway.
"Port is already allocated"Something else holds that host port — often an old container. docker ps -a, then lsof -i :8080. Change the host side: -p 8081:3000.
EXPOSE does not expose anything

EXPOSE 3000 in a Dockerfile is metadata — a note to a human reading docker inspect. It publishes nothing. Only -p at run time (or ports: in Compose) makes a port reachable from the host. Its one functional use is docker run -P, which publishes every EXPOSEd port on random host ports.

SERVICEIMAGEPORTSSTATUS

11 Docker Compose

Those five docker run commands, written down once and run with two words.

Compose takes the flags from section 04 and puts them in a YAML file. It creates a network for you automatically, so DNS between services works with no setup, and it starts and stops the whole stack as a unit. For local development it's the only sane way to work.

compose.yaml — a complete stackYAML
# No "version:" key — it's obsolete in Compose v2 and prints a warning.
name: myapp

services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
      target: final          # which multi-stage target
    image: myapp:1.0          # name the built image
    ports:
      - "8080:3000"            # quote it — 8080:3000 can parse as a sexagesimal number
    environment:
      DATABASE_URL: postgres://postgres:secret@db:5432/app
      NODE_ENV: production
    env_file: [.env]         # everything else
    depends_on:
      db:
        condition: service_healthy   # wait for READY, not just started
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
      interval: 30s
      timeout: 3s
      retries: 3
      start_period: 10s

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: app
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      retries: 10
    # no ports: — reachable only from inside the stack

  cache:
    image: redis:7-alpine
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - redisdata:/data

volumes:                    # named volumes must be declared here
  pgdata:
  redisdata:
the commandsterminal
$ docker compose up -d              # build if needed, create network, start everything
$ docker compose up --build          # force a rebuild
$ docker compose ps                  # status of the stack
$ docker compose logs -f api         # one service; omit the name for all
$ docker compose exec api sh         # shell into a running service
$ docker compose run --rm api npm test   # one-off task in a NEW container
$ docker compose restart api
$ docker compose down                # stop + remove containers and the network
$ docker compose down -v             # ...and the volumes. DELETES DATA.
$ docker compose config              # the fully resolved file — great for debugging

# note: `docker compose` (v2 plugin, current) not `docker-compose` (v1, python, EOL)
One file per environment, without duplication
# compose.yaml is loaded, then compose.override.yaml automatically on top.
# compose.override.yaml — dev only, git-ignored or committed as the dev default
services:
  api:
    build:
      target: builder        # dev stage, with dev dependencies
    volumes:
      - ./src:/app/src         # live reload
    environment:
      NODE_ENV: development
    command: ["npm", "run", "dev"]

# explicit composition for prod:
$ docker compose -f compose.yaml -f compose.prod.yaml up -d
Gotcha
  • depends_on alone does not wait for readiness. By default it only orders startup — your API still connects to a Postgres that hasn't finished initialising and crashes. You need condition: service_healthy plus a real healthcheck on the dependency, as above. Even then, make your app retry its database connection; that's the durable fix.
  • Compose prefixes everything with the project name — the directory name unless you set name:. Two projects in folders both called app will collide over containers and volumes.
  • docker compose down -v deletes your database. It's one character away from the harmless version.
  • Environment precedence runs: shell > environment: > env_file > Dockerfile ENV. A stale variable exported in your shell silently wins over the file, which is a genuinely maddening hour to debug. docker compose config shows the truth.
Part 3 · Doing it properly

Before it goes anywhere real

Everything above gets an app running. This part is what separates a demo from something you'd put on the internet.


CHECKRISKFIX·

12 Security & hardening

Six changes, none of them hard, that remove most of the risk from a container image.

Start with the big one: containers run as root by default

Unless you say otherwise, PID 1 inside your container is root — and it's the same root as the host's, just namespaced. Combine that with a bind-mounted host directory or a container-escape bug and root inside becomes root outside. Adding one USER line removes an entire class of problem.

Dockerfile — running as a non-root userDockerfile
FROM node:20-alpine

# many official images already ship a non-root user. node's is `node`.
# if yours doesn't, make one:
RUN addgroup -S app && adduser -S -G app app

WORKDIR /app
COPY --chown=app:app . .
RUN npm ci --omit=dev

USER app                    # everything after this — including CMD — runs as app

CMD ["node", "server.js"]

# verify it took:
# $ docker run --rm myapp id
# uid=101(app) gid=102(app)
run-time hardening flagsterminal
$ docker run -d \
    --user 1000:1000 \              # override the image's user
    --read-only \                   # filesystem is immutable...
    --tmpfs /tmp:rw,size=64m \      # ...except where the app genuinely writes
    --cap-drop ALL \                # drop every Linux capability
    --cap-add NET_BIND_SERVICE \    # add back only what's needed (port < 1024)
    --security-opt no-new-privileges \   # block setuid escalation
    --memory 512m --pids-limit 200 \     # blast radius
    myapp:1.0

# NEVER do these unless you know exactly why:
#   --privileged                      disables essentially all isolation
#   -v /var/run/docker.sock:/var/...  container can control the daemon = root on host
#   -v /:/host                        the entire host filesystem
Do thisBecause
Pin base images by digestFROM node:20-alpine@sha256:abc... — a tag can be re-pointed at any time, including at something malicious. Digests can't.
Use the smallest base you can debugFewer packages means fewer CVEs. -slim over full, distroless over alpine if you can live without a shell.
Scan images in CIdocker scout cves myapp:1.0, or trivy image myapp:1.0. Fail the build on HIGH and CRITICAL.
Never bake secretsARG, ENV and deleted files all persist in layers. Use BuildKit secrets at build time and injected env at run time.
Add a .dockerignoreStops COPY . . baking in .env, .git and SSH keys. This leaks credentials constantly in the real world.
Rebuild regularlyAn image built six months ago has six months of unpatched base-image CVEs. Rebuild on a schedule, not only on code change.
build-time secrets, done correctlyDockerfile + terminal
# syntax=docker/dockerfile:1
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./

# the secret is mounted for THIS COMMAND ONLY and never enters a layer
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci --omit=dev

# $ docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp .
# prove it: `docker history myapp` and `docker run --rm myapp cat /root/.npmrc`
# both come up empty.
LIMITON BREACHSIGNAL·

13 Limits, health & logs

Keeping one container from taking down the machine, and knowing when it's sick.

By default a container can use all the host's CPU and memory. One leaking process is enough to take down everything else on the box, including the Docker daemon. Limits are cheap insurance.

FlagEffect when exceeded
--memory 512mHard cap. The kernel OOM-kills the process: exit code 137, no warning, no log from your app. Always set this.
--memory-reservation 256mSoft target — reclaimed under host pressure but not enforced. Pairs with the hard cap.
--cpus 1.5Throttled, not killed. The app just gets slower — silently, which makes it hard to spot.
--pids-limit 200Caps process count. Stops a fork bomb, accidental or otherwise.
--restart unless-stoppedRestarts on crash and after a host reboot, but respects a manual docker stop.
health checks — so "running" means "working"Dockerfile
HEALTHCHECK --interval=30s \      # how often
            --timeout=3s \        # fail if the check itself hangs
            --start-period=20s \  # grace while booting; failures here don't count
            --retries=3 \         # consecutive failures before "unhealthy"
  CMD wget -qO- http://localhost:3000/health || exit 1

# exit 0 = healthy, exit 1 = unhealthy. that's the whole contract.
watching health and resourcesterminal
$ docker ps
CONTAINER ID   IMAGE      STATUS                    PORTS
9f2c8ad1c41d   myapp:1.0  Up 2 minutes (healthy)    0.0.0.0:8080->3000/tcp

$ docker inspect api --format '{{json .State.Health}}' | jq
$ docker stats --no-stream
NAME   CPU %   MEM USAGE / LIMIT   MEM %   NET I/O
api    0.51%   142MiB / 512MiB     27.7%   1.2MB / 840kB
Gotcha — Docker does not act on unhealthy

A plain Docker container marked unhealthy just sits there being unhealthy. --restart reacts to the process exiting, not to a failing health check. Orchestrators (Compose depends_on, Swarm, Kubernetes) are what actually respond to health. Locally, the value of a HEALTHCHECK is visibility and dependency ordering.

Gotcha — logs fill the disk

The default json-file driver has no rotation. A chatty container will happily write 40 GB to /var/lib/docker/containers/ and take the host down. Fix it globally in /etc/docker/daemon.json:

{
  "log-driver": "json-file",
  "log-opts": { "max-size": "10m", "max-file": "3" }
}

Then systemctl restart docker. Existing containers keep their old setting until recreated.

Log to stdout, always

Write logs to stdout and stderr, never to a file inside the container. docker logs, and every log shipper ever built, read exactly those two streams. A log file inside a container is invisible and disappears with it — official images symlink their log paths to /dev/stdout for precisely this reason.

TECHNIQUESAVESNEEDS·

14 Build performance

Getting a two-minute build down to five seconds.

TechniqueTypical saving
Order layers least→most volatileEnormous. The single highest-value change — see section 02.
A real .dockerignoreSeconds to minutes of context upload, plus avoids spurious cache misses.
Cache mounts for package managersKeeps the npm/pip/apt cache between builds without putting it in a layer.
Multi-stage + --targetParallel stages, and CI can stop early at a test stage.
Registry cache in CITurns a cold CI runner into a warm one.
BuildKit cache mountsDockerfile
# syntax=docker/dockerfile:1
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./

# the npm cache persists across builds but is NOT part of the image
RUN --mount=type=cache,target=/root/.npm \
    npm ci --omit=dev

# same idea elsewhere:
#   pip  → --mount=type=cache,target=/root/.cache/pip
#   go   → --mount=type=cache,target=/root/.cache/go-build
#   apt  → --mount=type=cache,target=/var/cache/apt
CI cache and multi-archterminal
# push the build cache to the registry so the next CI run starts warm
$ docker buildx build \
    --cache-from type=registry,ref=ghcr.io/acme/myapp:buildcache \
    --cache-to   type=registry,ref=ghcr.io/acme/myapp:buildcache,mode=max \
    -t ghcr.io/acme/myapp:1.0 --push .

# one image that runs on both Apple Silicon and x86 servers
$ docker buildx create --use
$ docker buildx build --platform linux/amd64,linux/arm64 \
    -t ghcr.io/acme/myapp:1.0 --push .

# what did the cache actually do?
$ docker build --progress=plain .      # "CACHED" on each reused step
$ docker build --no-cache .            # prove a cold build still works
Gotcha — the Apple Silicon trap

Building on an M-series Mac produces a linux/arm64 image by default. Deploy it to a normal x86 server and every container dies instantly with exec format error. Either build multi-arch as above, or force the target: docker build --platform linux/amd64 .... Emulated cross-builds are correct but slow — often 5–10× — so build natively in CI where you can.

Part 4 · Reference

Look things up

The part you come back to. Commands, instructions, error messages, vocabulary.


COMMANDWHAT IT DOES··

Command reference

Everything you'll type, grouped by what you're trying to do.

Containers

CommandDoes
docker run IMAGECreate and start a new container.
docker ps / ps -aRunning containers / all containers including exited.
docker start|stop|restart XControl an existing container. stop = SIGTERM then SIGKILL after 10s.
docker kill XImmediate SIGKILL. No graceful shutdown.
docker rm X / rm -f XDelete a stopped container / force-delete a running one.
docker exec -it X shRun a command in a running container.
docker logs -f --tail 100 Xstdout/stderr of PID 1. --since 10m to narrow.
docker cp X:/path ./pathCopy files in or out. Works on stopped containers too.
docker inspect XFull JSON state. Use --format to extract one field.
docker stats / top XLive resource usage / processes inside.
docker port XWhich host ports map where.
docker diff XFiles changed since the container started.
docker container pruneDelete every stopped container.

Images

CommandDoes
docker build -t name:tag .Build from the Dockerfile in the context directory.
docker imagesLocal images. -f dangling=true for untagged leftovers.
docker pull / pushFetch from or send to a registry.
docker tag src targetAdd another name to the same image. Copies nothing.
docker rmi name:tagRemove an image.
docker history name:tagLayers, sizes and the instruction that made each one.
docker save / loadExport to a tarball / import — moving images without a registry.
docker image prune -aDelete every image not used by a container.
docker scout cves name:tagVulnerability scan.

Volumes, networks, system

CommandDoes
docker volume ls / inspect / rmManage named volumes.
docker volume pruneDelete unused volumes. Destroys data.
docker network create NAMECreate a user-defined bridge — enables DNS by container name.
docker network ls / inspectList networks / see which containers are attached.
docker network connect NET XAttach a running container to another network.
docker system dfDisk usage by images, containers, volumes and build cache.
docker system prune -aReclaim almost everything unused. Add --volumes only if you mean it.
docker builder pruneClear the build cache — usually the largest single win.
docker eventsLive feed of daemon events.
docker info / versionDaemon config and versions. First thing to check when nothing works.

Compose

CommandDoes
docker compose up -dStart the whole stack in the background.
docker compose up --buildRebuild images first.
docker compose down / down -vStop and remove / also delete volumes.
docker compose ps / logs -fStatus / streaming logs.
docker compose exec SVC shShell into a running service.
docker compose run --rm SVC CMDOne-off command in a fresh container.
docker compose configThe fully merged, resolved file. Debug env problems here.
docker compose pullUpdate all images without rebuilding.
INSTRUCTIONPURPOSELAYER·

Dockerfile instruction reference

Every instruction you're likely to need, and whether it creates a layer.

InstructionWhat it doesLayer
FROM image AS nameBase image and the start of a stage. Required first (after ARG/comments).yes
RUN cmdExecute at build time. Combine related commands with && to keep layers few and cleanup effective.yes
COPY src dstCopy from context or another stage. --from, --chown, --chmod.yes
ADD src dstLike COPY, plus URL fetch and tar auto-extract. Prefer COPY.yes
WORKDIR /pathSet the working directory, creating it if needed. Use instead of RUN cd.metadata
ENV K=VEnvironment variable at build and run time. Visible in docker inspect.metadata
ARG K=defaultBuild-time only variable. Set with --build-arg. Visible in docker history.metadata
CMD ["exe","arg"]Default command, overridden by args after the image name.metadata
ENTRYPOINT ["exe"]Fixed executable; CMD becomes its arguments.metadata
EXPOSE 3000Documentation only. Does not publish.metadata
USER nameRun subsequent instructions and the final command as this user.metadata
VOLUME /dataDeclares a path as a mount point; an anonymous volume is created if none is supplied. Often better left to run time.metadata
HEALTHCHECK CMD ...How to test the container is working.metadata
LABEL k=vImage metadata. Use org.opencontainers.image.* keys for source, version, licence.metadata
STOPSIGNAL SIGTERMWhich signal docker stop sends.metadata
SHELL ["pwsh","-c"]Change the shell used by shell-form RUN/CMD.metadata
ONBUILD ...Trigger an instruction in any image built FROM this one. Surprising; avoid.metadata
ERRORCAUSEFIX·

Troubleshooting by error message

Find your error text. Nearly all of these have one specific cause.

The loop, every time

1. docker ps -a — what state is it in, and what exit code?  2. docker logs X — what did it say on the way out?  3. docker run --rm -it --entrypoint sh IMAGE — get inside and run it by hand.

Error / symptomWhat's happeningFix
Cannot connect to the Docker daemonThe daemon isn't running, or your user isn't in the docker group.Start Docker Desktop, or sudo systemctl start docker. On Linux: sudo usermod -aG docker $USER, then log out and back in.
port is already allocatedAnother process — often a container you forgot — holds that host port.docker ps -a, then lsof -i :8080. Or just publish a different host port: -p 8081:3000.
connection refused (but the port is published)Your app is listening on 127.0.0.1 inside the container, which -p cannot reach.Bind 0.0.0.0. Verify with docker exec X netstat -tlnp.
exec format errorArchitecture mismatch — an arm64 image on an amd64 host, or vice versa.docker build --platform linux/amd64, or build multi-arch with buildx.
exec ./entrypoint.sh: no such file or directory
(and the file is clearly there)
Two classic causes: Windows CRLF line endings in the script, or a glibc binary in an Alpine (musl) image.dos2unix entrypoint.sh, or match the builder and final stage to the same distro family.
permission denied on a mounted volumeThe container's uid can't write the host directory. Linux only — Docker Desktop hides this.-u $(id -u):$(id -g), or chown the host folder to the container's uid.
Container exits immediately, code 0PID 1 finished. Nothing is wrong — there was just nothing to keep running.Give it a long-running process, or -it for an interactive shell.
Exit code 137SIGKILL — almost always the memory limit.Raise --memory, or fix the leak. docker stats while it climbs.
Exit code 127Command not found inside the container.Typo, or the binary isn't in that base image. Alpine has no bash — use sh.
COPY failed: file not found in build contextThe path is outside the context directory, or excluded by .dockerignore.Paths are relative to the context (the .), never to the Dockerfile. You cannot COPY ../x.
no space left on deviceImages, stopped containers and build cache have filled the disk.docker system df, then docker system prune -a and docker builder prune.
manifest unknown / not foundThat tag doesn't exist for your platform, or the image is private.Check the tag on the registry; docker login.
unauthorized: authentication requiredNot logged in, or the token lacks push scope.docker login ghcr.io. For GHCR the token needs write:packages.
Code changes don't appearYou rebuilt but ran the old image, or the layer was cached.docker compose up --build. Confirm with docker images timestamps; --no-cache to be certain.
Container can't resolve another container's nameThey're on the default bridge, which has no DNS.docker network create appnet and put both on it.
Can't reach the host machine from a containerlocalhost inside means the container.Use host.docker.internal; on Linux add --add-host=host.docker.internal:host-gateway.
App can't reach the database on startupdepends_on waits for start, not readiness.Add a healthcheck to the db and condition: service_healthy — and make the app retry.
docker stop takes 10 seconds every timeYour app never receives SIGTERM, because shell-form CMD made sh PID 1.Use exec form: CMD ["node","server.js"]. Handle SIGTERM in the app.
Image is enormous (1 GB+)Build tools and dev dependencies shipped with the app.Multi-stage build (section 08) plus -slim or alpine. docker history shows the guilty layer.
TERMPLAIN ENGLISH··

Glossary

Words the docs assume you already know.

Image
A read-only, layered filesystem snapshot plus metadata saying what to run. A class.
Container
A running (or stopped) instance of an image, with its own thin writable layer.
Layer
The filesystem diff produced by one Dockerfile instruction. Cached, content-addressed and shared between images.
Base image
Whatever you put after FROM.
Registry
Where images are stored and shared — Docker Hub, GHCR, ECR.
Repository
One image name in a registry, holding many tags.
Tag
A movable pointer to an image version, like a git branch. Not a guarantee.
Digest
The sha256: content hash. Immutable — the only fully reproducible reference.
Build context
The directory sent to the daemon at build time — the . in docker build .. COPY can only see inside it.
Dockerfile
The recipe for building an image.
BuildKit
The modern build engine: parallel stages, cache mounts, build secrets. Default in current Docker.
Multi-stage build
Several FROMs in one Dockerfile; only the last becomes the image.
Volume
Storage managed by Docker that outlives the container.
Bind mount
A host directory mapped into a container. You own the files.
Union filesystem
The mechanism stacking read-only layers under one writable layer, with copy-on-write.
Namespace
Linux feature giving a process its own view of PIDs, network, mounts and users. The isolation in "container".
cgroup
Linux feature capping CPU, memory and I/O. The limits in "container".
PID 1
The first process in the container. When it exits, the container exits. It receives the signals.
Daemon (dockerd)
The background service that does the actual work. The docker CLI just talks to it.
containerd / runc
The lower-level runtime pieces that actually create containers. Docker sits on top.
OCI
Open Container Initiative — the standards for image and runtime formats, which is why images work across tools.
Distroless
An image with only your app and its runtime — no shell, no package manager.
Dangling image
An untagged leftover shown as <none>. Safe to prune.
Compose project
The namespace Compose prefixes to containers, networks and volumes — the folder name by default.

Questions & answers

What people ask next. Try answering each one before opening it.

Concepts ↳ #what▶
  • Is a container more or less secure than a VM?Less, structurally. A VM has a hypervisor boundary and its own kernel; containers share the host kernel, so one kernel vulnerability can cross the boundary. In exchange you get much smaller images, faster starts and a far smaller patch surface. For strong multi-tenant isolation people use both — containers inside per-tenant VMs, or a sandboxed runtime like gVisor or Kata.
  • Why is my Mac slow at bind-mounted file I/O?Because your containers run inside a Linux VM, and the bind mount crosses the VM boundary through a file-sharing protocol. Thousands of small reads — node_modules, .git — are the worst case. Mitigations: VirtioFS in Docker Desktop settings, :cached/:delegated mount flags, or keeping dependencies in a named volume rather than bind-mounting them.
  • Should one container run one process?Almost always yes. Docker's model ties the container's life to PID 1, restart policies act on the container, and logs come from stdout — all of which assume one workload. Multiple processes means writing a supervisor, and losing the signal that any one of them died. Legitimate exceptions exist (a tiny sidecar, an init like tini to reap zombies) but "web server + database in one image" is a mistake you'll pay for at the first restart.
Images & builds ↳ #layers▶
  • My image is 1.4 GB. Where do I start?docker history IMAGE and find the biggest layer — it's nearly always a dependency install or a copied build directory. Then, in order of payoff: multi-stage so build tools don't ship, a -slim or alpine base, a real .dockerignore, and cleanup inside the same RUN that created the mess (rm -rf /var/lib/apt/lists/*). Those four usually get 1.4 GB under 200 MB.
  • Should I commit the lockfile and pin the base image?Yes to both, for the same reason: reproducibility. npm ci with a committed lockfile gives identical dependencies; pinning FROM node:20.11-alpine (or a digest) means today's build matches last week's. Unpinned, a rebuild months later silently picks up a new base and new transitive deps, and "it worked in CI yesterday" becomes unanswerable.
  • Why does my build ignore the cache when nothing changed?Something in the context changed, even if it looks irrelevant — a log file, an editor swap file, a rebuilt dist/ — and COPY . . hashes all of it. Add a strict .dockerignore. Also: ARG values, a changed base image, and --no-cache in your CI script all invalidate. --progress=plain shows exactly which step missed.
Running & operating ↳ #run▶
  • Should I run a database in Docker?In development, absolutely — it's the single biggest quality-of-life win Compose offers. In production it's a real decision: you take on backups, upgrades, storage performance and failover yourself, and a mis-set docker compose down -v deletes everything. Most teams use a managed database in production and a containerised one locally, which is a perfectly coherent position.
  • How do I get secrets into a container properly?Ranked worst to best: baked into the image (never); ENV in the Dockerfile (never — it's in inspect); -e at run time (acceptable, still visible in inspect and process listings); an --env-file kept out of git (fine for dev); mounted as a file from a secret manager (good); fetched at startup with a short-lived identity — Vault, IRSA, workload identity (best). At build time, always BuildKit --secret.
  • Why does my app not shut down gracefully?Three usual reasons. Shell-form CMD, so sh is PID 1 and swallows SIGTERM. Or your app has no SIGTERM handler and relies on SIGKILL. Or shutdown takes longer than the 10-second default — docker stop -t 30. Fix the first with exec form, the second with a handler that stops accepting connections and drains, the third by measuring how long draining actually takes.
  • What's the difference between Docker and Kubernetes?Different layers, not competitors. Docker builds images and runs containers on one machine. Kubernetes schedules those same OCI images across many machines and adds restarts, rollouts, service discovery and scaling. You build with Docker and run in Kubernetes. (Kubernetes dropped the Docker runtime in 1.24 — but it still runs images Docker builds, because both follow the OCI standard.)
Where to go next▶
  • Practise on something realTake an app you already have, write a Dockerfile, make it multi-stage, add a Compose file with a database, then get the image under 200 MB and running as non-root. That sequence covers everything on this page.
  • Tools worth knowingdive to explore image layers interactively, hadolint to lint Dockerfiles, trivy or docker scout for CVEs, lazydocker for a terminal UI, and nicolaka/netshoot for debugging networking.
  • The things this page skippedDocker Swarm (largely superseded by Kubernetes), rootless mode, custom network drivers (overlay, macvlan), and registry mirroring. Also alternative runtimes — Podman is a near drop-in with no daemon and rootless by default.
  • Then: orchestrationOnce you're comfortable here, the Kubernetes handbook picks up exactly where this leaves off — it assumes you understand images, containers and volumes, and starts from what runs them across a fleet.