Skip to main content
Back to Blog
Docker Mastery: The Definitive Guide to Containers in 2026
dockerdevopscontainerssecuritynetworkingproduction

Docker Mastery: The Definitive Guide to Containers in 2026

Everything I wish I knew about Docker before running it in production - architecture, images, layers, networking, storage, security, multi-stage builds, orchestration, and the patterns that actually matter.

Most Docker tutorials stop at "run this and it works." This one doesn't. This is the guide I wish existed when I moved from "Docker runs on my laptop" to "Docker runs my production infrastructure."

We're covering how Docker actually works under the hood, why images are built the way they are, how containers network, how storage really behaves, and, most importantly, how to run it safely in production. Every section has working examples.


Table of Contents

How Docker Actually Works

Everyone says "Docker is containers." That's like saying a car is wheels. Let's look at what's really running when you type docker run.

Docker is not one program. On a modern system, a container is a stack of components:

┌───────────────────────────────────────┐
│  docker CLI  (your terminal commands) │
└──────────────────┬────────────────────┘
                   │ REST API over unix socket
┌──────────────────▼────────────────────┐
│  dockerd  (Docker daemon)             │
└──────────────────┬────────────────────┘
                   │
┌──────────────────▼────────────────────┐
│  containerd  (container lifecycle)    │
└──────────────────┬────────────────────┘
                   │
┌──────────────────▼────────────────────┐
│  runc  (OCI runtime - run/stop)       │
└──────────────────┬────────────────────┘
                   │ creates
┌──────────────────▼────────────────────┐
│  Your container: namespaces + cgroups │
└───────────────────────────────────────┘
  • dockerd (the daemon): manages images, volumes, networks, and the lifecycle API. It's the brain.
  • containerd: the container runtime that actually creates, starts, and destroys containers. It also manages snapshots and image content.
  • runc: a tiny OCI-compliant runtime that talks directly to the Linux kernel to spawn processes inside namespaces.
  • The kernel itself: containers are just processes with namespaces, cgroups, and overlay filesystems applied.

A container is not a mini-VM. It's a process (or set of processes) that the kernel isolates:

# On the host, containers are just processes
ps aux | grep docker

# PID 1 inside the container is a real PID on the host
docker inspect <container> --format '{{.State.Pid}}'

The Three Kernel Pillars

Namespaces give a container its own view of the world:

NamespaceIsolates
PIDProcess IDs (PID 1 inside the container != host PID 1)
NETNetwork interfaces, routing, ports
MNTMount points - a container sees only its own mount table
UTSHostname and domain
IPCInter-process communication
USERUser and group IDs (root in container != root on host)

cgroups limit and meter resources. Total CPU, memory, disk I/O, and network use are all constrained by the kernel:

docker run --memory=512m --cpus=0.5 --pids-limit=100 nginx

That --pids-limit=100 matters more than people think. A fork bomb in your container crashes the container, not the host.

Union filesystems (overlay2 by default) give you layered images and copy-on-write. We'll dig into that next.

Senior takeaway: containers boot in milliseconds vs. a VM's seconds because they're just processes. There's no kernel to boot, no BIOS, no init system unless you put one in. The process already exists in the kernel's process table: the container just gives it a new reality through namespaces.

Try It Yourself

Enter a container and inspect its namespaces:

docker run -it --rm alpine sh

# Inside
ps aux                        # only processes in OUR namespace
cat /proc/self/ns/pid         # namespace IDs
ls /sys/fs/cgroup/            # our cgroup
mount                         # our mount table - completely different

Exit and check the same paths on your host. Compare. That's isolation.


Docker vs Virtual Machines

The classic confusion. Both give you isolation, but they live in different universes:

Virtual MachineContainer
KernelOwn full kernel (via hypervisor)Shares host kernel
Boot timeSeconds to minutesMilliseconds
SizeGBsMBs
IsolationHardware-level (hypervisor)Kernel-level (namespaces/cgroups)
Guest OSAny OSAny userspace that runs on host kernel
Density per host~5-50~100-1000s

The VM runs a full operating system on top of a hypervisor. The container runs one isolated process tree on top of your host kernel.

