Back to handbooks index
Production Container Operations

Docker & Containerization Enterprise Handbook

A practical handbook for engineers who build, ship, debug, and govern containers in production. The focus is operational reality: immutable images, stateless services, lean runtime layers, least privilege, and the commands teams actually use under pressure.

Docker CLI Multi-Stage Builds Security & Least Privilege March 2026
i
Production culture: containerization is not just packaging. Mature teams treat images as immutable release artifacts, keep containers stateless, externalize configuration, and assume every container may be rescheduled onto another host at any time.

Operating Model

Containers work best when the engineering model is disciplined. Build once, promote the exact same image through environments, inject configuration at runtime, and write logs to stdout/stderr so the platform can aggregate them. If a container needs hand-edited state on disk, interactive SSH fixes, or ad hoc package installation after deployment, the system is already drifting away from container-native operations.

Immutability
Rebuild the image for every change. Do not patch running containers. That is how teams preserve traceability and rollback safety.
Statelessness
Assume the container filesystem is disposable. Put durable data in managed databases, object stores, or explicitly mounted volumes.
Least Privilege
Run as a non-root user, minimize capabilities, and ship the smallest runtime image that can do the job.

Table of Contents

Module 1: Core Commands
Lifecycle management, safe cleanup, deep inspection, and live CPU or memory triage with the Docker CLI.
Module 2: Industry-Standard Dockerfiles
A production-ready multi-stage FastAPI image, layer-cache strategy, and explicit non-root runtime execution.
Module 3: Common Mistakes & Anti-Patterns
Why root containers, bloated images, and bad process management keep causing avoidable incidents.
Module 4: Debugging in Docker
Interactive shells, entrypoint overrides, and log workflows for services that fail during startup.
Module 5: Docker vs Podman
Daemon-backed versus daemonless operation, rootless defaults, and practical migration notes.
Module 6: Orchestration
Why standalone containers are insufficient in HA production and why Kubernetes became the standard.

Module 1: Core Commands (The Useful CLI Guide)

Most engineers do not struggle with docker run; they struggle with fleets of stale images, crash-looping services, and figuring out what a container is actually doing on a busy host. This module focuses on the commands used in daily delivery and incident response.

Lifecycle: Start, Stop, Remove, and Reset

Container lifecycle operations are usually part of local development cleanup, CI runners, or emergency host maintenance. Be deliberate: broad deletion commands are useful, but they are destructive, so they belong in controlled dev or ephemeral CI environments, not shared production hosts.

!
Real-world scenario: a shared self-hosted CI runner accumulates hundreds of stopped containers after interrupted jobs. Disk pressure builds until new pipelines fail. A scheduled cleanup job using targeted lifecycle commands restores the runner without rebooting the machine.
# Start a stopped container again when you want to preserve its writable layer for investigation.
docker start api-dev

# Stop a running container gracefully and give the process time to handle SIGTERM.
docker stop --time 20 api-dev

# Restart a service after changing an injected env var or bind-mounted config file.
docker restart api-dev

# Remove a container that is already stopped so its name can be reused cleanly.
docker rm api-dev

# Force stop and remove a stuck container in one command during local cleanup or CI teardown.
docker rm -f api-dev

# Remove every container on an ephemeral development machine; use only when you intend a full reset.
docker rm -f $(docker ps -aq)
CommandWhen to Use ItOperational Note
docker startResume a stopped container for debuggingUseful when you want the original writable layer intact
docker stopNormal shutdownSends SIGTERM, then SIGKILL after the timeout
docker rmDelete stopped containersSafe default when cleanup is intentional and scoped
docker rm -fKill and remove in one shotCommon in CI teardown and local recovery workflows

Housekeeping: Reclaim Image, Build Cache, and Volume Space

Docker hosts quietly collect abandoned layers, stopped containers, unused networks, and orphaned volumes. The right cleanup command can recover tens of gigabytes in minutes, but volume pruning requires care because persistent application state may live there.

$
Real-world scenario: an EC2-based build server runs out of disk every Friday because each branch build leaves behind intermediate layers. A nightly prune job cuts build failures and avoids emergency EBS expansion.
# See total Docker disk usage before deleting anything so you know where space is going.
docker system df

# Remove stopped containers, dangling images, unused networks, and build cache.
docker system prune

# Remove unused images as well, not just dangling ones; good for disposable dev hosts.
docker system prune -a

# Include anonymous volumes when you know the host should be fully reset.
docker system prune -a --volumes

