← Writing
The Editorial · Engineering

The Docker cheat sheet - every command, flag, and answer, no padding

Issue 028Aug 28, 202610 min read

A container is a process with a lie told to it about the filesystem, the network, and who else is running. Everything else is packaging. This is the whole working surface of Docker as it exists today - Compose v2 with no version key, BuildKit cache and secret mounts, buildx bake, compose watch, scout - as commands you can copy and the short answers to the questions people actually ask.

The issue

Docker's surface area is big and its useful surface area is small. Nearly everything you will be asked, in an interview or by a broken deploy at 2am, comes out of four facts: what a container actually is, how the layer cache decides what to rebuild, where data lives, and how two containers find each other. The rest is flags.

So: a container is a process. One ordinary Linux process, started with namespaces (it gets its own view of the filesystem, network interfaces, process table, hostname, users) and cgroups (it gets a ceiling on CPU, memory, and I/O). There is no guest kernel, no boot. That is the entire trick, and it is why a container starts in milliseconds and why you cannot run a Linux image on a Windows kernel without a Linux VM hiding somewhere.

An image is a stack of read-only layers plus a config blob. A container is that stack with a thin writable layer on top, copy-on-write. Everything the process writes lands in that top layer, and that top layer is deleted with the container. Remember that one sentence and volumes stop being confusing.

The commands you actually type

daily.shBash
# run: -d detach, --rm delete on exit, -p host:container, -e env, -v mount
docker run -d --rm --name api -p 3000:3000 -e NODE_ENV=production myapp:1.2

docker ps                 # running     |  docker ps -a  = including dead
docker logs -f --tail 100 api           # follow output
docker exec -it api sh                  # shell inside a RUNNING container
docker stop api           # SIGTERM, then SIGKILL after 10s (-t to change)
docker rm -f api          # kill and delete
docker stats              # live CPU / memory per container
docker inspect api        # the full JSON truth: mounts, env, network, health
docker port api           # what got published where
docker cp api:/app/out.log ./out.log    # copy files either direction
docker top api            # processes inside, as seen from the host

# images
docker images
docker pull node:22-alpine
docker tag myapp:1.2 registry.example.com/team/myapp:1.2
docker push registry.example.com/team/myapp:1.2
docker history myapp:1.2     # layer-by-layer, with sizes - find the fat one
docker image inspect myapp:1.2 --format '{{.Config.Entrypoint}} {{.Config.Cmd}}'
docker save myapp:1.2 | gzip > myapp.tgz    # airgapped transfer
docker rmi myapp:1.2

The layer cache, which is your whole build time

Each instruction in a Dockerfile produces a layer. BuildKit reuses a cached layer only if the instruction and its inputs are unchanged and every layer above it was also a cache hit. One touched file in an early COPY invalidates everything downstream. Order instructions from least to most frequently changing, always.

DockerfileDockerfile
# syntax=docker/dockerfile:1
# --- deps: reinstalled only when the lockfile moves -------------------------
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci

# --- build: compiles; all of this is thrown away ----------------------------
FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

# --- runner: what actually ships. No compiler, no source, no dev deps -------
FROM node:22-alpine AS runner
ENV NODE_ENV=production
WORKDIR /app
COPY --from=deps --link /app/node_modules ./node_modules
COPY --from=build --link /app/dist ./dist
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD wget -qO- http://localhost:3000/health || exit 1
ENTRYPOINT ["node", "dist/server.js"]

CMD vs ENTRYPOINT, settled

entrypoint.dockerfileDockerfile
ENTRYPOINT ["ping"]      # the executable - hard to override (needs --entrypoint)
CMD ["-c", "3", "localhost"]   # the default arguments - replaced by run args

# docker run img                 -> ping -c 3 localhost
# docker run img -c 5 example.com -> ping -c 5 example.com
# docker run --entrypoint sh img  -> sh

# Exec form vs shell form, the one that actually bites:
CMD ["node", "server.js"]      # PID 1 is node. SIGTERM reaches it. Clean stop.
CMD node server.js             # PID 1 is /bin/sh, which ignores SIGTERM.
                               # Result: every stop takes the full 10s timeout.

Where data lives

storage.shBash
# named volume - Docker-managed, survives the container, the default for state
docker volume create pgdata
docker run -v pgdata:/var/lib/postgresql/data postgres:17-alpine

# bind mount - a host path, for source you are editing. Long form is clearer:
docker run --mount type=bind,src="$PWD"/src,dst=/app/src,readonly myapp

# tmpfs - RAM only, never touches disk, gone on stop. Good for secrets/scratch.
docker run --tmpfs /tmp:rw,size=64m myapp

