Skip to content
Dev Dump

Docker

These notes run from how containers relate to the kernel through day-to-day CLI workflows, then into engine and image mechanics, operations, and multi-service Compose. They mirror the other topic hubs here: short sections, deep links, and diagrams where a picture saves a long paragraph.

Docker track overview

Topics in this section — read left to right; each page goes deeper on that slice of the stack.

Hands-on examples assume a counter-api/ folder with a small HTTP service, a Dockerfile, and a compose.yaml. Paths in code blocks are relative to that project root so you can paste them into your own repo.


You want the app you built on your laptop to run the same way on a teammate’s machine, in CI, and in staging — without shipping a whole virtual machine per app.

A container is not one kernel feature — it is namespaces (what it can see) plus cgroups (what it can use), started from an OCI image bundle (how it boots):

Namespaces, cgroups, and OCI bundle

Three pillars: isolation of view, accounting of resources, and the OCI bundle runc executes.

Terminal window
$ docker run --rm hello-world
Hello from Docker!
This message shows that your installation appears to be working correctly.
To generate this message, Docker took the following steps:
1. The Docker client contacted the Docker daemon.
2. The Docker daemon pulled the "hello-world" image from the Docker Hub.
(amd64)
3. The Docker daemon created a new container from that image which runs the
executable that produces the output you are currently reading.
4. The Docker daemon streamed that output to the Docker client, which sent it
to your terminal.

What happened under the hood, step by step:

  1. CLI → dockerd — your terminal sent a POST /containers/create request to the Docker daemon over a Unix socket.
  2. Image pulldockerd asked the registry for the hello-world layers; they were stored in the local content store.
  3. runc + namespacescontainerd called runc, which set up PID, NET, MNT, and UTS namespaces and mounted the overlay rootfs.
  4. PID 1 runs/hello started as PID 1 inside the container, wrote to stdout, and exited.
  5. --rm cleanup — because you passed --rm, the container’s writable layer and metadata were removed as soon as PID 1 exited.

The UTS namespace gives each container its own hostname. Notice that the hostname inside the container is the short container ID, not your laptop’s hostname:

Terminal window
$ hostname
my-laptop
$ docker run --rm alpine hostname
3f2a1c7b4d9e

The PID namespace means processes inside the container see a fresh numbering starting at 1. Your process tree on the host is invisible to the container:

Terminal window
$ docker run --rm alpine ps aux
PID USER TIME COMMAND
1 root 0:00 ps aux

Only ps itself is visible — PID 1 — because that is the only process running in that namespace.


The --cpus and --memory flags write cgroup entries before your process starts. This example caps a container to half a CPU core and 256 MiB of RAM, then reads what it sees:

Terminal window
$ docker run --rm --cpus="0.5" --memory=256m alpine sh -c 'nproc; free -m'
1
total used free shared buff/cache available
Mem: 256 4 251 0 0 251
Swap: 0 0 0
  • nproc reports 1 — the cgroup CPU quota translates to a single visible processor.
  • free -m shows 256 MiB total — the memory cgroup limit is reflected as the container’s total RAM.
  • Swap is 0 because --memory without --memory-swap sets both to the same value, leaving no extra swap headroom.

Use docker version to confirm the CLI and server API versions match (critical after upgrades or switching remote contexts). Use docker info to see the storage driver, cgroup version, default runtime, and any insecure registries — first stop when “Docker acts weird”:

Terminal window
$ docker version
Client: Docker Engine - Community
Version: 26.1.4
API version: 1.45
Go version: go1.21.11
OS/Arch: linux/amd64
Server: Docker Engine - Community
Engine:
Version: 26.1.4
API version: 1.45 (minimum version 1.24)
Go version: go1.21.11
OS/Arch: linux/amd64
$ docker info --format '{{.CgroupVersion}} {{.StorageDriver}}'
2 overlay2

CgroupVersion: 2 and StorageDriver: overlay2 is the modern healthy combination on Linux.


Virtual machineLinux container
KernelGuest kernel per VMShares host kernel
Isolation boundaryHypervisor + hardware virtNamespaces + cgroups
Boot timeSeconds to minutesUsually sub-second
SizeLarge disk imagesSlim image layers
OS divergenceAny OS guestMust match host kernel ABI

Rule of thumb: choose a VM when you need a different kernel (or Windows workloads on Linux); choose a container when you need reproducible userspace without the boot overhead.

NamespaceRough effectFeels like to your app
PIDOwn process IDsPID 1 can be your server
NETOwn network interfaces, routingOwn eth0, own ports
MNTOwn mount tableYour / is the image rootfs
UTSOwn hostnamehostname inside container
IPCOwn SysV IPC / POSIX mqLess collision with host
USER (optional)UID/GID mapping”Root” inside may be unprivileged outside
You typeKernel / runtime effect
-p 8080:80Publish container port 80 on host 8080 (NAT rules on bridge)
--cpus, --memorycgroup v2 limits
--network hostSkip separate NET namespace (share host stack)
-v /data:/dataBind mount — host path visible inside mount namespace
--read-onlyRootfs mounted read-only; writable dirs need tmpfs/volumes
--rmRemove container and its writable layer on exit
--pid hostShare host PID namespace (use with extreme care)

  • Forgetting --rm fills the disk. Every docker run without --rm leaves a stopped container and its writable layer. docker ps -a lists them; docker container prune removes stopped ones.
  • PID 1 must handle signals. If your CMD is a shell script, signals like SIGTERM may not reach the actual process. Use exec in the script or the JSON array form (CMD ["node", "server.js"]).
  • USER namespace is opt-in. Rootless Docker uses USER namespaces to avoid running dockerd as root; classic installs run the daemon as root, so container root is a privileged boundary — use USER in your Dockerfile.

Habit to internalise: edit → rebuild → follow logs → shell in — all without guessing container names from raw docker ps output when you use Compose service keys.

Compose edit rebuild observe loop

Compose wraps the same Engine APIs you would call by hand; service names stay stable across restarts.

CLI + engine versions

docker version

After install, upgrades, or CI image bumps — client/server must speak compatible API.

Full engine fingerprint

docker info

Debugging storage driver, cgroup version, insecure registries, live restore, swarm.

Switch remote / Desktop

docker context ls
docker context use NAME

Point CLI at cloud builder, SSH host, or Colima/Lima socket.

Same commands as the cards above — Environment sanity
Scenario Command (example) When to use
CLI + engine versions docker version After install, upgrades, or CI image bumps — client/server must speak compatible API.
Full engine fingerprint docker info Debugging storage driver, cgroup version, insecure registries, live restore, swarm.
Switch remote / Desktop docker context ls docker context use NAME Point CLI at cloud builder, SSH host, or Colima/Lima socket.
Terminal window
docker version
docker info
docker context ls

Fetch an image

docker pull nginx:1.27

Before run in CI or air-gapped prep.

Push your tag

docker push registry.example.com/team/app:1.2.0

After docker tag and docker login.

List local images

docker image ls

Housekeeping; see dangling <none> (alias: docker images).

Remove one image

docker image rm IMAGE

Free disk; fails if containers still reference it.

Remove unused images

docker image prune

Safe-ish cleanup; add -a for aggressive (see caution).

Retag for registry

docker tag SOURCE:tag TARGET:tag

Promote app:sha to app:1.0.0.

Registry auth

docker login
docker logout

Private Docker Hub, ECR, GCR, Harbor.

Search Hub (quick)

docker search postgres

Rough discovery; prefer registry UI for versions.

Same commands as the cards above — Images and registry
Scenario Command (example) When to use
Fetch an image docker pull nginx:1.27 Before run in CI or air-gapped prep.
Push your tag docker push registry.example.com/team/app:1.2.0 After docker tag and docker login.
List local images docker image ls Housekeeping; see dangling <none> (alias: docker images).
Remove one image docker image rm IMAGE Free disk; fails if containers still reference it.
Remove unused images docker image prune Safe-ish cleanup; add -a for aggressive (see caution).
Retag for registry docker tag SOURCE:tag TARGET:tag Promote app:sha to app:1.0.0.
Registry auth docker login docker logout Private Docker Hub, ECR, GCR, Harbor.
Search Hub (quick) docker search postgres Rough discovery; prefer registry UI for versions.
Terminal window
docker pull alpine:3.20
docker images
docker tag myapp:dev myregistry/myapp:1.0.0
docker login myregistry
docker push myregistry/myapp:1.0.0
docker image prune

Build from Dockerfile

docker build -t myapp:dev .

Default local build; context = current dir.

Pass build args

docker build --build-arg NODE_VERSION=20 -t myapp .

Parameterize ARG in Dockerfile.

Multi-stage target

docker build --target prod -t myapp:prod .

Ship only the runtime stage.

Ignore cache

docker build --no-cache -t myapp:clean .

Reproduce “fresh” CI-like builds.

BuildKit / builders