# Remove only unused volumes after verifying no stateful local databases depend on them.
docker volume prune

# List volumes first so you can spot anything that looks like live PostgreSQL or Redis data.
docker volume ls

On production platforms, pruning is usually delegated to host maintenance automation or avoided entirely because production containers are orchestrated and short-lived. The more mature pattern is to keep hosts immutable too, replacing them rather than hand-cleaning them.

Inspection: Find IP Addresses, Mounts, and Environment Values

docker inspect exposes the raw runtime metadata that the CLI summary commands hide. This matters when a service is unreachable, a secret was not injected, or you need to confirm which image digest actually launched.

i
Real-world scenario: a sidecar integration fails only in one environment. Inspection shows the target container started without the expected API_BASE_URL variable because the wrong Compose override file was used in deployment.
# Dump full container metadata as JSON when you need the authoritative runtime view.
docker inspect api-dev

# Extract the bridge-network IP address with Go templating for fast host-level debugging.
docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' api-dev

# Use jq to pull environment variables and scan them cleanly in incident response.
docker inspect api-dev | jq -r '.[0].Config.Env[]'

# Filter for a specific environment variable when you suspect a bad secret or endpoint value.
docker inspect api-dev | jq -r '.[0].Config.Env[]' | grep '^API_BASE_URL='

# Confirm which image digest actually started, not just the mutable image tag.
docker inspect api-dev | jq -r '.[0].Image'

# Inspect mounts to verify whether the service is reading from a named volume or bind mount.
docker inspect api-dev | jq -r '.[0].Mounts[] | [.Type, .Source, .Destination] | @tsv'
+
Operational habit: when a deployment behaves differently than expected, inspect the live container before changing code. Many outages are configuration mismatches, not application regressions.

Monitoring: Real-Time CPU and Memory Triage

docker stats is the fastest way to answer, "is this container starving the host or being OOM-killed?" It is not a replacement for Prometheus, Datadog, or Kubernetes metrics, but it is invaluable during a live shell session on a node.

# Watch all running containers and spot CPU spikes, memory leaks, or network saturation in real time.
docker stats

# Focus on one suspect service to reduce noise during an incident.
docker stats api-dev

# Capture a one-time snapshot for automation or an incident timeline.
docker stats --no-stream --format 'table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.NetIO}}'

If memory usage is pinned and restart counts keep climbing, the next questions are usually whether the process has a leak, whether heap limits are misconfigured, or whether the workload exceeds the container limit that was set by Compose or the orchestrator.

Module 2: Industry-Standard Dockerfiles

A production Dockerfile is not just a working Dockerfile. It should build reproducibly, leverage cache correctly, keep the runtime image small, avoid privilege escalation, and start the application with a signal-safe process model. The example below uses FastAPI because it is common in internal platforms and straightforward to read.

+
Real-world scenario: a platform team standardizes one approved base pattern across dozens of Python services. Build times drop because cache behavior becomes predictable, and security review becomes easier because every service follows the same hardening model.
# Dockerfile - production-ready FastAPI image with multi-stage build, non-root runtime, and signal-safe startup.
FROM python:3.12-slim AS builder

# Keep Python deterministic and quiet in containers.
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PIP_NO_CACHE_DIR=1

# Builder stage includes compilers only because some wheels may need native build support.
RUN apt-get update && apt-get install -y --no-install-recommends \
    build-essential \
    gcc \
    dumb-init \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /build

# Copy dependency manifests first so Docker can reuse this layer when app code changes but deps do not.
COPY requirements.txt ./

# Install dependencies into a dedicated prefix that can be copied into the runtime stage.
RUN pip install --prefix=/install -r requirements.txt

# Copy the application after dependencies so source-only changes do not invalidate dependency caching.
COPY app ./app


FROM python:3.12-slim AS runtime

# Keep Python runtime behavior container-friendly and declare the application home.
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    APP_HOME=/app