┌────────────────────────────────────────────┐
│              VM WITHOUT DOCKER             │
├──────┬──────┬──────┬──────┬────────────────┤
│  App │  App │  App │  App │                │
│ Bins │ Bins │ Bins │ Bins │                │
│ Libs │ Libs │ Libs │ Libs │                │
├──────┴──────┴──────┴──────┤                │
│      Guest OS / Kernel    │   Host Kernel  │
├───────────────────────────┼────────────────┤
│      Hypervisor           │                │
├───────────────────────────┴────────────────┤
│                  Bare Metal                │
└────────────────────────────────────────────┘

┌────────────────────────────────────────────┐
│              CONTAINERS (Docker)           │
├──────┬──────┬──────┬──────┬────────────────┤
│  App │  App │  App │  App │                │
│ Bins │ Bins │ Bins │ Bins │                │
│ Libs │ Libs │ Libs │ Libs │                │
├──────┴──────┴──────┴──────┤                │
│         Host OS Kernel    │                │
├───────────────────────────┴────────────────┤
│                  Bare Metal                │
└────────────────────────────────────────────┘

The trade-off: containers share one kernel, so a kernel vulnerability affects everything. VMs are more isolated but horrifically wasteful. The real answer in production isn't either/or: it's containers running inside VMs on Kubernetes. Each node is a VM (isolation + safety), and containers run inside it (efficiency + density).


Images and Layers

An image is a read-only template. A container is an image that's running, with a thin read-write layer on top.

Layers Are the Secret

Every instruction in a Dockerfile creates a layer. Layers are cached and reused by their hash. When you change one line, only that layer and everything after it are rebuilt: everything below comes from cache.

FROM ubuntu:24.04          # layer 1: base OS
RUN apt update ...          # layer 2: packages
COPY app.py .               # layer 3: our code
RUN npm install             # layer 4: dependencies
CMD ["python", "app.py"]    # layer 5: metadata (no size)

An image is like a stack of transparent sheets. The container adds one more sheet on top where your writes go (copy-on-write). Read a file you never modified? The kernel reads through the layers. Write a file? The kernel copies it up to your top sheet and writes there: the base layer stays untouched.

Image Inspection

docker history <image>              # every layer, command, and size
docker inspect <image>              # full metadata: entrypoint, labels, env
docker image save <image> -o img.tar  # export the image if you want to poke around

The #1 Performance Rule

Order your Dockerfile by volatility. The things that change most often go last; the things that change least go first. This is the single most impactful optimization in all of Docker:

# WRONG - code before deps means every code change rebuilds all deps
FROM node:22-alpine
COPY . .
RUN npm install
RUN npm run build

# RIGHT - deps first, code last
FROM node:22-alpine
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

Never copy the whole repo before installing dependencies. COPY . . before npm install invalidates the dependency cache on every single code change. npm ci on the lockfile alone means dependencies rebuild only when the lockfile changes.

Tag Images Meaningfully

myapp:latest                # NEVER in production. Never.
myapp:2026-09-09.1234       # good - date + build number
myapp:3.14.2                # good - semver tag
myapp:$(git rev-parse HEAD) # common - commit SHA

latest is fine for local dev. In CI/CD you need immutable tags so you can roll back to a specific exact build. Image IDs are hashes of the layers: two builds are never identical, so an immutable tag is your only way back.

Build What You Need

docker build --build-arg=... -t app:tag .
docker build --platform=linux/amd64 -t app:tag .   # cross-platform
docker build --no-cache -t app:tag .               # force full rebuild

BuildKit (default since Docker 23.0) also supports cache mounts, secrets, SSH, and parallel stages. We'll use those in the multi-stage section.

Dockerfiles, Done Right

A good Dockerfile is small, fast, and secure. Let me walk through one that's all three, instruction by instruction.

# Pin the digest. "python:3.13-slim" moves; this specific layer never will.
FROM python:3.13-slim AS builder

# Metadata matters - who owns this, what does it do
LABEL org.opencontainers.image.source="https://github.com/you/app"
LABEL org.opencontainers.image.authors="[email protected]"

WORKDIR /app

# Python conventions: no pyc/pycache, unbuffered output, no pip cache
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PIP_NO_CACHE_DIR=1 \
    PIP_DISABLE_PIP_VERSION_CHECK=1

# 1. Dependencies first - they change least
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# 2. Code second - it changes most
COPY src/ ./src/
COPY entrypoint.sh .
RUN chmod +x entrypoint.sh && chown -R nobody:nogroup /app

USER nobody
CMD ["python", "-m", "app"]

The Rules