docker buildx ls
docker buildx build --platform linux/arm64 -t myapp .

Cross-arch images, remote builders.

Trim build cache

docker builder prune

Reclaim GB from BuildKit cache.

Layer history

docker history myapp:dev

See which Dockerfile step added size.

Inspect image JSON

docker image inspect myapp:dev

Digests, env, entrypoint, labels.

Same commands as the cards above — Build
Scenario Command (example) When to use
Build from Dockerfile docker build -t myapp:dev . Default local build; context = current dir.
Pass build args docker build --build-arg NODE_VERSION=20 -t myapp . Parameterize ARG in Dockerfile.
Multi-stage target docker build --target prod -t myapp:prod . Ship only the runtime stage.
Ignore cache docker build --no-cache -t myapp:clean . Reproduce “fresh” CI-like builds.
BuildKit / builders docker buildx ls docker buildx build --platform linux/arm64 -t myapp . Cross-arch images, remote builders.
Trim build cache docker builder prune Reclaim GB from BuildKit cache.
Layer history docker history myapp:dev See which Dockerfile step added size.
Inspect image JSON docker image inspect myapp:dev Digests, env, entrypoint, labels.
Terminal window
docker build -t counter-api:dev -f Dockerfile .
docker buildx version
docker history counter-api:dev --no-trunc
docker image inspect counter-api:dev --format '{{json .RootFS.Layers}}' | head -c 500

Foreground run

docker run --rm -it nginx

Throwaway interactive runs: smoke tests, one-off CLIs, or getting a shell. `--rm` removes the container filesystem after exit so names and disk do not accumulate; `-it` allocates a pseudo-TTY and keeps STDIN open so shell-oriented images behave like a normal terminal.

Detached server

docker run -d --name web -p 8080:80 nginx

Processes that should keep running in the background (web servers, queues, agents). `-d` detaches immediately; `--name` gives a stable handle for `docker logs`/`exec`; `-p host:container` publishes a port on the host so browsers or other apps can reach the service.

Env vars

docker run -e PORT=3000 myapp

Configuration that varies by environment without baking secrets or ports into the image (12-factor style). Use multiple `-e` flags or `--env-file` to load a file of `KEY=value` lines.

Auto-remove

docker run --rm ...

CI steps, linters, codegen, or any job where you want the container gone as soon as the command finishes. Avoids exited containers piling up and frees the container name immediately.

Restart policy

docker run --restart unless-stopped ...

Daemons on Linux hosts or VMs where Docker restarts after reboot. `unless-stopped` restarts the container unless you explicitly stopped it; alternatives include `on-failure` for batch-style workloads.

Workdir / user

docker run -w /app -u 1000:1000 myapp

Match production security posture in dev: run as a non-root UID/GID (`-u`) and set the process working directory (`-w`) so relative paths and file writes land where the image author expects.

Override entrypoint

docker run --entrypoint sh -it myapp

Bypass the image default `ENTRYPOINT`/`CMD` (for example to open a shell or run `strace`) without rebuilding. Useful when the app binary crashes on startup and you need to inspect the filesystem first.

Resource limits

docker run --cpus ... --memory ...

Cap CPU time and memory on shared laptops or shared CI runners so one container cannot starve others. Combine with `docker stats` to validate limits under load.

List running

docker ps

Default inventory of containers in the `running` state: IDs, names, ports, and the command line Docker is tracking.

List all

docker ps -a

Include exited and created containers — use this when a `docker run` fails with “name already in use” or when cleaning up failed one-off runs.

Start / stop

docker start NAME
docker stop NAME

Reuse an existing container with the same filesystem layer and configuration instead of `docker run` again. `stop` sends SIGTERM then SIGKILL after a grace period (configurable with `-t`).

Hard kill

docker kill NAME

Immediate termination when `stop` is too slow or the process ignores SIGTERM. Optionally pass a different signal (for example `docker kill -s HUP`).

Remove container

docker rm NAME

Delete a stopped container record to free the name and metadata. `docker rm -f` force-removes a running container (SIGKILL then remove).

Bulk remove stopped

docker container prune

Batch-delete all stopped containers after heavy local experimentation; review the prompt carefully on shared machines.

Same commands as the cards above — Run and lifecycle
Scenario Command (example) When to use
Foreground run docker run --rm -it nginx Throwaway interactive runs: smoke tests, one-off CLIs, or getting a shell. `--rm` removes the container filesystem after exit so names and disk do not accumulate; `-it` allocates a pseudo-TTY and keeps STDIN open so shell-oriented images behave like a normal terminal.
Detached server docker run -d --name web -p 8080:80 nginx Processes that should keep running in the background (web servers, queues, agents). `-d` detaches immediately; `--name` gives a stable handle for `docker logs`/`exec`; `-p host:container` publishes a port on the host so browsers or other apps can reach the service.
Env vars docker run -e PORT=3000 myapp Configuration that varies by environment without baking secrets or ports into the image (12-factor style). Use multiple `-e` flags or `--env-file` to load a file of `KEY=value` lines.
Auto-remove docker run --rm ... CI steps, linters, codegen, or any job where you want the container gone as soon as the command finishes. Avoids exited containers piling up and frees the container name immediately.
Restart policy docker run --restart unless-stopped ... Daemons on Linux hosts or VMs where Docker restarts after reboot. `unless-stopped` restarts the container unless you explicitly stopped it; alternatives include `on-failure` for batch-style workloads.
Workdir / user docker run -w /app -u 1000:1000 myapp Match production security posture in dev: run as a non-root UID/GID (`-u`) and set the process working directory (`-w`) so relative paths and file writes land where the image author expects.
Override entrypoint docker run --entrypoint sh -it myapp Bypass the image default `ENTRYPOINT`/`CMD` (for example to open a shell or run `strace`) without rebuilding. Useful when the app binary crashes on startup and you need to inspect the filesystem first.
Resource limits docker run --cpus ... --memory ... Cap CPU time and memory on shared laptops or shared CI runners so one container cannot starve others. Combine with `docker stats` to validate limits under load.
List running docker ps Default inventory of containers in the `running` state: IDs, names, ports, and the command line Docker is tracking.
List all docker ps -a Include exited and created containers — use this when a `docker run` fails with “name already in use” or when cleaning up failed one-off runs.
Start / stop docker start NAME docker stop NAME Reuse an existing container with the same filesystem layer and configuration instead of `docker run` again. `stop` sends SIGTERM then SIGKILL after a grace period (configurable with `-t`).
Hard kill docker kill NAME Immediate termination when `stop` is too slow or the process ignores SIGTERM. Optionally pass a different signal (for example `docker kill -s HUP`).
Remove container docker rm NAME Delete a stopped container record to free the name and metadata. `docker rm -f` force-removes a running container (SIGKILL then remove).
Bulk remove stopped docker container prune Batch-delete all stopped containers after heavy local experimentation; review the prompt carefully on shared machines.
Terminal window
docker run -d --name counter-api -p 3000:3000 --restart unless-stopped counter-api:dev
docker ps
docker stop counter-api && docker rm counter-api

Stream logs

docker logs -f counter-api

See stdout/stderr like tail -f.

Last N lines

docker logs --tail 100 counter-api

Large logs without flooding terminal.

Since timestamp

docker logs --since 2026-01-01T12:00:00 counter-api

Incident windows.

Attach to PID1 TTY

docker attach counter-api

Care: Ctrl+C may go to process; prefer exec for shells.

Shell inside

docker exec -it counter-api sh

Live debugging, curl localhost, printenv.

One-off command

docker exec counter-api wget -qO- http://127.0.0.1:3000/health

Smoke tests from same network ns.

JSON metadata

docker inspect counter-api

IP, mounts, state, host paths.

JSONPath

docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' counter-api

Scripting.

Live processes

docker top counter-api

Like ps on host for container PIDs.

Live stats

docker stats

CPU/mem streams for all running containers.

Event stream

docker events --since 1h

Correlate restarts with deploys.

Copy files

docker cp counter-api:/app/logs.txt .
docker cp ./local.conf counter-api:/tmp/

Grab cores, configs, heap dumps.

Same commands as the cards above — Debug and inspect
Scenario Command (example) When to use
Stream logs docker logs -f counter-api See stdout/stderr like tail -f.
Last N lines docker logs --tail 100 counter-api Large logs without flooding terminal.
Since timestamp docker logs --since 2026-01-01T12:00:00 counter-api Incident windows.
Attach to PID1 TTY docker attach counter-api Care: Ctrl+C may go to process; prefer exec for shells.
Shell inside docker exec -it counter-api sh Live debugging, curl localhost, printenv.
One-off command docker exec counter-api wget -qO- http://127.0.0.1:3000/health Smoke tests from same network ns.
JSON metadata docker inspect counter-api IP, mounts, state, host paths.
JSONPath docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' counter-api Scripting.
Live processes docker top counter-api Like ps on host for container PIDs.
Live stats docker stats CPU/mem streams for all running containers.
Event stream docker events --since 1h Correlate restarts with deploys.
Copy files docker cp counter-api:/app/logs.txt . docker cp ./local.conf counter-api:/tmp/ Grab cores, configs, heap dumps.
Terminal window
docker logs -f --tail 50 counter-api
docker exec -it counter-api sh
docker inspect counter-api --format '{{.State.Health.Status}}'