# Install only the tiny runtime package we still need; no compiler toolchain should survive into production.
RUN apt-get update && apt-get install -y --no-install-recommends \
    dumb-init \
    && rm -rf /var/lib/apt/lists/* \
    && groupadd --system appuser \
    && useradd --system --gid appuser --create-home --home-dir /home/appuser appuser

WORKDIR ${APP_HOME}

# Bring in the already-built Python dependencies from the builder stage.
COPY --from=builder /install /usr/local

# Copy only the application code needed at runtime.
COPY --chown=appuser:appuser app ./app

# Drop privileges before the service starts so an RCE does not immediately become root on the container.
USER appuser

# Document the application port for humans and tooling.
EXPOSE 8000

# dumb-init becomes PID 1 so signals are forwarded and zombie processes are reaped correctly.
ENTRYPOINT ["/usr/bin/dumb-init", "--"]

# Exec-form CMD avoids an extra shell process and lets the app receive SIGTERM directly.
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
# requirements.txt - pin versions for reproducibility across CI, staging, and production.
fastapi==0.116.1
uvicorn[standard]==0.35.0

Layer Caching: Why Copying Dependencies First Matters

Docker caches each build layer based on the instruction and the files used by that instruction. If you copy the entire repository before installing dependencies, then any code change invalidates the dependency-install layer, forcing a full rebuild. That is why mature Dockerfiles copy package.json, package-lock.json, poetry.lock, or requirements.txt before the rest of the application.

i
Real-world scenario: a monorepo API pipeline takes 11 minutes per build because the Dockerfile copies the entire repository before dependency installation. Reordering three lines drops repeat build times to under 2 minutes for source-only changes.
COPY requirements.txt -> RUN pip install -> COPY app source -> Fast rebuilds for code-only changes
# Bad pattern - every source code change forces dependencies to reinstall, slowing every CI build.
COPY . .
RUN pip install -r requirements.txt

# Good pattern - dependency layers stay cached until the manifest changes.
COPY requirements.txt ./
RUN pip install -r requirements.txt
COPY app ./app

In a monorepo, the impact is even larger. One changed README should not trigger a 10-minute dependency rebuild for every service image in the pipeline.

Security & Least Privilege: Non-Root by Default

The default user in many images is root. If an attacker gets code execution inside that container, root-level filesystem access and broader kernel interaction become immediately available. Running as a dedicated unprivileged user limits the blast radius and aligns with enterprise policy checks.

# Create a system group and user with no login shell because the app does not need interactive privileges.
RUN groupadd --system appuser \
    && useradd --system --gid appuser --create-home --home-dir /home/appuser appuser

# Ensure the application files are owned by the non-root account before dropping privileges.
COPY --chown=appuser:appuser app ./app

# From this line onward, the container runtime executes as the unprivileged account.
USER appuser
x
Enterprise reality: many admission controllers and image scanning policies now flag or reject images that run as root. Least-privilege is no longer an optional hardening step; it is often a deployment prerequisite.

Module 3: Common Mistakes & Anti-Patterns

Junior engineers often get a container to run and assume the job is done. In practice, the first version of a Dockerfile often bakes in security risk, wasted storage, or brittle runtime behavior. These issues are predictable and worth eliminating early.

1. Running as Root

Leaving the image at its default root user is the most common container hardening failure. The container boundary is not a security boundary in the same sense as a VM boundary. Kernel escapes are rare but serious, and root inside the container is still more powerful than it needs to be.

Why it breaks enterprise policy

Security teams care because root containers can write to more paths, manipulate file ownership, and sometimes exploit overly permissive host mounts. In regulated environments, root containers often fail compliance review before the service even reaches staging.

# Risky - the process still runs as root because no USER instruction was ever declared.
FROM python:3.12-slim
WORKDIR /app
COPY . .
CMD ["python", "app.py"]

# Better - create a dedicated runtime user and run the app without root privileges.
RUN groupadd --system appuser && useradd --system --gid appuser appuser
USER appuser

2. Fat Images

Another frequent mistake is installing build tools like gcc, curl, package managers, and debugging utilities in the final image. That makes the image larger, slower to distribute, broader in attack surface, and noisier to scan. If the service only needs the built artifacts at runtime, those tools should stay in a builder stage and never ship to production.

!
Real-world scenario: an API image grows past 1.5 GB because the final stage still includes compilers, package indexes, test fixtures, and temporary archives. Deployments slow down across every region because each node has to pull the bloated layer set.
# Anti-pattern - the final image keeps compilers and package metadata forever.
FROM python:3.12-slim
RUN apt-get update && apt-get install -y gcc curl build-essential
COPY requirements.txt ./
RUN pip install -r requirements.txt

# Better - compile in builder, copy only the runtime result into the final stage.
FROM python:3.12-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends gcc build-essential

FROM python:3.12-slim AS runtime
COPY --from=builder /install /usr/local

Multi-stage builds solve the problem cleanly. The builder image can be heavy and disposable. The runtime image should be boring, small, and predictable.

3. Zombie Processes, PID 1, and CMD vs ENTRYPOINT

Containers are often launched with a shell-form command like CMD uvicorn app.main:app --host 0.0.0.0 --port 8000. That quietly inserts /bin/sh -c as PID 1. Signals may not be forwarded correctly, child processes may not be reaped, and shutdown behavior becomes messy. The result is hanging deployments, stuck rollouts, or orphaned worker processes.

PatternBehaviorRecommendation
Shell-form CMDRuns through /bin/sh -cAvoid for production app processes
Exec-form CMDProcess receives signals directlyPreferred for most containers
dumb-init as ENTRYPOINTProper signal forwarding and zombie reapingStrong default for long-running services
# Problematic - shell form inserts a shell as PID 1 and complicates signal handling.
CMD uvicorn app.main:app --host 0.0.0.0 --port 8000

# Better - dumb-init is PID 1 and the app starts in exec form so SIGTERM reaches it directly.
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

Use ENTRYPOINT for the fixed executable wrapper and CMD for default arguments that operators may want to override. That split is easier to reason about during debugging and orchestration.

Module 4: Debugging in a Dockerized Environment

When a containerized service fails, the debugging sequence should be systematic: confirm container state, inspect the effective configuration, check logs, then enter the container or override the entrypoint if necessary. Avoid changing the image during triage unless you have already reproduced the issue and know the root cause.

Shell Access Inside a Running Container

If the service is still up, docker exec is the cleanest way to inspect the live filesystem, current environment, generated config files, or local network reachability. Prefer /bin/sh because many slim images do not include Bash.

# Open a POSIX shell in a running container to inspect files, env vars, and network reachability.
docker exec -it api-dev /bin/sh

# Print environment variables inside the container when injection problems are suspected.
docker exec -it api-dev /bin/sh -c 'env | sort'

# Verify the app port is listening from inside the container namespace.
docker exec -it api-dev /bin/sh -c 'ss -lntp || netstat -lntp'

Debugging a Container That Crashes on Startup

If the container exits immediately, there is nothing to exec into. The usual pattern is to run the image again but override the entrypoint so the container stays alive long enough for inspection. This is especially useful for missing env vars, import errors, bad working directories, or permissions problems.

i
Real-world scenario: a freshly built image works in CI but exits instantly in staging. Overriding the entrypoint reveals the app expects a certificate file that was present in the CI workspace but never copied into the final runtime image.
# Start the image with a shell instead of the normal entrypoint so you can inspect it interactively.
docker run -it --rm --entrypoint /bin/sh myorg/payments-api:2026.03.31

# Once inside, try the original startup command manually to see the raw error immediately.
uvicorn app.main:app --host 0.0.0.0 --port 8000

# If the image uses an init wrapper, inspect the configured metadata first to understand the normal start path.
docker image inspect myorg/payments-api:2026.03.31 | jq -r '.[0].Config.Entrypoint, .[0].Config.Cmd'

Log Analysis: Read, Follow, and Scope Output

Container logs should go to stdout and stderr. That design keeps the image simple and lets the platform decide how logs are collected. During debugging, start with the last few lines, then follow the stream in real time if the service is still running.

# Print the most recent 100 log lines so you do not drown in historical noise.
docker logs --tail 100 api-dev

# Follow the log stream live during startup or while replaying a failing request.
docker logs -f --tail 100 api-dev

# Include timestamps so you can align container events with load balancer or application logs elsewhere.
docker logs -f --tail 100 --timestamps api-dev

# Scope logs to a recent window when investigating a specific incident interval.
docker logs --since 15m --timestamps api-dev

If logs are empty, that itself is a clue. The process may be failing before it initializes logging, or stdout may be redirected incorrectly inside the image. In mature environments, the next step is correlating container logs with orchestrator events and node-level metrics.

Module 5: Docker vs Podman

Docker remains the most recognized container tooling ecosystem, but Podman is increasingly common in enterprises that want a daemonless model and stronger rootless defaults. The CLI experience is intentionally similar, so the choice is mostly about architecture and operating constraints rather than day-one usability.

Architecture: Daemon-Based vs Daemonless

Docker uses a long-running background daemon that receives requests from the CLI and performs build or runtime operations on the host. Podman is daemonless: the CLI launches containers directly through the OCI stack without requiring a central persistent daemon process.

!
Real-world scenario: a hardened jump host disallows persistent privileged background services. Podman fits the operating model cleanly because engineers can run containers without managing a resident daemon process.
Docker
Client-server model with a resident daemon. Mature ecosystem, familiar workflows, and broad third-party tooling support.
Podman
Daemonless architecture with a CLI that speaks OCI-native concepts directly. Often preferred where reduced daemon footprint matters.
# Show Docker server details to confirm the daemon-backed client-server model in use on the host.
docker info

# Show Podman host information and runtime details without relying on a long-lived daemon service.
podman info

# Check whether dockerd is present on the machine when comparing host architecture choices.
ps -ef | grep dockerd

Security: Why Rootless Podman Appeals to Enterprises

Podman is favored in stricter security environments because rootless containers are a first-class default rather than an afterthought. That reduces the privileges required to run workloads locally or on shared systems. Docker also supports rootless modes, but Podman built much of its operational identity around that model from the start.

+
Enterprise pattern: security-conscious workstation fleets often standardize on Podman for developers who need local containers but should not have broad root-equivalent daemon access on corporate laptops.
# Run a rootless container with Podman and print the effective user identity inside the container.
podman run --rm alpine:3.21 id

# Inspect Podman security settings on the host when validating a rootless rollout plan.
podman info --format '{{.Host.Security}}'

# Check whether Docker is running in rootless mode before comparing security posture.
docker info | grep -i rootless

Migration: Close Enough to Be a Drop-In for Many Teams

For many day-to-day commands, Podman is intentionally close to Docker. That is why teams often start with an alias during evaluation. The similarity reduces retraining cost, though build behavior, networking defaults, and Compose workflows should still be tested carefully before standardizing.

# Alias docker to podman in a shell profile during evaluation so common commands continue to work.
alias docker=podman

# Confirm the replacement CLI is available before telling developers to switch.
podman version

# Test your standard image build path under Podman before assuming full compatibility.
podman build -t myorg/payments-api:test .

Module 6: Container Orchestration (Kubernetes vs Docker Swarm)

A single Docker host is fine for local development, demos, and some small internal tools. It is not enough for serious production platforms that need high availability, rolling deployments, service discovery, autoscaling, health-based restarts, and standardized networking or secret management.

x
The need for orchestration: if a node dies and your only copy of a service dies with it, you do not have a production platform. Orchestration exists to schedule replacements, spread replicas, balance traffic, and keep desired state enforced automatically.

Docker Swarm

Docker Swarm is Docker's built-in clustering and orchestration layer. It is simple to learn and lighter to operate than Kubernetes, which made it attractive for small teams and hobby deployments. Today it is mostly seen in smaller environments, legacy internal platforms, or teams that explicitly trade ecosystem depth for simplicity.

# Initialize a Swarm manager on a small lab cluster or proof-of-concept environment.
docker swarm init

# Deploy a replicated service to understand the Swarm operating model.
docker service create --name web --replicas 3 -p 8080:80 nginx:1.27-alpine

# Inspect the desired replica count versus actual running tasks.
docker service ls

Swarm's simplicity is real, but the ecosystem momentum is not. Most enterprises no longer choose it for new strategic platforms because vendor integrations, policy tooling, and managed-cloud support overwhelmingly center on Kubernetes.

Kubernetes (K8s)

Kubernetes is the industry-standard orchestration platform. It won because it provided a rich, extensible control plane and because the entire cloud-native ecosystem standardized around it. At a minimum, engineers should understand Pods, Deployments, and Services:

i
Why K8s won the orchestration war: self-healing, declarative desired state, broad cloud vendor support, mature ingress and policy tooling, first-class observability integrations, and an enormous ecosystem of controllers and operators.
# Inspect the rollout health of a Deployment during a production release.
kubectl rollout status deployment/payments-api --namespace production

# List Pods with node placement and restart counts when debugging stability issues.
kubectl get pods -n production -o wide

# Check the Service object that fronts the workload and verify cluster IP or load balancer exposure.
kubectl get service payments-api -n production

Kubernetes is more complex than raw Docker by design. That complexity is justified when teams need horizontal scale, reliability, release control, and policy enforcement across many services. If the workload is a single internal API with modest traffic, a managed container platform like ECS Fargate, Cloud Run, Azure Container Apps, or a simple VM may still be the better engineering decision.

Closing Guidance

The best container teams optimize for boring operations. Images are small, startup commands are explicit, containers do one job well, and rebuilds are cheap enough that nobody is tempted to patch live systems. If you remember only three things, remember these: build immutable artifacts, run them with the least privilege possible, and hand serious production scheduling to an orchestrator rather than a human.