docker volume ls
docker volume inspect pgdata
docker volume rm pgdata          # data is gone. There is no undo.

Networking

network.shBash
docker network create app-net
docker run -d --name db  --network app-net postgres:17-alpine
docker run -d --name api --network app-net -p 3000:3000 myapp
# api reaches the database at  db:5432  - the container name is the DNS name.

docker network ls
docker network inspect app-net       # who is attached, and their IPs
docker network connect app-net some-existing-container

Compose, current syntax

compose.yamlYAML
# No 'version:' key - it has been obsolete for years and warns if present.
services:
  api:
    build:
      context: .
      target: runner
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgres://app:secret@db:5432/app
    depends_on:
      db:
        condition: service_healthy   # waits for HEALTHY, not merely started
    develop:
      watch:                          # docker compose watch
        - action: sync                # copy changed files into the container
          path: ./src
          target: /app/src
        - action: rebuild             # dependency change -> rebuild the image
          path: package.json

  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: app
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 5s
      timeout: 3s
      retries: 10

volumes:
  pgdata:
compose.shBash
docker compose up -d --build        # note the SPACE: v2 is a CLI plugin
docker compose watch                # sync/rebuild on file change
docker compose ps
docker compose logs -f api
docker compose exec api sh
docker compose run --rm api npm run migrate   # one-off, not the long-running svc
docker compose down                 # stop + remove containers and the network
docker compose down -v              # …and delete the named volumes. Careful.
docker compose config               # the fully merged, resolved file - debug here

# overrides: base + environment-specific, later files win
docker compose -f compose.yaml -f compose.prod.yaml up -d

Building: buildx, bake, multi-platform

build.shBash
docker build -t myapp:1.2 .            # BuildKit is the default engine now
docker build --target build -t myapp:dev .   # stop at an intermediate stage
docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp:1.2 .
docker build --progress=plain --no-cache .   # see every command's real output

# multi-arch (Apple Silicon dev, amd64 servers) - needs a container builder
docker buildx create --use --name multi
docker buildx build --platform linux/amd64,linux/arm64 -t org/app:1.2 --push .

# registry-backed cache, so CI stops rebuilding from scratch every run
docker buildx build \
  --cache-from type=registry,ref=org/app:cache \
  --cache-to   type=registry,ref=org/app:cache,mode=max \
  -t org/app:1.2 --push .

# bake: many targets, declared in a file, built in parallel
docker buildx bake --file docker-bake.hcl --push

docker scout quickview myapp:1.2       # CVE summary
docker scout cves myapp:1.2 --only-severity critical,high

When it will not start

debug.shBash
docker ps -a                         # what was the exit code?
docker logs <id>                     # 99% of the time the answer is right here
docker inspect <id> --format '{{.State.ExitCode}} {{.State.Error}}'
docker inspect <id> --format '{{json .State.Health}}'   # failing healthcheck?
docker events                        # live stream of the daemon's decisions

# the image runs but your entrypoint is wrong - bypass it
docker run -it --rm --entrypoint sh myapp:1.2
docker run -it --rm myapp:1.2 sh -c 'ls -la /app && node -v'

# distroless / no shell in the image: attach a debug toolbox to the namespace
docker run -it --rm --pid container:api --network container:api nicolaka/netshoot

# exit codes worth memorising
#   0    clean exit - your process finished. A CMD that isn't long-running.
#   1    application error, read the logs
#   125  the docker run command itself was malformed
#   126  the command exists but is not executable (missing +x, wrong arch)
#   127  command not found (usually: not present in a slim/alpine base)
#   137  SIGKILL - OOM-killed, or a stop that timed out. Check MemoryLimit.
#   139  SIGSEGV
#   143  SIGTERM - normal stop

Disk, which fills up silently

cleanup.shBash
docker system df            # images / containers / volumes / build cache
docker system df -v         # per-item, so you can see what the offender is

docker container prune      # stopped containers
docker image prune          # dangling (untagged) images
docker image prune -a       # every image not used by a container - aggressive
docker builder prune        # the BuildKit cache, usually the biggest surprise
docker volume prune         # UNUSED volumes. This deletes data. Read it twice.

docker system prune -af --volumes   # everything not in use. Know what you typed.

# keep CI from filling the disk without nuking today's cache
docker builder prune --filter 'until=168h' -f

The short answers

If you keep only one paragraph: a container is a process with a private view of the machine, an image is a stack of read-only layers that the cache rebuilds from the first change downwards, data outlives a container only in a volume, and services find each other by name on a user-defined network. Every command above is a way of touching one of those four things.

All issues