List networks

docker network ls

See bridge/host/custom.

Create network

docker network create app-net

User-defined bridge for DNS between containers.

Inspect network

docker network inspect app-net

Containers attached, subnets, gateways.

Remove unused nets

docker network prune

After compose experiments.

List volumes

docker volume ls

Named volumes for databases.

Create volume

docker volume create pgdata

Pre-create with labels or drivers.

Inspect volume

docker volume inspect pgdata

Mountpoint on Linux host disk.

Same commands as the cards above — Networks and volumes (objects)
Scenario Command (example) When to use
List networks docker network ls See bridge/host/custom.
Create network docker network create app-net User-defined bridge for DNS between containers.
Inspect network docker network inspect app-net Containers attached, subnets, gateways.
Remove unused nets docker network prune After compose experiments.
List volumes docker volume ls Named volumes for databases.
Create volume docker volume create pgdata Pre-create with labels or drivers.
Inspect volume docker volume inspect pgdata Mountpoint on Linux host disk.
Terminal window
docker network create counter-net
docker volume create counter-pgdata

Disk usage breakdown

docker system df

See images vs build cache vs containers.

Safe cleanup

docker system prune

Remove stopped containers + dangling nets + build cache (layers still used stay).

Aggressive cleanup

docker system prune -a --volumes

Destructive — removes unused images and unused volumes.

Same commands as the cards above — Disk and cleanup
Scenario Command (example) When to use
Disk usage breakdown docker system df See images vs build cache vs containers.
Safe cleanup docker system prune Remove stopped containers + dangling nets + build cache (layers still used stay).
Aggressive cleanup docker system prune -a --volumes Destructive — removes unused images and unused volumes.

Start stack

docker compose up -d

From directory with compose.yaml.

Rebuild images

docker compose up -d --build

After Dockerfile changes.

Tear down

docker compose down

Stop and remove containers + default network.

Remove volumes too

docker compose down -v

Reset Postgres data in dev.

Service status

docker compose ps

Which container is healthy.

Logs

docker compose logs -f api

Per-service follow.

Shell in service

docker compose exec api sh

Same as docker exec but with service name.

Pull bases

docker compose pull

Refresh pinned tags before CI.

Build only

docker compose build

Warm cache before up.

Same commands as the cards above — Compose (multi-service dev)
Scenario Command (example) When to use
Start stack docker compose up -d From directory with compose.yaml.
Rebuild images docker compose up -d --build After Dockerfile changes.
Tear down docker compose down Stop and remove containers + default network.
Remove volumes too docker compose down -v Reset Postgres data in dev.
Service status docker compose ps Which container is healthy.
Logs docker compose logs -f api Per-service follow.
Shell in service docker compose exec api sh Same as docker exec but with service name.
Pull bases docker compose pull Refresh pinned tags before CI.
Build only docker compose build Warm cache before up.

See the full counter-api stack: Docker Compose.


Save image tarball

docker save -o myapp.tar myapp:1.0

Move image without registry.

Load tarball

docker load -i myapp.tar

Import on target host.

Export filesystem

docker export CONTAINER > rootfs.tar

Rare — loses history/metadata; prefer save.

Import tarball as image

docker import rootfs.tar myimg:raw

Golden images / odd pipelines.

Same commands as the cards above — Air-gap / CI artifacts (short)
Scenario Command (example) When to use
Save image tarball docker save -o myapp.tar myapp:1.0 Move image without registry.
Load tarball docker load -i myapp.tar Import on target host.
Export filesystem docker export CONTAINER > rootfs.tar Rare — loses history/metadata; prefer save.
Import tarball as image docker import rootfs.tar myimg:raw Golden images / odd pipelines.

Image insights

docker scout quickview myapp:latest

If Docker Scout enabled — quick CVE overview.

Same commands as the cards above — Supply chain (optional one-liner)
Scenario Command (example) When to use
Image insights docker scout quickview myapp:latest If Docker Scout enabled — quick CVE overview.

When a container “hangs on start,” pulls the wrong digest, or survives a daemon restart, you need a request path mental model — not more flags guessed at random.


Docker engine stack from CLI to kernel

The request stack: each layer hands off to the next, ending at kernel primitives.

The stack shows the flow; the diagram below shows who owns what when you need to point at a subsystem:

Engine component responsibilities

Vertical ownership: each row is a process boundary you can observe on the host (ps, journalctl, inspect).

CLI through runc to kernel

Horizontal slice of the same stack — matches the arrows in the stack figure above.

docker run happy path — order of operations

Section titled “docker run happy path — order of operations”

docker run sequence

Numbered hand-offs: registry is optional when layers are already local; shim IO is omitted for space.

Step by step:

  1. CLI → dockerd — the CLI sends an HTTP request over the Unix socket (/var/run/docker.sock on Linux). Payload includes image reference, cmd/entrypoint, mounts, port bindings, env, cgroup limits, and network mode.
  2. dockerd resolves the image — if missing locally, the pull flow talks to the registry (manifests, layer blobs) and stores content through containerd’s content store.
  3. Snapshot / rootfs — containerd unpacks image layers into a snapshot (usually overlay2 on Linux). That snapshot becomes the container’s root filesystem view.
  4. OCI bundlerunc expects a directory (a bundle) with config.json (OCI runtime spec) and rootfs/. The shim and containerd prepare that bundle from image config + mounts.
  5. containerd-shim — one shim process per container so runc can exit after spawning the workload while stdio streaming and exit codes remain available.
  6. runc — applies namespaces and cgroups, pivots into rootfs, execs PID 1 (your CMD/ENTRYPOINT).

Trace a real docker run from command to running process, then inspect every layer to confirm each hand-off happened correctly.

Terminal window
$ docker run -d --name counter-api -p 3000:3000 counter-api:dev
a7f3e2c1d84b9051e6c2a0f9b3d4e7c8f1a2b5d6e9f0c3a4b7d8e1f2a3b6c9d0

In a second terminal, observe the lifecycle events that dockerd emitted:

Terminal window
$ docker events --since 1m --filter container=counter-api
2024-05-15T09:14:02.110381Z container pull counter-api:dev (image=counter-api:dev)
2024-05-15T09:14:02.391724Z container create counter-api (image=counter-api:dev, name=counter-api)
2024-05-15T09:14:02.447891Z network connect app-net (container=counter-api, name=app-net)
2024-05-15T09:14:02.513042Z container start counter-api (image=counter-api:dev, name=counter-api)

Interpretation: create (step 4 — OCI bundle built) fires before start (step 6 — runc execs PID 1). If you see create without start, the failure is in shim / runc, not in your application code.

3. Confirm the container is actually running

Section titled “3. Confirm the container is actually running”
Terminal window
$ docker ps --format '{{.Names}}\t{{.Status}}\t{{.Ports}}'
counter-api Up 8 seconds 0.0.0.0:3000->3000/tcp
Terminal window
$ docker inspect --format \
'{{.HostConfig.Runtime}} | PID={{.State.Pid}} | Status={{.State.Status}}' \
counter-api
runc | PID=84732 | Status=running
  • Runtime=runc confirms the OCI runtime that executed this container (could be runsc, kata for alternative runtimes you’ve registered with dockerd).
  • PID=84732 is the host-level PID of the shim process holding the container’s stdio; PID 1 inside the container is the process the shim launched via runc.
  • Status=running means containerd’s task state is live.
Terminal window
$ ps aux | grep containerd-shim | grep counter-api
root 84729 0.0 0.0 containerd-shim-runc-v2 -namespace moby -id a7f3e2c1d84b...

One containerd-shim-runc-v2 per container — exactly what the mental model predicts. This process outlives runc (which exits immediately after spawning PID 1) and survives a dockerd restart when live-restore is enabled.

Terminal window
$ curl -s localhost:3000/health
{"status":"ok","count":0}

Terminal window
# Which Docker context am I talking to? (avoid accidentally hitting prod)
docker context show
# Deep config: default bridge CIDR, cgroup driver, registered runtimes
docker info --format '{{json .Runtimes}}'
# Live events stream — filter by image or container name
docker events --since 5m --filter image=counter-api:dev
# Full JSON config of a container (runtime, mounts, cgroup limits, state)
docker inspect counter-api
# Runtime and PID in one line
docker inspect --format \
'{{.HostConfig.Runtime}} | PID={{.State.Pid}} | Status={{.State.Status}}' \
counter-api
# Linux: follow daemon logs
journalctl -u docker.service -f