1. Run as a non-root user. This is non-negotiable. Nearly every base image runs as root by default. Adding two lines fixes it:

FROM python:3.13-slim
RUN groupadd -r app && useradd -r -g app -m app
USER app

Distroless images do this for you and remove the shell while they're at it. If an attacker gets code execution as root, they own the container, and if your container is privileged or shares the host's PID namespace, they own the host.

2. Combine related RUN commands. Each RUN is a layer; each layer costs disk and network time. Chain related work:

RUN apt-get update \
    && apt-get install -y --no-install-recommends curl ca-certificates \
    && rm -rf /var/lib/apt/lists/*

rm -rf /var/lib/apt/lists/* deletes the apt index inside the same layer, so it never ships in the image.

3. Use COPY, not ADD. ADD does magic (URLs, auto-extraction) that surprises people. COPY is predictable. Keep ADD only when you genuinely need tarball extraction.

4. .dockerignore is not optional. Without it, COPY . . ships your .git/, node_modules/, and .env files into the build context (and into your image if you copy the whole dir). A .env leaked into an image that gets pushed to a registry is a breach.

node_modules
.git
.gitignore
.env*
*.md
*.log
.next
dist
coverage
Dockerfile
docker-compose*

5. Use exec form (["..."]) for CMD and ENTRYPOINT, not shell form ("... "). Shell form wraps your command in /bin/sh -c, which doesn't forward signals. Exec form makes the process PID 1 directly, so docker stop and SIGTERM actually reach it. Exit codes are also more honest.

6. ENTRYPOINT vs CMD. CMD provides defaults that can be overridden by passing args to docker run; ENTRYPOINT is the fixed command that always runs. The standard pattern:

ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["mysqld"]            # docker run img mysqld --verbose overrides CMD

7. Don't run builds as root when you can avoid it. Use a specific, tagged base image and rely on the maintainers' defaults, and pin the digest for supply-chain hygiene:

FROM node:22-alpine@sha256:1a2b3c...

Multi-Stage Builds

The single most valuable Dockerfile concept. One Dockerfile, multiple FROM lines: each is a stage. You build in one stage and copy only what you need into the final stage. Artifacts of earlier stages are discarded.

A real-world Node.js example (build tools are huge; the app doesn't need them):

# ---- Stage 1: deps ----
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

# ---- Stage 2: build ----
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

# ---- Stage 3: runtime ----
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production

RUN addgroup -S app && adduser -S app -G app

COPY --from=builder /app/dist ./dist
COPY --from=deps /app/node_modules ./node_modules

USER app
EXPOSE 3000
CMD ["node", "dist/server.js"]

Wait: why does the runtime stage re-copy node_modules from the deps stage instead of COPY . .? Because deps pruned dev dependencies inside npm ci (with NODE_ENV=production or npm ci --omit=dev), and the builder's node_modules are tainted by build tooling anyway. We take the clean dependencies.

Why This Matters

The builder stage has TypeScript, webpack, and dev tooling: hundreds of MB. The runtime stage gets a handful of compiled files. Result: a 1.1GB image becomes ~120MB, and the attack surface drops to almost nothing.

Go: the textbook example

Go compiles to a static binary, so you can skip even Alpine:

FROM golang:1.24 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/bin/app .

# scratch = NOTHING. No shell, no libs. Just the binary.
FROM scratch
COPY --from=builder /app/bin/app /app
USER 65532:65532
EXPOSE 8080
ENTRYPOINT ["/app"]

FROM scratch is empty. The container contains exactly one binary plus whatever you copy in. A Go "hello world" HTTP server lands around 7-10MB.

BuildKit Superpowers

BuildKit unlocks caches that persist between builds:

FROM node:22-alpine AS deps
RUN --mount=type=cache,target=/root/.npm npm ci
  • --mount=type=cache keeps /root/.npm warm between builds: package downloads don't re-run.
  • --mount=type=bind mounts a file into a layer without including its content in the layer.
  • --mount=type=secret injects secrets during build without baking them into a layer:
RUN --mount=type=secret,id=npm_token \
    NPM_TOKEN=$(cat /run/secrets/npm_token) npm ci
docker build --secret id=npm_token,src=./token.txt .

Secrets mounted this way never touch Dockerfile, layer, or image. You can even use --mount=type=ssh to git clone private repos over SSH.


Containers: The Runtime

Now we run things. The fundamentals first.

The Lifecycle

docker create --name app myimage     # image -> writable layer, stopped
docker start app                     # run the process
docker stop app                      # SIGTERM, wait, then SIGKILL
docker start app                     # reuse the container (same filesystem state)
docker restart app                   # stop + start
docker pause app                     # freeze all processes (SIGSTOP)
docker rm -f app                     # destroy
docker run = create + start + attach

Key mental model: docker stop sends SIGTERM, waits 10 seconds (default), then SIGKILLs. If your app can't shut down gracefully in 10 seconds (in-flight requests, open DB connections, a queue you're mid-message on), you get a dirty kill.

Make graceful shutdown actually work. PID 1 gets the SIGTERM, so PID 1 must be your app (exec form) and your app must handle it:

// Node - single instance, in-flight requests drain before exit
const server = app.listen(3000);

process.on('SIGTERM', () => {
  console.log('SIGTERM received, draining connections...');
  server.close(() => process.exit(0));
  // hard exit if things hang
  setTimeout(() => process.exit(1), 10000).unref();
});

Docker also respects a stop grace period: give heavy apps more than 10 seconds where it matters:

docker run --stop-timeout=30 myapp

PID 1 and Zombies

In a container, PID 1 is special: it must reap zombie processes. If your app isn't written to reap children (a Node process, a python process), zombies accumulate when you spawn subprocesses.

Two fixes:

  1. Run a tiny init like tini as PID 1: RUN apk add tini && ENTRYPOINT ["/sbin/tini", "--", "your-app"]
  2. Use an image that already does this (the node official image bundles one for you).

Resource Limits Are Not Optional

A container without limits can starve the host. docker run them, or put them in Compose:

deploy:
  resources:
    limits:
      memory: 512M
      cpus: '0.5'
      pids: 100
    reservations:
      memory: 128M

In plain docker run:

docker run --memory=512m --memory-swap=512m --cpus=0.5 --pids-limit=100 nginx

--memory-swap=512m (equal to memory) disables swap, so memory pressure manifests as OOM-kills inside the container rather than silent swap thrash.

One trap: don't set a memory limit below what the JVM / Node / interpreters pre-reserve at startup, or you'll OOM immediately. Check the app's real baseline with docker stats before setting limits.


Networking

┌─────────────────────────────────────────────────────┐
│ Bridge (default)  172.17.0.0/16                    │
│  ┌─────────┐ ┌─────────┐                           │
│  │ app:3000│ │ db:5432 │   app→db works by name,   │
│  └────┬────┘ └────┬────┘   NOT by IP               │
│       └─────┬─────┘                                │
│  docker0 bridge (host-internal NAT)                │
└─────────────┼───────────────────────────────────────┘
         -p 3000:3000  (port publish)
              │
           Internet

When you create a network (docker network create mynet), Docker gives it a bridge + DNS. Containers on the same network resolve each other by container name. This is how we don't hardcode IPs.

The Four Driver Types

1. bridge (default): private subnet per network, NAT'd to the host. Use for a group of containers that talk to each other on one host.

2. host: no network namespace. The container shares the host's network stack directly. -p is ignored. Fast (no NAT bounce), but port collisions are real. Use for UDP-heavy or latency-sensitive things like DNS servers, or when performance beats isolation.

docker run --network host myapp

3. none: loopback only. No external connectivity. Use for security-sensitive jobs that must not touch the network (build jobs, offline services).

4. overlay: used by Swarm/Kubernetes to connect containers across hosts. It's the driver that makes distributed systems work; you typically consume it through an orchestrator rather than docker network create directly.

Bonus: macvlan gives a container its own MAC address and puts it directly on your physical LAN. Useful for legacy apps that need to be discoverable on the network, but it doesn't do port mapping and can burn IPs fast.

The DNS Trick That Fixes Most Confusion

docker network create app-net
docker run -d --name api --network app-net myapi:1.0
docker run -d --name db  --network app-net postgres:17

Now the API connects to db:5432, not 172.18.0.4. If the DB container restarts and gets a new IP, nothing breaks. Inside the containers, Docker's embedded DNS resolver handles name resolution for you automatically.

Publishing Ports

-p 3000:3000 maps host 3000 → container 3000. The container IP is irrelevant unless you're on the same bridge network. Traffic destined for host:3000 hits the host, the daemon forwards it to the container's IP inside the bridge.

Prefer the bind syntax over the legacy -p shorthand for clarity, and always pin the host port:

docker run -p 127.0.0.1:3000:3000 myapp   # listen ONLY on localhost
docker run -p 0.0.0.0:8080:80 nginx        # every interface (dangerous on prod)

Exposing a port ≠ publishing it. EXPOSE 3000 in the Dockerfile is documentation only: no port is reachable from outside until you -p or --network host it.

Debugging Networking

docker network ls
docker network inspect app-net          # see the subnet, gateway, endpoints
docker exec api ip a                    # the container's interfaces (if iproute2 exists)
docker exec api getent hosts db         # resolve a peer by name
nsenter -t $(docker inspect api --format '{{.State.Pid}}') -n ip a   # host-side view

Storage and Volumes

Containers are ephemeral by design: the writable layer dies with the container. But your database, your uploads, your logs need to survive restarts. This is where volumes come in.

The Three Storage Types

1. Volumes: managed by Docker, stored in /var/lib/docker/volumes/. Backed up, shared between containers easily. The default choice for persistent data.

docker volume create pgdata
docker run -d --name db -v pgdata:/var/lib/postgresql/data postgres:17

Anonymous volumes (-v /var/lib/postgresql/data) are fine for -v short-term work but they orphan data when the container is removed. Name everything you care about.

2. Bind mounts: a direct path on the host filesystem mounted into the container. Perfect for dev (live reload) and for config files. But the container can modify host files, so bind-mounts should never be writable from untrusted containers unless you control the paths carefully.

docker run -d -v /home/me/app:/app -p 3000:3000 node-dev  # dev: live code
docker run -d -v /etc/nginx/nginx.conf:/etc/nginx/nginx.conf:ro nginx  # config, read-only

3. tmpfs: RAM-backed storage. Fast, volatile, gone on stop. Use for secrets that shouldn't hit disk, or scratch space:

docker run --tmpfs /tmp:size=64m,mode=1777 myapp

Anatomy of a Volume mount

The -v/--mount flag has a precise grammar. --mount is clearer and more explicit:

# -v (concise)
-v pgdata:/var/lib/postgresql/data:ro

# --mount (verbose, explicit)
--mount type=volume,source=pgdata,target=/var/lib/postgresql/data,readonly

Sharing Between Containers

docker run -d --name shared-web --volume-from nginx80 --network nginx-net myapp

Wait: that requires running a named container to attach to. More commonly you share via the same named volume in Compose, or, the senior pattern, don't share files across containers at all unless you have to. Shared volumes across containers are a source of corruption and races. Prefer a bus (Redis, a socket, object storage) instead.

Backup and Restore

Because volumes are just directories, backup is tar over a helper container:

docker run --rm -v pgdata:/volume -v $(pwd):/backup alpine \
  tar czf /backup/pgdata-$(date +%Y%m%d).tar.gz -C /volume .

docker run --rm -v pgdata:/volume -v $(pwd):/backup alpine \
  tar xzf /backup/pgdata.tar.gz -C /volume

Never dump to the container's writable layer. pg_dump > /var/lib/postgresql/data/backup.sql writes into the container, which disappears when the container is recreated.

The Golden Rule

The writable container layer is garbage. State must live in volumes. If you can recreate a container from an image plus volumes without losing anything, you've done it right.


Docker Compose: Dev to Prod

Compose is docker run with a YAML file: a declarative definition of your whole stack. This is where Docker becomes actually usable for real applications.

A Development Compose File

name: myapp

services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
      target: runner        # use the multi-stage runtime stage
    ports:
      - '3000:3000'
    environment:
      NODE_ENV: development
      DATABASE_URL: postgres://app:secret@db:5432/app
    volumes:
      - .:/app              # live-reload code
      - /app/node_modules   # keep container's node_modules (anonymous volume)
    depends_on:
      db:
        condition: service_healthy
    develop:
      watch:
        - path: ./src
          action: sync
          target: /app/src

  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:

Save, then:

docker compose up -d            # build + start detached
docker compose up --build       # force rebuild
docker compose ps
docker compose logs -f api      # tail api logs
docker compose exec api sh      # shell into a running service
docker compose down             # stop and remove
docker compose down -v          # ...and destroy named volumes (data loss!)
docker compose restart api

The develop.watch block enables docker compose watch: file changes in src sync into the container and restart the app automatically. It's the smooth dev loop:

docker compose watch

Environment and Secrets

Never hardcode secrets in compose files. Use env_file and ${VAR} interpolation:

services:
  api:
    env_file:
      - .env.production
    environment:
      DATABASE_URL: ${DATABASE_URL}

And keep .env* out of git (they already should be in .dockerignore).

Production Overlays

Compose supports your overlay file strategy for production differences:

# docker-compose.override.yml (dev)   vs   docker-compose.prod.yml (prod)
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

A typical prod overlay removes the bind mount, bumps replicas (for services without persistent identity), and drops debug settings.

Compose is for single-host stacks. The moment you need multi-host networking, rolling updates, or node failure handling, you've graduated to an orchestrator (Kubernetes, Swarm, or Nomad). Compose's deploy: block hints at those concepts but only works fully with docker stack deploy.


Security: Production Hardening

This section is the reason to read this post. "It runs" is not enough: an insecure container is a liability. Here's the hardening checklist that matters, in order of impact.

1. Never Run as Root

If your app runs as root and gets exploited, the attacker has UID 0. On a misconfigured host (privileged flag, /var/run mount, user-namespace disabled), UID 0 in the container maps to root on the host.

FROM node:22-alpine
RUN addgroup -S app && adduser -S app -G app
USER app

Use scratch, distroless, or alpine for the final stage to minimize what an attacker can even touch.

2. Drop All Capabilities

Linux capabilities are granular root powers. Containers don't need most of them.

# docker-compose.yml or docker run --cap-drop=ALL --cap-add=
services:
  api:
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE   # only if binding ports <1024
    security_opt:
      - no-new-privileges:true

cap_drop: [ALL] removes everything; add back only what the app genuinely needs. Combine with no-new-privileges so a compromised process can't escalate.

3. Read-Only Root Filesystem

A read-only root makes most post-exploitation payloads (writing binaries, phishing for config changes) fail instantly. The process needs a /tmp, so give it a tmpfs:

services:
  api:
    read_only: true
    tmpfs:
      - /tmp:size=64m,mode=1777
    volumes:
      - pdata:/app/data   # explicit writable paths for real state

Now the image is the only thing in the container's root FS, and it's immutable.

4. Signatures and Scanning

  • Scan every image in CI with trivy: it catches known CVEs per layer.
  • Cosign sign your images and verify before deploy. Supply-chain attacks are the #1 way bad code gets in.
  • Keep base images tiny and pinned to a digest so "alpine" can't silently become "alpine-with-vulns."

5. Secrets Management

Never bake secrets into image layers: they survive in the image history forever. Options:

# Compose - mount as a file, never as a cloneable env var
secrets:
  db_password:
    file: ./secrets/db_password.txt

services:
  api:
    secrets:
      - db_password

Prefer Docker secrets / a proper secret store (Vault, the cloud provider's secret manager) over environment variables. Env vars leak into docker inspect, logs, and crash dumps. Files at least gate exposure behind the container's filesystem permissions.

6. Garbage In the Layer Cache Is Exposure

# Layer: has your private key
RUN COPY keys .
# Next layer: deletes it, but the LOWER LAYER still contains it!

No matter what the final layer looks like. A layer is immutable: with the right tooling you can diff the image and read the deleted file. Never COPY secrets. Use --mount=type=secret during build.

7. Limit What You Publish

  • Bind to 127.0.0.1 for anything admin.
  • Don't publish 2375/2376 (the Docker API) to the internet: that's a root shell by default.
  • Put containers behind a reverse proxy (Caddy, Traefik, Nginx) and terminate TLS there. Don't expose 20 ports per service.

8. User Namespaces

# /etc/docker/daemon.json
{
  "userns-remote": true,
  "userns": "host"
}

With user namespaces on, root inside the container is a high-numbered UID on the host: it cannot leverage kernel exploits to become real root. It costs some convenience (bind mounts get tricky) but it's the deepest protection you get at the daemon level.

If you run Docker in production, use the orchestrator's namespace controls (Kubernetes: runAsNonRoot: true is table stakes).


Observability: Logs, Health, Metrics

Production containers are flying blind without these. Three pillars, none optional in 2026.

Healthchecks

A healthcheck tells the orchestrator "is this container actually useful?", not "is the process alive." A container can be running with a dead worker pool behind it.

services:
  api:
    healthcheck:
      test: ['CMD', 'node', 'healthcheck.mjs']   # or CMD-SHELL for curl
      interval: 10s
      timeout: 3s
      retries: 3
      start_period: 30s     # grace for boot before counting failures

Plain docker run:

docker run --health-cmd="curl -f http://localhost/healthz || exit 1" \
           --health-interval=10s \
           --health-start-period=30s nginx

Then inspect status with docker inspect --format '{{json .State.Health}}' api.

Critical details:

  • Healthcheck runs inside the container's network namespace: hit localhost, not your published host port.
  • start_period is the moment failed checks don't count as unhealthy. Missing it causes sporadic restarts on slow boots.
  • Only CMD, CMD-SHELL, or NONE are valid exec forms.

Logging

Logs go to stdout/stderr and Docker collects them:

docker logs -f api                 # tail
docker logs --tail 100 api         # last 100 lines
docker logs --since 5m api         # last 5 minutes

Never write to files in production. If your app logs to a file, its death takes the logs with it. Configure JSON driver so the orchestrator can ingest structured logs:

# /etc/docker/daemon.json
{
  "log-driver": "json-file",
  "log-opts": { "max-size": "10m", "max-file": "3" }
}

max-size + max-file bounds disk usage: unbounded logs will fill your disk. In Kubernetes-style setups, normalize to structured JSON and ship with a sidecar/agent (Fluent Bit, Vector, Loki).

Metrics

Export Prometheus-format metrics from your app, scrape them, and alert on them:

const client = require('prom-client')
const httpHistogram = new client.Histogram({
  name: 'http_request_duration_seconds',
  help: 'HTTP request latency',
  labelNames: ['method', 'route', 'status'],
  buckets: [0.005, 0.01, 0.05, 0.1, 0.3, 0.5, 1, 3, 5, 10]
})

Scrape with Prometheus (or Grafana Alloy) and alert on: request error rate, p99 latency, healthcheck failures, restarts, and OOM kills. These four catch most production incidents.

The graceful shutdown triangle

Healthcheck (when to send traffic), readiness + SIGTERM handling (when to drain) and a bounded stop_timeout combine into zero-downtime deploys. Each side needs the other two or you still get dropped requests.


CI/CD and Registries

Docker in CI is where it either sings or makes you cry. The patterns:

1. Build Once, Ship the Same Artifact

Never build in prod. CI builds the image, tags it, pushes it to a registry, and environments pull that exact image:

# .github/workflows/deploy.yml (GitHub Actions)
- name: Build and push
  uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: |
      ghcr.io/you/api:${GITHUB_SHA::7}
      ghcr.io/you/api:latest
    cache-from: type=gha
    cache-to: type=gha,mode=max

The type=gha cache is GitHub Actions' equivalent of BuildKit's local cache: it makes rebuilds fast without managing an external cache backend. mode=max caches all layers including intermediate ones.

2. Use a Cache Backend, Not Your Nostalgia

Without a cache, every CI build is a cold build. Beyond type=gha, use a real registry cache (type=registry) for self-hosted GitLab/Bitbucket or a MinIO-backed BuildKit cache:

docker buildx build --cache-to type=registry,ref=registry.local/cache:api,mode=max .

3. Multi-Arch Builds

Ship linux/amd64 and linux/arm64 from one CI run so you don't discover you only built for x86 on a Friday:

docker buildx create --use
docker buildx build --platform linux/amd64,linux/arm64 \
  --tag ghcr.io/you/api:1.0.0 --push .

This produces a manifest list: the registry serves the right platform, transparently.

4. Registries

  • Docker Hub: fine for public images, rate-limited aggressively in CI. Cache your pulls.
  • GHCR: free, auth'd via GitHub token, tight registry-to-repo permissions. The default for GitHub shops.
  • Self-hosted (Harbor, Quay, plain Registry): air-gapped/private infra.

Enable immutable tags on the registry, enable scanning (Trivy is free), and prune untagged images or your registry becomes a tar pit.

5. Tag Hygiene

ghcr.io/you/api:<sha>     # immutable, deployable
ghcr.io/you/api:<tag>     # mutable moving target; rollback = impossible

Deploy by immutable SHA. Keep latest pointers purely for convenience, never for deploys.


Troubleshooting Like a Senior

Stuck container? Slow builds? Weird network? Here's how to diagnose instead of guessing.

Container won't start

docker logs <container>                 # 1. what did it say?
docker inspect <container> --format '{{json .State}}'   # 2. exit code, OOM status
docker inspect <container> --format '{{json .Config.Env}}'  # 3. did env get set right?

OOM kills show up as .State.OOMKilled: true with exit code 137. If you set --memory, check baseline with docker stats.

Container starts then dies immediately

docker run --rm -it myimage sh          # 1. does the ENTRYPOINT survive without args?
docker run --rm --entrypoint sh myimage -lc 'ls /app'   # 2. is the artifact actually in the image?
docker history myimage                  # 3. what is the real CMD?

90% of "dies immediately" is a missing artifact, a wrong workdir, or an entrypoint expecting interactive input.

Networking mysteries

docker exec api ping db                 # 1. is the target resolvable?
docker exec api nc -zv db 5432          # 2. is the port open?
docker network inspect app-net          # 3. are both containers on the same network?
docker port api                         # 4. is the port actually published?

Also: the container can reach out but inbound fails = published-port issue, not routing. Two images that can't talk = they're on different bridge networks.

Slow rebuilds

docker build --progress=plain .        # see every step, cached/missing markers
docker build --no-cache .              # isolate cache suspicion
docker system df                       # how much reclaimed/abandoned is eating disk

Disk fill-ups

docker system df                       # space by category
docker system prune                    # dangling images, dead containers, unused cache
docker system prune -a --volumes       # nuclear: removes ALL unused + volumes (backup first!)
docker system df -v                    # per-image/container detail

Schedule docker system prune -af --filter 'until=720h' (runs older than 30 days) so "docker system df" never becomes an incident.

The debug kitchen sink

docker events --filter container=api    # subscribe to lifecycle events in real time
docker top api                          # processes inside the running container
docker stats --no-stream api            # live resource snapshot
docker exec -it api strace -p 1         # if strace is in the image; often it isn't: rebuild for debug

The Production Checklist

The difference between "I can run Docker" and "I ship software on Docker" lives in this list. Run through it before anything touches a production node: with actual examples:

# docker-compose.prod.yml
name: myapp-prod

services:
  api:
    image: ghcr.io/you/api:${IMAGE_TAG}   # immutable tag, injected by CI
    restart: unless-stopped                # survive node reboots
    read_only: true
    tmpfs:
      - /tmp:size=64m,mode=1777
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    healthcheck:
      test: ['CMD', 'node', '/app/health.mjs']
      interval: 10s
      timeout: 3s
      retries: 3
      start_period: 30s
    deploy:
      resources:
        limits:
          memory: 512M
          cpus: '0.5'
          pids: 100
        reservations:
          memory: 128M
    secrets:
      - db_password
    logging:
      driver: json-file
      options:
        max-size: 10m
        max-file: '3'
    networks:
      - front
      - back

secrets:
  db_password:
    file: ./secrets/db_password.txt

networks:
  front:
  back:

volumes:
  pgdata:

The checklist that goes with it:

  • Immutable image tags, never latest, deployed by the same artifact CI tested.
  • Non-root user + cap_drop: ALL + read-only rootfs or you haven't hardened anything.
  • Healthchecks with a start_period on every service that matters.
  • Resource limits (memory, cpus, pids) on everything: no exceptions.
  • Secrets as files, not env vars; never in image layers.
  • Logs bounded (max-size/max-file), on stdout, JSON when possible.
  • Backups of named volumes verified by actual restores, not by the existence of a script.
  • Scans enabled (image scanning + secret scanning) and failures block the build.
  • restart: unless-stopped on singletons; an orchestrator for anything multi-node.
  • A kill switch: know your rollback path to the previous immutable tag before you need it.

Docker doesn't make deploys easy because it's a container tool. It makes them easy because it forces you to think about artifacts, state, and failure modes: the same things that bite every production app. Master the layers, harden the runtime, and treat state as sacred. Everything else is syntax.

The best time to apply this checklist was the first deploy. The second-best time is tonight. Happy containerizing.


This guide pairs well with Docker Build Optimization and PageSpeed-optimized Next.js builds.

Related Posts

From 4 Minutes to 2 Seconds: Docker Build Optimization

How we achieved 100x faster Docker rebuilds and 42% smaller images while hardening containers for production

dockerdevopsperformance+2 more
Read More

Two Pools Are Better Than One: Splitting Our PostgreSQL Connections

How we fixed connection pool exhaustion by separating API and background workers into dedicated pools — and kept both FastAPI and Celery happy.

postgresqldevopsperformance+2 more
Read More