Image builds (docker build) are handled by BuildKit, even when you use the classic command. BuildKit resolves Dockerfile instructions into a DAG, pulls base layers, runs build steps in ephemeral containers or LLB workers, then exports a new image manifest into containerd’s content store.

Terminal window
docker buildx version # confirm BuildKit is active
docker buildx ls # see builders: default docker driver vs. remote Kubernetes builder

An OCI bundle is a folder on disk: config.json (OCI runtime spec) plus a rootfs/ tree. runc run reads that folder, applies isolation (namespaces, cgroups, seccomp), and replaces itself with your program as PID 1. You rarely touch bundles manually — but docker inspect output is the same information surfaced as JSON for the higher-level Docker object.

ComponentOwned byFails when…
docker CLIDocker, Inc.socket path wrong, wrong context
dockerdDocker, Inc.image not found, port conflict, daemon crash
containerdCNCF / Dockersnapshot corruption, content store full
containerd-shimCNCF / Dockerstdio leaks, zombie on restart
runcOCInamespace/cgroup limits, seccomp policy, rootfs missing

  • docker run hangs at “create” but never reaches “start” — the OCI bundle preparation failed. Look for docker events showing create but no start, then check journalctl -u docker.service for runc: container_linux.go errors (often a seccomp or capability issue).

  • Container survives systemctl restart docker unexpectedly — live-restore is on ("live-restore": true in /etc/docker/daemon.json). The shim holds the container alive independently of dockerd. Intentional, but surprising the first time.

  • runc runtime error: operation not permitted — a cgroup v2 host with a container that targets v1-only paths. Check docker info | grep 'Cgroup Version' and update the container’s cgroup configuration.

  • Multiple containerd-shim processes per container — you probably restarted dockerd without live-restore enabled and then started a new container. Orphaned shims: docker system prune won’t remove them; find and kill stale shims with ps aux | grep containerd-shim.


You need reproducible deploys: the same bytes in CI, staging, and prod — without copying a 20 GB VM each time. And you want pulls to be fast: if two images share a base layer, that layer should only ever be downloaded and stored once.

OCI image concepts

One image ties together layers, config, tags, digests, registry transport, and the writable container layer.

Overlay-style union of image layers and writable container layer

A union mount stacks read-only image layers under one thin writable container layer (copy-on-write).

Terminal window
$ docker pull redis:7.2-alpine
7.2-alpine: Pulling from library/redis
579b34f0a95b: Pull complete
d3e1b2ad34f4: Pull complete
a3e53f9cbee4: Pull complete
a5e7d5ff7cbe: Pull complete
Digest: sha256:3d9f5b4c6a2e1d8b0c7f3a1e2d6b9c4f7a0e3d8b2c5f1a4e7d0b3c6f9a2e5d8b1
Status: Downloaded newer image for redis:7.2-alpine
docker.io/library/redis:7.2-alpine

Each Pull complete line is one layer blob being downloaded and verified by its sha256. The final Digest: is the manifest hash — the same manifest hash on any registry node or any machine worldwide.

Terminal window
$ docker history redis:7.2-alpine --human --no-trunc
IMAGE CREATED CREATED BY SIZE COMMENT
sha256:3d9f5b4c6a2e1d8b0c7f3a1e2d6b9c4f7a0e3d8b2c5f1a4e7d0b3c6f9a2e5d8 2 weeks ago CMD ["redis-server"] 0B buildkit.dockerfile.v0
<missing> 2 weeks ago EXPOSE map[6379/tcp:{}] 0B buildkit.dockerfile.v0
<missing> 2 weeks ago RUN /bin/sh -c set -eux; savedAptMark="$(apt-mark showmanual)" 31.2MB buildkit.dockerfile.v0
<missing> 2 weeks ago ENV REDIS_VERSION=7.2.4 REDIS_DOWNLOAD_URL=http://download.redis.io/… 0B buildkit.dockerfile.v0
<missing> 2 weeks ago RUN /bin/sh -c addgroup -S redis && adduser -S -G redis redis 4.72kB buildkit.dockerfile.v0
<missing> 2 weeks ago FROM alpine:3.19 7.34MB

The 0B rows are metadata-only instructions (CMD, EXPOSE, ENV) — they cost nothing on disk. The large RUN layer (31.2 MB) compiles and installs Redis. The FROM alpine:3.19 row is the base layer: 7.34 MB.

Layer reuse in action: pull a second Alpine-based image

Section titled “Layer reuse in action: pull a second Alpine-based image”

Now pull alpine:3.19 directly and watch what happens:

Terminal window
$ docker pull alpine:3.19
3.19: Pulling from library/alpine
579b34f0a95b: Already exists
Digest: sha256:13b7e62e8df80264dbb747995705a986aa530415763a6c58f84a3ca8af9a5bcd
Status: Downloaded newer image for alpine:3.19
docker.io/library/alpine:3.19

579b34f0a95b: Already exists — Docker’s content-addressable store recognised the blob hash and skipped the download entirely. That layer (Alpine’s root filesystem) is stored once on disk and mounted read-only by both redis:7.2-alpine and alpine:3.19. Every image that derives from alpine:3.19 gets the same deduplication for free.

Terminal window
$ docker image inspect redis:7.2-alpine --format '{{.RepoDigests}}'
[docker.io/library/redis@sha256:3d9f5b4c6a2e1d8b0c7f3a1e2d6b9c4f7a0e3d8b2c5f1a4e7d0b3c6f9a2e5d8b1]
$ docker image inspect alpine:3.19 --format '{{.RepoDigests}}'
[docker.io/library/alpine@sha256:13b7e62e8df80264dbb747995705a986aa530415763a6c58f84a3ca8af9a5bcd]

RepoDigests is the manifest digest — the thing you should pin in a Dockerfile or Kubernetes manifest for production.

Terminal window
$ docker tag redis:7.2-alpine myregistry.io/cache/redis:7.2-alpine
$ docker login myregistry.io
$ docker push myregistry.io/cache/redis:7.2-alpine
The push refers to repository [myregistry.io/cache/redis]
579b34f0a95b: Layer already exists
d3e1b2ad34f4: Pushed
a3e53f9cbee4: Pushed
a5e7d5ff7cbe: Pushed
7.2-alpine: digest: sha256:3d9f5b4c6a2e1d8b0c7f3a1e2d6b9c4f7a0e3d8b2c5f1a4e7d0b3c6f9a2e5d8b1 size: 1165

Layer already exists appears for any layer the registry has already seen — the same dedup logic applies on the server side. docker tag never copies bytes; it just creates a new pointer in local metadata.

Registry pull by tag vs digest

Tags resolve to a manifest digest first; digest pulls skip the mutable tag indirection.

A tag is a mutable pointer — the registry can reassign :latest at any time. A digest is immutable — if the bytes change, the hash changes. Production environments should always pull by digest.


CommandWhat it does
docker pull IMAGE[:TAG]Download image from registry
docker pull IMAGE@sha256:…Pull exact immutable manifest
docker images [REPO]List local images
docker tag SOURCE TARGETCreate a new tag alias (no copy)
docker push IMAGEUpload layers to registry
docker image inspect IMAGEFull JSON metadata (env, ports, digests)
docker history IMAGE [--no-trunc]Per-layer size and origin instruction
docker image rm IMAGERemove image from local store
docker image ls -f dangling=trueList untagged <none> layers
docker image pruneRemove dangling layers
docker save -o FILE.tar IMAGEExport image to tarball
docker load -i FILE.tarImport image from tarball
docker search TERMSearch Docker Hub
docker login REGISTRYAuthenticate to a registry
ConceptMutable?When to use
Tag (:7.2-alpine)Yes — can be reassignedHuman releases, local dev, convenience
Digest (@sha256:…)No — content-addressedDockerfile FROM, Kubernetes pod specs, SBOM pinning

Pin with digest in production:

Terminal window
docker pull alpine@sha256:4bcff63911fcb4448bd4fdacec03ce985c43f7af5a99552d4d0d0e3e2730a027

The build context is a tarball sent to the daemon on every docker build. Without ignore rules you upload node_modules, .git, and secrets — slowing every build and potentially leaking credentials.

Example for a Node counter-api/ project:

.dockerignore
node_modules
npm-debug.log
.git
.env
.env.*
dist
coverage

Mirror your .gitignore plus any secret filenames specific to your stack.

Terminal window
docker save -o counter-api.tar counter-api:1.0.0
docker load -i counter-api.tar

Use for air-gapped transfers, demo laptops, or CI artifact hand-offs where a registry is unavailable.


  • Tags are not locks. :latest on Docker Hub can change under you overnight. If your CI pulls :latest on every build, you may get a different image tomorrow. Pin with @sha256: for reproducibility.
  • docker tag does not copy bytes. It creates a new local metadata pointer to the same manifest. The layers are unchanged — both the old and new name share the exact same blobs.
  • docker history rows may not map 1:1 to Dockerfile lines. Multi-stage builds, --squash, and BuildKit cache exports can merge or split layers. Use it for size triage, not audit.
  • A dangling image is not a missing layer. <none>:<none> images are intermediate layers orphaned when you rebuild with the same tag. They waste space but are harmless — prune them with docker image prune (without -a).
  • Pushing a tag does not push a digest reference. You must explicitly pull from the registry after pushing and capture the returned digest if you need to record the canonical @sha256: for a lockfile.

You want a small, fast-to-pull image that still has all compilers and test tools during build — without shipping them to production. And you want rebuilds to take seconds, not minutes, because unchanged layers come from cache.

Each FROM starts a fresh stage. Later stages COPY --from only the artifacts they need, so build-time tooling never reaches the runtime image:

Multi-stage Dockerfile flow

Artifacts flow forward; only the final stage becomes the image you tag and ship.

BuildKit hashes every instruction and its inputs. A cache hit reuses the stored layer; a cache bust forces that layer — and every layer after it — to re-execute. This is why you order instructions from least frequently changing to most frequently changing:

Docker layer cache order

Put stable lines first; anything that invalidates cache also invalidates every later RUN/COPY.

Editing a single source file only busts the COPY src/ layer and below — the expensive npm ci step stays cached because package-lock.json did not change.


Starting from counter-api/ with the Dockerfile below (full listing in Reference):

Terminal window
$ docker build -t counter-api:dev .
[+] Building 18.4s (14/14) FINISHED
=> [internal] load build definition from Dockerfile 0.0s
=> [internal] load .dockerignore 0.0s
=> [internal] load metadata for docker.io/library/node:20-bookworm 1.2s
=> [internal] load metadata for docker.io/library/node:20-bookworm-slim 1.1s
=> [deps 1/3] FROM node:20-bookworm@sha256:... 3.8s
=> [deps 2/3] COPY package.json package-lock.json ./ 0.1s
=> [deps 3/3] RUN npm ci 9.2s
=> [build 1/3] COPY --from=deps /app/node_modules ./node_modules 0.4s
=> [build 2/3] COPY . . 0.1s
=> [build 3/3] RUN npm run build 1.8s
=> [prod 1/3] FROM node:20-bookworm-slim@sha256:... 2.1s
=> [prod 2/3] COPY --from=build /app/dist ./dist 0.1s
=> [prod 3/3] COPY package.json ./ 0.0s
=> exporting to image 0.4s
=> => exporting layers 0.3s
=> => writing image sha256:4a2b9c1d... 0.0s
=> => naming to docker.io/library/counter-api:dev 0.0s

What happened: BuildKit resolved both base images in parallel (node:20-bookworm for deps/build, node:20-bookworm-slim for prod), ran npm ci (the slowest step), compiled the source, then assembled the slim runtime image. Total: 18 s on a cold cache.

Edit src/index.js (a single-line change), then rebuild:

Terminal window
$ docker build -t counter-api:dev .
[+] Building 2.9s (14/14) FINISHED
=> [internal] load build definition from Dockerfile 0.0s
=> [internal] load .dockerignore 0.0s
=> [internal] load metadata for docker.io/library/node:20-bookworm 0.8s
=> [internal] load metadata for docker.io/library/node:20-bookworm-slim 0.7s
=> CACHED [deps 1/3] FROM node:20-bookworm@sha256:... 0.0s
=> CACHED [deps 2/3] COPY package.json package-lock.json ./ 0.0s
=> CACHED [deps 3/3] RUN npm ci 0.0s
=> CACHED [build 1/3] COPY --from=deps /app/node_modules ./node_modules 0.0s
=> [build 2/3] COPY . . 0.1s
=> [build 3/3] RUN npm run build 1.8s
=> CACHED [prod 1/3] FROM node:20-bookworm-slim@sha256:... 0.0s
=> [prod 2/3] COPY --from=build /app/dist ./dist 0.1s
=> [prod 3/3] COPY package.json ./ 0.0s
=> exporting to image 0.2s
=> => exporting layers 0.1s
=> => writing image sha256:7f3c0e2a... 0.0s
=> => naming to docker.io/library/counter-api:dev 0.0s

Cache behavior: package.json and package-lock.json did not change, so the npm ci step (deps 3/3) is CACHED — the 9 s install is skipped entirely. Only COPY . . (which picks up the edited file), npm run build, and the two prod-stage copy steps re-run. Rebuild took 2.9 s instead of 18 s.

Terminal window
$ docker history counter-api:dev
IMAGE CREATED CREATED BY SIZE
7f3c0e2a1b4c 2 seconds ago CMD ["node" "dist/index.js"] 0B
<missing> 2 seconds ago HEALTHCHECK --interval=10s --timeout=3s ... 0B
<missing> 2 seconds ago EXPOSE 3000 0B
<missing> 2 seconds ago COPY package.json ./ # buildkit 1.23kB
<missing> 2 seconds ago COPY /app/dist ./dist # buildkit 45.2kB
<missing> 2 seconds ago USER node 0B
<missing> 2 seconds ago ENV NODE_ENV=production 0B
<missing> 2 seconds ago WORKDIR /app 0B
<missing> 3 minutes ago /bin/sh -c #(nop) CMD ["node"] 0B
... ... node:20-bookworm-slim base layers 74.4MB

The final image is ~75 MB. The node_modules from the deps stage and all build tools from node:20-bookworm are absent — they existed only in intermediate stages that BuildKit discarded.


# --- deps stage: install all deps from lockfile ---
FROM node:20-bookworm AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# --- build stage: compile / bundle ---
FROM node:20-bookworm AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# --- prod stage: slim runtime image ---
FROM node:20-bookworm-slim AS prod
WORKDIR /app
ENV NODE_ENV=production
USER node
COPY --from=build /app/dist ./dist
COPY package.json ./
RUN npm install --omit=dev
EXPOSE 3000
HEALTHCHECK --interval=10s --timeout=3s CMD node -e "require('http').get('http://127.0.0.1:3000/health',r=>process.exit(r.statusCode===200?0:1))"
CMD ["node", "dist/index.js"]
RUN --mount=type=cache,target=/root/.npm \
npm ci

Use this when the cache directory should persist across builds but must not land in an image layer. Works for npm, pip, go mod download, Maven, and Gradle. The mount is private to the builder — it never appears in docker history.

CommandWhen you use it
docker build -t counter-api:dev .Default BuildKit build, tags the result
docker build -t counter-api:1.0.0 --target prod .Build only up to a named stage; useful for iterating on the build stage while keeping deps cached
docker build --build-arg HTTP_PROXY=$HTTP_PROXY -t counter-api:ci .Forward proxy or version info at build time
docker build --no-cache -t counter-api:clean .Reproduce “fresh machine” failures (missing ARG, flaky network)
docker history counter-api:devInspect layer sizes and commands

docker buildx commands (cross-arch and remote builders)

Section titled “docker buildx commands (cross-arch and remote builders)”
Terminal window
# Create a builder that supports multi-platform builds
docker buildx create --name remote --driver docker-container
docker buildx use remote
# Build for two architectures and push to a registry
docker buildx build --platform linux/amd64,linux/arm64 \
-t myuser/counter-api:1.0.0 --push .
# Reclaim disk after heavy buildx experimentation
docker builder prune

Use --platform linux/amd64,linux/arm64 when developing on Apple Silicon and deploying to x86-64 servers, or when publishing public multi-arch images.


Common failure modes:

  • npm ci re-runs on every build even when nothing changed. Check that COPY package.json package-lock.json ./ appears before RUN npm ci, and that you are not running COPY . . before it. Any file that changes busts the layer.
  • --target stops before the expected stage. Stage names are case-sensitive in the FROM ... AS <name> line. COPY --from=Build fails if the stage is declared AS build.
  • CACHED layers from a previous image tag disappear on CI. By default, CI runners start with empty caches. Pass --cache-from type=registry,ref=myuser/counter-api:buildcache and --cache-to type=registry,ref=myuser/counter-api:buildcache,mode=max to persist the BuildKit cache in your registry.
  • docker build ignores .dockerignore. A missing or wrong .dockerignore lets node_modules/, .git/, and large test fixtures into the build context, slowing the COPY . . step and sometimes busting cache unexpectedly. Always exclude build artifacts and dev-only directories.

Your container starts but returns 502s, exits with code 137, or works on your laptop and fails in CI — you need fast feedback loops without rebuilding images blindly.

Know which state your container is in before picking a remedy. docker start only works from Stopped; docker rm -f is the nuclear option from any state.

Container lifecycle states

Simplified lifecycle — pause is optional; your day-to-day is usually Running ↔ Stopped.

Five categories cover almost every runtime problem:

Runtime debug toolbox

Start with observe + inspect; use exec when you need the same network and filesystem as PID 1.

Use this flow when a container is misbehaving and you do not know where to start:

Runtime debug decision tree

Branch on `docker ps` state first; exit 137 often means memory, but confirm with inspect.

Scenario: counter-api is crash-looping with exit 137

Section titled “Scenario: counter-api is crash-looping with exit 137”

You come back to find the container gone. Walk through the full investigation below.

Terminal window
$ docker ps -a --filter name=counter-api
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
a3f9c1e72b4d counter-api:dev "node src/index.js" 4 minutes ago Exited (137) 2 minutes ago counter-api

Exit 137 = 128 + signal 9 (SIGKILL). The process did not exit cleanly — something killed it. The two most common causes are OOM and an explicit docker kill.

Terminal window
$ docker inspect --format '{{.State.ExitCode}} {{.State.OOMKilled}}' counter-api
137 true

OOMKilled: true confirms the Linux kernel’s OOM killer sent SIGKILL because the container exceeded its memory limit (or the host ran out of memory).

Step 3 — look at the last log lines before the kill
Section titled “Step 3 — look at the last log lines before the kill”
Terminal window
$ docker logs --tail 30 counter-api
[server] Listening on :3000
[server] GET /count 200 4ms
[server] GET /increment 200 6ms
[server] GET /increment 200 5ms
[server] GET /count 200 4ms
[server] Memory in use: 142 MB
[server] Memory in use: 198 MB
[server] Memory in use: 247 MB
[server] Memory in use: 303 MB

The log shows memory climbing steeply with no corresponding error — the container simply disappeared mid-output when the kernel killed it. There is no graceful shutdown message.

Terminal window
$ docker events --filter container=counter-api --since 10m
2026-05-14T09:12:03.441Z container start a3f9c1e72b4d (image=counter-api:dev, name=counter-api)
2026-05-14T09:15:58.017Z container oom a3f9c1e72b4d (image=counter-api:dev, name=counter-api)
2026-05-14T09:15:58.024Z container die a3f9c1e72b4d (image=counter-api:dev, name=counter-api, exitCode=137)

The oom event appears six milliseconds before die — the event stream tells the story chronologically. No guesswork needed.

Step 5 — check what limit was set (none in this case)
Section titled “Step 5 — check what limit was set (none in this case)”
Terminal window
$ docker inspect --format '{{.HostConfig.Memory}}' counter-api
0

0 means no limit was set — the container was allowed to use all host memory until the kernel ran out and killed it. This is the most dangerous configuration for memory-leaking apps.

Step 6 — fix: restart with a memory limit
Section titled “Step 6 — fix: restart with a memory limit”
Terminal window
$ docker rm counter-api
$ docker run -d --name counter-api \
-p 3000:3000 \
--memory 256m \
--memory-swap 256m \
counter-api:dev
$ docker stats --no-stream counter-api
CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O
a9b2c3d4e5f6 counter-api 0.4% 41.3MiB / 256MiB 16.1% 648B / 0B 0B / 0B

--memory-swap 256m (equal to --memory) disables swap for this container, so future OOMs are deterministic and fast rather than causing host thrash.

Terminal window
$ curl -s localhost:3000/health
{"status":"ok","count":0}

Quick sanity check

docker ps

Running containers and published ports.

Find crash-looped containers

docker ps -a

All containers including exited.

Bulk cleanup investigation

docker ps --filter status=exited

Only stopped containers.

First stop for any failure

docker logs counter-api

All stdout and stderr so far.

Incident watching

docker logs -f --tail 100 counter-api

Live tail, last 100 lines.

Narrow a time window

docker logs --since 10m counter-api

Last ten minutes of logs.

Spot resource pressure

docker stats counter-api

Live CPU, memory, net, block I/O.

Script-friendly snapshot

docker stats --no-stream counter-api

Single stats sample.

See child processes

docker top counter-api

Process table inside the container.

Correlate crash timeline

docker events --filter container=counter-api

Lifecycle event stream.

Same commands as the cards above — Observation commands
Scenario Command (example) When to use
Quick sanity check docker ps Running containers and published ports.
Find crash-looped containers docker ps -a All containers including exited.
Bulk cleanup investigation docker ps --filter status=exited Only stopped containers.
First stop for any failure docker logs counter-api All stdout and stderr so far.
Incident watching docker logs -f --tail 100 counter-api Live tail, last 100 lines.
Narrow a time window docker logs --since 10m counter-api Last ten minutes of logs.
Spot resource pressure docker stats counter-api Live CPU, memory, net, block I/O.
Script-friendly snapshot docker stats --no-stream counter-api Single stats sample.
See child processes docker top counter-api Process table inside the container.
Correlate crash timeline docker events --filter container=counter-api Lifecycle event stream.

Under the hood: Docker aggregates container stdio from the container shim. The default json-file log driver writes rotating JSON files on the host — check docker inspect --format '{{.LogPath}}' counter-api when host disk fills.

docker inspect returns the full object as JSON. The --format flag accepts Go template syntax for targeted, script-friendly output.

Crash diagnosis

{{.State.ExitCode}} {{.State.OOMKilled}}

Pair exit code with OOM flag.

Process state

{{.State.Status}}

running / exited / paused.

Published port mappings

{{json .NetworkSettings.Ports}}

Structured port bindings.

json-file log path

{{.LogPath}}

Host path when disk fills.

Memory limit (bytes)

{{.HostConfig.Memory}}

0 means unlimited.

Restart policy name

{{.HostConfig.RestartPolicy.Name}}

no / always / on-failure.

Same commands as the cards above — Inspect (Go template fragments)
Scenario Command (example) When to use
Crash diagnosis {{.State.ExitCode}} {{.State.OOMKilled}} Pair exit code with OOM flag.
Process state {{.State.Status}} running / exited / paused.
Published port mappings {{json .NetworkSettings.Ports}} Structured port bindings.
json-file log path {{.LogPath}} Host path when disk fills.
Memory limit (bytes) {{.HostConfig.Memory}} 0 means unlimited.
Restart policy name {{.HostConfig.RestartPolicy.Name}} no / always / on-failure.
Terminal window
# Examples
docker inspect counter-api
docker inspect --format '{{.State.ExitCode}} {{.State.OOMKilled}}' counter-api
docker inspect --format '{{json .NetworkSettings.Ports}}' counter-api
docker inspect --format '{{.LogPath}}' counter-api
docker inspect --format '{{.HostConfig.Memory}}' counter-api
Terminal window
docker exec -it counter-api sh
docker exec counter-api printenv NODE_ENV
docker exec counter-api node -e "require('http').get('http://127.0.0.1:3000/health',r=>console.log(r.statusCode))"

docker exec joins the container’s existing namespaces — you see the same filesystem and network as PID 1, but you are a separate process. Use it for interactive shells, one-off smoke tests, and grabbing ps output from inside the container’s view.

Terminal window
# Pull a file from the container to the host
docker cp counter-api:/app/package.json ./package.json.from-container
# Push a file from the host into the container
docker cp ./local-config.json counter-api:/tmp/local-config.json

Use docker cp for core dumps, heap snapshots, and config files — without SSH into the host.

Normal shutdown

docker stop counter-api

SIGTERM, then SIGKILL after grace — let the app clean up.

Resume stopped

docker start counter-api

Same container config as before.

Quick bounce

docker restart counter-api

Stop then start during debugging.

Hung app

docker kill counter-api

SIGKILL immediately.

Reload config

docker kill --signal HUP counter-api

nginx-style SIGHUP reload.

Remove stopped

docker rm counter-api

After stop.

Kill and remove

docker rm -f counter-api

One step for a running container.

Prune all stopped

docker container prune

Removes every stopped container.

Same commands as the cards above — Lifecycle control
Scenario Command (example) When to use
Normal shutdown docker stop counter-api SIGTERM, then SIGKILL after grace — let the app clean up.
Resume stopped docker start counter-api Same container config as before.
Quick bounce docker restart counter-api Stop then start during debugging.
Hung app docker kill counter-api SIGKILL immediately.
Reload config docker kill --signal HUP counter-api nginx-style SIGHUP reload.
Remove stopped docker rm counter-api After stop.
Kill and remove docker rm -f counter-api One step for a running container.
Prune all stopped docker container prune Removes every stopped container.

Other common failures:

Exit 137, OOMKilled true

docker inspect --format '{{.State.OOMKilled}}' NAME

Container hit memory limit (or host OOM).

Exit 1 or 2 on start

docker logs counter-api

App crash or bad config.

Exit 0 immediately

Inspect Dockerfile CMD

Batch job finished cleanly.

Container keeps restarting

docker events

Die then start loops with restart policy.

Port already in use

lsof -i :3000

Another process on the host.

exec returns not running

docker ps -a

Container exited before exec landed.

Same commands as the cards above — Other common failures
Scenario Command (example) When to use
Exit 137, OOMKilled true docker inspect --format '{{.State.OOMKilled}}' NAME Container hit memory limit (or host OOM).
Exit 1 or 2 on start docker logs counter-api App crash or bad config.
Exit 0 immediately Inspect Dockerfile CMD Batch job finished cleanly.
Container keeps restarting docker events Die then start loops with restart policy.
Port already in use lsof -i :3000 Another process on the host.
exec returns not running docker ps -a Container exited before exec landed.

Microservices need to call each other by name, publish ports to your laptop browser, and avoid port collisions — without hard-coding IP addresses that change every docker compose up.

Pick the mode from what the container needs to reach, then picture where its eth0 plugs in.

Docker network mode picker

Match network mode to isolation and DNS needs; Compose uses a user-defined bridge per project.

Host bridge and veth pairs to containers

On the default bridge, each container gets a veth pair into docker0; published ports add NAT rules from the host.

-p 3000:3000 does not move your container onto the host’s network — it tells dockerd to program a DNAT (destination NAT) rule in the host’s iptables/nftables. A packet to localhost:3000 is rewritten to the container’s bridge IP and forwarded over docker0:

Host port publish DNAT flow

Published ports use DNAT on the host; the container still listens on its own port inside the network namespace.
Terminal window
$ docker run -d --name counter-api -p 3000:3000 counter-api:dev
9f2a3c7e1b4d6a8c0e2f1d3b5a7c9e0f2a4b6c8d0e1f3a5b7c9d1e3f5a7b9c0d
$ docker ps --format '{{.Names}}\t{{.Status}}\t{{.Ports}}'
counter-api Up 3 seconds 0.0.0.0:3000->3000/tcp, :::3000->3000/tcp
$ curl -s localhost:3000/health
{"status":"ok","count":0}
  • The 0.0.0.0:3000->3000/tcp line is the published mapping — 0.0.0.0 means any host interface. Bind to localhost only with -p 127.0.0.1:3000:3000 so it’s not exposed to your LAN.
  • curl hits the host port; netfilter DNATs it into the container. Nothing inside the container changed — it still just listens on :3000.

Worked example: two containers talking by name

Section titled “Worked example: two containers talking by name”

This is the everyday goal — an API resolving its database as db, no IPs hard-coded. Use a user-defined bridge (the default bridge has no DNS).

Terminal window
# 1. Isolated network with its own subnet + embedded DNS
$ docker network create app-net
b1c2d3e4f5a6...
# 2. Start Postgres and the API on that network
$ docker run -d --network app-net --name db \
-e POSTGRES_PASSWORD=secret -e POSTGRES_DB=counter postgres:16-alpine
$ docker run -d --network app-net --name api -p 3000:3000 \
-e DATABASE_URL=postgres://postgres:secret@db:5432/counter counter-api:dev
# 3. The API resolves "db" by name via Docker's embedded resolver
$ docker exec api getent hosts db
172.18.0.2 db
# 4. Hit the API by name from a throwaway container on the same network
$ docker run --rm --network app-net curlimages/curl -s http://api:3000/health
{"status":"ok","count":1}

Why it works: every container on app-net has /etc/resolv.conf pointing at 127.0.0.11, Docker’s embedded DNS. It answers db and api with their current bridge IPs, so a container restart (and new IP) never breaks the connection string.


FeatureDefault bridgeUser-defined bridge
DNS between containers❌ (use IPs / links)✅ container name → IP
IsolationAll on same default netSeparate subnet per network
Portable to Composeawkwardnetworks: block
ModeCommand sketchWhen you use this
bridge (default)docker run nginxNormal app containers
hostdocker run --network host ...Performance / multicast quirks; Linux-only semantics
nonedocker run --network none ...Air-gapped batch jobs
container:NSdocker run --network container:peer ...Share another container’s stack (sidecars, legacy)
Terminal window
docker network ls # list networks (bridge/host/custom)
docker network create app-net # user-defined bridge with DNS
docker network inspect app-net # subnet, gateway, attached containers
docker network connect app-net api # attach a running container
docker network rm app-net # remove one network

  • The default bridge has no name resolution. If ping db fails, you’re almost always on the default bridge — create a user-defined network instead.
  • Published ports can collide. bind: address already in use means the host port is taken; change the left side (-p 3001:3000) or stop the other listener.
  • Don’t prune the default bridge. docker network prune removes unused user-defined networks only — it won’t (and shouldn’t) touch bridge, host, or none.
Terminal window
docker network prune # clean up user-defined networks after experiments

Containers are ephemeral by design — the writable layer on top of an image is tied to the container’s lifetime. Delete the container (docker rm) and that layer is gone. If your Postgres container is storing data inside its own writable layer, you lose every row when you recreate it.

You also need a way to inject live source code from your laptop into a container so edits take effect without a rebuild.

Docker solves both by separating the data plane (where bytes live) from the container lifecycle.

Storage strategy decision

Pick bind mounts for live code on disk; named volumes when you want Docker to own the backing path.

Bind mount vs named volume paths

Bind mounts mirror a host directory; named volumes map to a managed directory under /var/lib/docker/volumes on Linux.

The key insight: a named volume’s backing directory on the host exists independently of any container. When you docker rm a container, the named volume is untouched. The next container that mounts the same volume name gets the exact same bytes.


Part 1 — Prove persistence with a named volume and Postgres

Section titled “Part 1 — Prove persistence with a named volume and Postgres”
Terminal window
# 1. Create the named volume explicitly (also happens implicitly on first run)
$ docker volume create counter-pgdata
counter-pgdata
# 2. Inspect it — see where Docker stores the actual files on the host
$ docker volume inspect counter-pgdata
[
{
"CreatedAt": "2024-11-14T09:12:03Z",
"Driver": "local",
"Labels": {},
"Mountpoint": "/var/lib/docker/volumes/counter-pgdata/_data",
"Name": "counter-pgdata",
"Options": {},
"Scope": "local"
}
]

Mountpoint is where data physically lives. On macOS Docker Desktop this path is inside the Linux VM, not directly on your Mac filesystem — which is why named volumes are faster on macOS than bind mounts.

Terminal window
# 3. Start Postgres, mounting the volume at the data directory
$ docker run -d \
--name pg-demo \
-e POSTGRES_PASSWORD=secret \
-e POSTGRES_DB=counter \
-v counter-pgdata:/var/lib/postgresql/data \
postgres:16-alpine
a3f8c1e2d4b6a9e0c1f2d3b4a5c6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4
$ docker ps --format '{{.Names}}\t{{.Status}}'
pg-demo Up 4 seconds
Terminal window
# 4. Write a row (connect via psql inside the container)
$ docker exec -it pg-demo psql -U postgres -d counter -c \
"CREATE TABLE hits (id serial PRIMARY KEY, ts timestamptz DEFAULT now()); INSERT INTO hits DEFAULT VALUES;"
CREATE TABLE
INSERT 0 1
$ docker exec pg-demo psql -U postgres -d counter -c "SELECT * FROM hits;"
id | ts
----+-------------------------------
1 | 2024-11-14 09:12:41.537429+00
(1 row)
Terminal window
# 5. Destroy the container — simulating an upgrade, a crash, a redeploy
$ docker rm -f pg-demo
pg-demo
# The volume still exists — no -v flag means the volume was NOT deleted
$ docker volume ls
DRIVER VOLUME NAME
local counter-pgdata
Terminal window
# 6. Start a brand-new container with the SAME volume name
$ docker run -d \
--name pg-demo-v2 \
-e POSTGRES_PASSWORD=secret \
-e POSTGRES_DB=counter \
-v counter-pgdata:/var/lib/postgresql/data \
postgres:16-alpine
b7c2d1e3f5a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4
# 7. Query — row survives because the volume, not the container, owns the data
$ docker exec pg-demo-v2 psql -U postgres -d counter -c "SELECT * FROM hits;"
id | ts
----+-------------------------------
1 | 2024-11-14 09:12:41.537429+00
(1 row)

Why this works: Postgres writes its data files under /var/lib/postgresql/data. Docker maps that path to counter-pgdata/_data on the host. When the container is removed, only the writable layer (logs, pid files, ephemeral state) disappears. The named volume’s contents are unaffected. The new container mounts the same directory and Postgres resumes right where it left off.


Bind mounts are the standard way to get live-reload during development: your editor saves to the host, the container sees the change instantly through the shared path.

Terminal window
# From the project root — mount source into the container's working directory
$ docker run --rm -it \
-p 3000:3000 \
-v "$PWD:/app" \
-w /app \
node:20-bookworm \
npm run dev
> counter-api@1.0.0 dev
> nodemon src/index.js
[nodemon] 3.0.2
[nodemon] watching path(s): *.*
[nodemon] starting `node src/index.js`
Server listening on :3000

Edit any file in $PWD/src on the host — nodemon detects the change inside the container and restarts automatically. No rebuild, no new image.

Terminal window
# In another terminal — confirm the API is live
$ curl -s localhost:3000/health
{"status":"ok","count":0}

MechanismHost path you control?Typical use
Bind mountYes (-v $PWD:/app)Live-reload dev, injecting configs, CI artifacts
Named volumeNo (Docker manages it)Database files, uploaded blobs, build caches
tmpfsRAM only (no disk)Sensitive scratch data, reduce disk I/O
Terminal window
docker volume create counter-pgdata # create a named volume
docker volume ls # list all volumes
docker volume inspect counter-pgdata # show Mountpoint and metadata
docker volume rm counter-pgdata # delete one volume (must be detached)
docker volume prune # delete all volumes not used by any container
docker system df -v # show disk usage: volumes vs images vs build cache

docker volume inspect tip: read the Mountpoint field to find where data physically lives. On Linux this is /var/lib/docker/volumes/<name>/_data; on macOS it is inside the Docker Desktop VM and you cannot browse it directly from the Mac filesystem.

docker system df -v tip: see how much each named volume consumes so you know what to clean before a disk-full situation.


macOS bind-mount performance: Bind mounts on Docker Desktop route through a VM filesystem sync layer. Mounting a large node_modules directory (tens of thousands of files) can be noticeably slow. Mitigations:

  • Mount only src/ instead of the whole project root.
  • Use a named volume for node_modules and a bind mount for src/ (classic two-volume pattern).
  • Adopt a devcontainer setup where node_modules lives entirely inside the container.

Linux UID/GID EACCES errors: If the container process runs as node (uid 1000) but host files are owned by your account (uid 501 on macOS, typically 1000 on Linux but may differ in CI), bind-mount writes fail with permission denied.

Common mitigations:

  • Run the dev container as root for bind mounts (not suitable for production images).
  • Align UIDs: chown -R 1000:1000 ./src on the host or in a CI setup step.
  • Use a named volume for write-heavy paths and a bind mount only for read-heavy source files.

Named volume vs writable layer: if you do NOT mount a volume or bind mount, the container writes data into its own writable layer. That data is gone on docker rm. This is the most common source of “my database was wiped” surprises.


Running multiple containers together (API + database + cache) manually means re-typing long docker run commands, hard-coding IPs that change on every restart, and keeping mental state about which network and volume flags go where. There is no single source of truth to share with teammates or check into git.

Compose solves all three: one declarative file, one command, reproducible every time — and the service keys become DNS names containers can use to reach each other immediately.

Every key in compose.yaml maps to one concept you already know from plain Docker:

compose.yaml structure

Top-level sections map directly to objects the Engine already exposes.

docker compose up sequence

Compose is a thin client: it creates networks/volumes, pulls/builds, starts containers, then waits on health when you ask it to.

Steps 5–6 in the diagram correspond to depends_on with condition: service_healthy: Compose holds the api start until dockerd reports the db container’s healthcheck as passing. Without that gate the API process often races Postgres and crash-loops.

depends_on health gate

Health gate: api should not start until Postgres accepts connections.

The project structure assumes a counter-api/ directory with a Dockerfile and this compose.yaml at its root. Start from the project root.

Terminal window
$ docker compose up -d --build
[+] Building 12.3s (11/11) FINISHED
=> [api] load build definition from Dockerfile
=> [api] load .dockerignore
=> [api] load metadata for docker.io/library/node:20-alpine
=> [api internal] load build context
=> [api 1/4] FROM docker.io/library/node:20-alpine
=> [api 2/4] WORKDIR /app
=> [api 3/4] COPY package*.json ./
=> [api 4/4] RUN npm ci --omit=dev
=> [api] exporting to image
=> naming to counter-api:dev
[+] Running 4/4
Network counter-api_app Created 0.1s
Volume "counter-api_pgdata" Created 0.0s
Container counter-api-db-1 Started 0.5s
Container counter-api-api-1 Started 3.8s

What happened in that output:

  • Network + volume created first — Compose always creates infrastructure before containers.
  • db started at 0.5 s — the container is running, but Postgres is not yet ready.
  • api started at 3.8 s — Compose polled the db healthcheck (pg_isready) every 5 seconds; the API container was only created after the healthcheck reported healthy. The 3.3-second gap is that wait.
Terminal window
$ docker compose ps
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
counter-api-api-1 counter-api:dev "node src/index.js" api 3 minutes ago Up 3 minutes 0.0.0.0:3000->3000/tcp
counter-api-db-1 postgres:16-alpine "docker-entrypoint.s…" db 3 minutes ago Up 3 minutes (healthy) 5432/tcp

The (healthy) badge next to db confirms the healthcheck is passing. api has no healthcheck: block of its own, so it shows only Up — that is fine for development.

Terminal window
$ docker compose logs -f api
counter-api-api-1 | Server listening on :3000
counter-api-api-1 | Connected to Postgres at db:5432
counter-api-api-1 | GET /health 200 4ms

The second log line (Connected to Postgres) only appears because the healthcheck gate ensured Postgres was accepting connections before Node even started. Without condition: service_healthy, that line is often preceded by a series of ECONNREFUSED errors during the Postgres init phase.

Terminal window
$ curl -s localhost:3000/health
{"status":"ok","count":0}
$ docker compose exec db psql -U postgres -d counter -c '\dt'
List of relations
Schema | Name | Type | Owner
--------+----------+-------+----------
public | counters | table | postgres
(1 row)
Terminal window
$ docker compose down
[+] Running 3/3
Container counter-api-api-1 Removed 0.4s
Container counter-api-db-1 Removed 0.5s
Network counter-api_app Removed 0.1s

The named volume counter-api_pgdata is not removed — the database survives the next compose up.


services:
api:
build:
context: .
dockerfile: Dockerfile
target: prod
image: counter-api:dev
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://postgres:postgres@db:5432/counter
depends_on:
db:
condition: service_healthy
networks:
- app
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: counter
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d counter"]
interval: 5s
timeout: 3s
retries: 10
networks:
- app
volumes:
pgdata:
networks:
app:
CommandWhat it does
docker compose up -dStart all services detached (background)
docker compose up -d --buildRebuild images before starting
docker compose psList containers + status + health badge
docker compose logs -f apiStream logs for a single service
docker compose exec api shOpen a shell in the running api container
docker compose exec db psql -U postgres -d counter -c '\dt'Run a one-off psql command
docker compose pullRefresh pinned tags from the registry
docker compose buildBuild images without starting containers
docker compose downStop + remove containers + project network
docker compose down -vAs above and remove named volumes (DB wipe)

Compose is a thin client over the same Engine API you use with docker run. The mapping is exact:

Compose conceptRough docker equivalent
services.api (with build:)docker build -t counter-api:dev . then docker run --name api --network app -p 3000:3000 --env ...
services.db (with volumes:)docker run --name db --network app -v pgdata:/var/lib/postgresql/data --env ...
networks.appdocker network create app (user-defined bridge with embedded DNS)
volumes.pgdatadocker volume create counter-api_pgdata
depends_on: condition: service_healthyPoll docker inspect --format '{{.State.Health.Status}}' db until healthy, then start api

  • depends_on without condition is not enough. depends_on: db alone only waits for the db container to start — Postgres keeps initialising for another second or two after that. Always pair it with condition: service_healthy and a healthcheck: block, or your API will crash-loop on first boot.

  • Healthcheck retries exhaust silently. If pg_isready fails 10 times (50 s total with interval: 5s), the container transitions to unhealthy and Compose refuses to start dependent services. Check with docker compose ps — a (unhealthy) badge is the signal. Then run docker compose logs db to see why Postgres failed.

  • Port collisions on the host. If 0.0.0.0:3000 is already in use (another dev server, another Compose project), compose up fails with bind: address already in use. Change the left side of the port mapping ("3001:3000") or stop the conflicting process.

  • Stale images after code changes. docker compose up -d does not rebuild — it reuses the last built layer cache. Always pass --build after touching the Dockerfile or any files in the build context, or your running container will have old code.

  • Project name scoping. Compose prefixes every resource with the project name (default: directory name). Two Compose projects in different directories can define the same service names without colliding because the containers are named project1-api-1 and project2-api-1 respectively. If you rename the directory, compose down in the old location won’t find the old containers — use docker compose -p old-name down instead.


Terminal window
docker scout quickview counter-api:dev

If Docker Scout is enabled in your org, this gives a fast CVE summary before you tag 1.0.0 for production.


TermPlain meaning
OCIOpen Container Initiative — standard image formats and the contract runc uses to start a process.
ImageRead-only template (layers + config) used to create containers.
ContainerA created or running instance with its own writable thin layer on top of the image.
Docker daemon (dockerd)Background service that owns containers, images, networks, and volumes on this machine.
containerdRuntime Docker delegates to for container lifecycle and image content.
runcOCI low-level runtime: namespaces, cgroups, and exec of your PID 1.

Further reading: Docker Docs, containerd architecture, Open Containers Initiative.