Skip to content

Docker in Production: Images, Volumes, Networks & Compose

Build, ship, and operate containers correctly — from your first Dockerfile to multi-service Compose stacks and registry workflows.

Docker EngineDocker ComposeBuildKitRegistry
RR

Ranjith R

Linux SysAdmin & Cloud Engineer

Published February 2, 2026Updated June 29, 2026
8 min readIntermediate#Docker#Containers#DevOps#Compose
Share
Section 01

Introduction

Docker packages an application and everything it needs to run — libraries, runtime, configuration — into a single, portable image, then runs that image as an isolated containerusing kernel features already built into Linux. The value isn't the container runtime itself; it's that "works on my machine" stops being a problem because the machine ships with the artifact.

This guide covers Docker the way it's actually used in production: building images correctly (layers, caching, multi-stage builds), persisting data with volumes, wiring services together on user-defined networks, orchestrating multi-container stacks with Compose, and pushing images through a registry — plus the CLI reference, troubleshooting instincts, and interview questions that come from running containers in anger.

Who this is for

  • Engineers containerizing an application for the first time.
  • DevOps/SRE engineers who need Docker fundamentals before Kubernetes.
  • Anyone who has run docker run but not yet debugged it at 2 a.m.
Section 02

Prerequisites

You should have the following before working through the commands in this guide:

  • A Linux, macOS, or Windows (WSL2) machine where you can install Docker Engine or Docker Desktop.
  • sudo/administrator access to install packages and manage the docker daemon.
  • Basic comfort with the Linux shell and a text editor.
  • A free Docker Hub account if you want to follow the registry push/pull examples.

Engine vs. Desktop

Docker Engine is the daemon + CLI, typically installed directly on Linux servers. Docker Desktop wraps Engine in a lightweight VM for macOS/Windows and adds a GUI — both speak the same API, so every command in this guide works identically on either.
Section 03

Theory

Containers vs. images

An imageis a read-only, layered filesystem plus metadata (entrypoint, env vars, exposed ports) — it's the recipe. A containeris a running instance of that image with a thin writable layer on top for any runtime changes. Stop a container and that writable layer's state is gone unless it was written to a volume; the image underneath never changes.

Namespaces and cgroups, conceptually

Docker doesn't virtualize hardware — it isolates a normal Linux process using two kernel features. Namespaces give a process its own view of resources:pid (its own process tree), net (its own network stack and interfaces), mnt (its own filesystem mounts), uts (its own hostname), and user (UID/GID remapping). cgroups (control groups) then cap and account for how much CPU, memory, and I/O that process is allowed to consume. A container is, at the kernel level, just a process with these two constraints applied — which is why containers start in milliseconds instead of the seconds-to-minutes a full VM boot takes.

Images: layers and tags

Every instruction in a Dockerfile that changes the filesystem (RUN, COPY, ADD) produces a new, content-addressed, immutable layer. Layers are cached and shared across images — if two images both start from node:20-alpine and run the same apt-get install, that layer is stored once on disk and pulled once over the network. A tag is just a mutable pointer to a specific image ID, e.g. myapp:1.4.2 or myapp:latestlatestis not special to Docker, it's only the default tag applied when none is given, and should never be relied on in production deploys.

Volumes: named volumes, bind mounts, tmpfs

MechanismManaged byTypical use
Named volumeDocker (/var/lib/docker/volumes)Databases, persistent app state
Bind mountHost filesystem path you chooseLocal dev — live-mount source code
tmpfs mountHost RAM, never written to diskSecrets or scratch space that must not persist

Named volumes are the production default: Docker manages the lifecycle, they survive docker rmof the container, and they're portable across drivers (local disk, NFS, cloud block storage). Bind mounts couple you to a specific host path, which is exactly what you want in development but a liability in production.

Networks: bridge, host, none, overlay

By default, every container joins the bridge network — an isolated virtual switch on the host with NAT out to the internet. hostmode removes network isolation entirely, sharing the host's network namespace directly (faster, but no port mapping and no isolation). none gives a container no networking at all. overlay networks span multiple Docker hosts and are what Swarm/Compose multi-host setups use for cross-node container communication.

Always create a user-defined bridge network

Containers on the default bridge network can only reach each other by IP. Containers on a user-defined bridge network get automatic DNS — a container named api can reach a container named db simply by resolving the hostname db, no hardcoded IPs required. Compose creates one of these for you automatically per project.
Section 04

Architecture

The Docker CLI never touches containers directly — every command is a REST API call to the daemon, which delegates the actual container lifecycle down through containerd and runc to the kernel:

docker CLI → dockerd → containerd → runc → kernel namespaces/cgroups

Why this layering matters

runc is the OCI-compliant low-level tool that actually creates the namespaced, cgrouped process. containerd manages the container lifecycle, image pulls, and storage above it. This is the same stack Kubernetes uses under the hood via the CRI — understanding it demystifies both tools.
Section 05

Installation

Install Docker Engine directly on Linux servers, or Docker Desktop on a local macOS/Windows machine for development:

terminal
# Remove any old/conflicting packages
sudo apt remove docker docker-engine docker.io containerd runc

# Set up Docker's official apt repository
sudo apt update
sudo apt install -y ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \
  sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg

echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
  https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

# Install Engine, CLI, containerd, buildx, and compose plugin
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

# Run docker without sudo
sudo usermod -aG docker $USER
newgrp docker

docker version
docker compose version

Verify the install before trusting it

Run docker run hello-worldafter any install. It pulls a tiny image, confirms the daemon is reachable, runs a container, and exits cleanly — the fastest smoke test for a broken socket permission or a daemon that isn't running.
Section 06

Configuration

The Dockerfile

A Dockerfile is an ordered list of build instructions. Instruction order matters directly for build speed: Docker caches each layer and invalidates it — and every layer after it — the moment its inputs change. Put whatever changes least often first.

Dockerfile
# --- Stage 1: build ---
FROM node:20-alpine AS builder
WORKDIR /app

# Copy only manifest files first so npm ci is cache-hit
# on every rebuild that doesn't touch dependencies
COPY package.json package-lock.json ./
RUN npm ci

# Now copy the rest of the source — this layer invalidates often,
# but everything above it stays cached
COPY . .
RUN npm run build

# --- Stage 2: runtime ---
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production

# Run as a non-root user, not the default root
RUN addgroup -S app && adduser -S app -G app
COPY --from=builder --chown=app:app /app/dist ./dist
COPY --from=builder --chown=app:app /app/node_modules ./node_modules
COPY --from=builder --chown=app:app /app/package.json ./package.json

USER app
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://localhost:3000/health || exit 1

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

The multi-stage build above never ships the build tooling, dev dependencies, or source maps into the final image — only the compiled output. This routinely turns a 1GB+ image into something in the 100–200MB range.

.dockerignore

Everything not excluded here is sent to the build context and is a candidate to invalidate cache or bloat the image — treat it like .gitignore for builds:

.dockerignore
node_modules
npm-debug.log
.git
.gitignore
.env
.env.*
dist
coverage
Dockerfile
docker-compose.yml
*.md

Building and tagging images

terminal
# Build from the Dockerfile in the current directory
docker build -t myapp:1.4.2 .

# Tag the same image for a registry and as the rolling "latest"
docker tag myapp:1.4.2 registry.example.com/myapp:1.4.2
docker tag myapp:1.4.2 registry.example.com/myapp:latest

# Build a specific stage only (useful for a debug/test image)
docker build --target builder -t myapp:builder .

# Build with BuildKit cache mounts for faster dependency installs
DOCKER_BUILDKIT=1 docker build -t myapp:1.4.2 .

Volumes

terminal
# Named volume — Docker-managed, portable, survives container removal
docker volume create pgdata
docker run -d --name db -v pgdata:/var/lib/postgresql/data postgres:16

# Bind mount — host path, ideal for local dev live-reload
docker run -d --name web -v $(pwd)/src:/app/src -p 3000:3000 myapp:1.4.2

# tmpfs mount — RAM-backed, never touches disk
docker run -d --name cache --tmpfs /app/tmp:rw,size=64m myapp:1.4.2

# Inspect and clean up
docker volume ls
docker volume inspect pgdata
docker volume prune

Networks

terminal
# Create a user-defined bridge network with built-in DNS
docker network create app-net

# Attach containers to it — they can now resolve each other by name
docker run -d --name db --network app-net postgres:16
docker run -d --name api --network app-net -p 8080:8080 myapp:1.4.2
# from inside 'api', the hostname 'db' resolves to the db container's IP

docker network ls
docker network inspect app-net

Compose: a real multi-service stack

Compose describes an entire application — app, database, reverse proxy — as one declarative file, with a private network and named volumes created automatically:

docker-compose.yml
services:
  app:
    build: .
    image: myapp:1.4.2
    restart: unless-stopped
    environment:
      NODE_ENV: production
      DATABASE_URL: postgres://app:secret@db:5432/appdb
    depends_on:
      db:
        condition: service_healthy
    networks:
      - backend

  db:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: appdb
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - backend

  nginx:
    image: nginx:1.27-alpine
    restart: unless-stopped
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - app
    networks:
      - backend

volumes:
  pgdata:

networks:
  backend:
    driver: bridge
terminal
# Bring the whole stack up in the background, rebuilding images if needed
docker compose up -d --build

# Follow logs across all services
docker compose logs -f

# Scale a single service
docker compose up -d --scale app=3

# Tear down (add -v to also remove named volumes)
docker compose down

Registry: Docker Hub vs. private registry

Docker Hub is the default public registry; production teams typically run a private registry (AWS ECR, GCP Artifact Registry, GitLab Registry, or a self-hosted registry:2) for proprietary images and tighter access control.

terminal
# Authenticate against a registry
docker login registry.example.com

# Tag with a semantic version AND a content-addressable/immutable reference
docker tag myapp:1.4.2 registry.example.com/team/myapp:1.4.2
docker tag myapp:1.4.2 registry.example.com/team/myapp:$(git rev-parse --short HEAD)

# Push, then pull on the target host
docker push registry.example.com/team/myapp:1.4.2
docker pull registry.example.com/team/myapp:1.4.2

# Log out when done on a shared machine
docker logout registry.example.com

Never deploy on the latest tag

Deploying myapp:latestmeans you can't tell what's actually running and can't reliably roll back. Tag every build with an immutable identifier — a semver or a git SHA — and reference that exact tag in your deploy manifests.
Section 07

Commands

Running and managing containers

terminal
docker run -d --name web -p 8080:80 nginx:1.27-alpine   # detached, port mapped
docker run -it ubuntu:24.04 bash                          # interactive shell
docker run --rm alpine echo hi                            # auto-remove on exit
docker ps                                                  # running containers
docker ps -a                                               # all containers, including stopped
docker stop web                                            # graceful (SIGTERM, then SIGKILL)
docker start web
docker restart web
docker rm web                                              # remove a stopped container
docker rm -f web                                           # force stop + remove

Inspecting and debugging

terminal
docker logs web                    # stdout/stderr from the container
docker logs -f --tail 100 web       # follow, last 100 lines
docker exec -it web sh              # shell into a running container
docker inspect web                  # full JSON metadata (network, mounts, env)
docker top web                      # processes running inside the container
docker stats                        # live CPU/mem/net for all containers
docker diff web                     # filesystem changes vs. the image

Images and disk cleanup

terminal
docker images                       # list local images
docker rmi myapp:1.4.2               # remove an image
docker image prune                   # remove dangling (untagged) images
docker image prune -a                # remove ALL unused images, not just dangling
docker system df                     # disk usage breakdown by category
docker system prune -a --volumes     # nuke unused images, containers, networks, volumes

Compose CLI reference

terminal
docker compose up -d          # start stack in background
docker compose down           # stop and remove containers/networks
docker compose down -v        # also remove named volumes
docker compose ps             # stack-scoped container list
docker compose logs -f app    # logs for one service
docker compose exec app sh    # shell into a running service
docker compose restart app    # restart one service
docker compose config         # validate + print resolved config
Section 08

Examples

Containerizing a Node.js API end-to-end, from source to a running, networked stack:

  1. Write the Dockerfile and .dockerignore

    Multi-stage build: npm ci and npm run build in a builder stage, then copy only dist/ and node_modules into a slim node:20-alpine runtime stage running as a non-root user.
  2. Build and smoke-test locally

    docker build -t myapp:dev ., then docker run --rm -p 3000:3000 myapp:dev and curl localhost:3000/health before writing any orchestration.
  3. Add Postgres on a user-defined network

    docker network create app-net, start Postgres with a named volume attached to that network, then confirm the app container can resolve it by hostname — no hardcoded IPs.
  4. Move to Compose

    Translate both containers into a docker-compose.yml with a depends_on healthcheck gate so the app never starts before Postgres is actually accepting connections.
  5. Tag and push to a registry

    Tag the built image with the git SHA, docker login to the target registry, docker push, then pull and run that exact tag on the target server.
Section 10

Real World Example

Scenario: a production host running a Compose stack alerts at 3 a.m. with disk usage at 95% and new deploys failing with no space left on device, even though the application itself writes almost no data.

  1. Confirm and scope the problem

    df -h confirms the root volume is nearly full. docker system df shows tens of gigabytes tied up in images and build cache, not application data.
  2. Identify the cause

    Months of CI builds each produced a new tagged image without ever removing the old, now-dangling layers underneath — every deploy left behind an untagged image, and BuildKit's cache had never been pruned.
  3. Free space immediately

    docker image prune -a -f removes unused images, docker builder prune -f clears the build cache, and docker system prune -a --volumes -f (run only after confirming no needed volumes are unused) reclaims the rest.
  4. Prevent recurrence

    Add a scheduled docker system prune -af --filter "until=168h" job to cron, and configure the CI pipeline to delete the previous image tag from the registry once a new deploy is confirmed healthy instead of retaining every build forever.
Troubleshooting

Common Issues

Container exits immediately after docker run

Check docker logs <container> first — most commonly the process specified in CMD/ENTRYPOINTran to completion (e.g. it wasn't a long-running foreground process) or crashed on startup due to a missing env var. Containers stay alive only as long as their PID 1 process does.

Bind: address already in use

Another process (often a previous container, or a service installed directly on the host) already holds that port. Find it with sudo ss -tulnp | grep :8080 or docker ps to spot a stale container still bound to it, then stop the conflicting process or map to a different host port with -p 8081:80.

Volume permission denied inside the container

A named volume or bind mount is often owned by root or a UID that doesn't match the container's non-root user. Fix ownership with an entrypoint chown, or align the container's USERUID with the host directory's owning UID via --user $(id -u):$(id -g).

No space left on device

Almost always dangling images and build cache, not application data — docker system df shows the breakdown, and docker system prune -a --volumes reclaims it. Put this on a schedule instead of firefighting it reactively.

Containers can't resolve each other by name

They're likely on the default bridge network, which has no embedded DNS — only user-defined bridge networks (including the one Compose creates automatically) give containers hostname resolution. Create one explicitly with docker network create and attach both containers to it.
Best Practices

Best Practices

  • Use multi-stage builds so build tooling and dev dependencies never ship in the runtime image.
  • Order Dockerfile instructions from least- to most-frequently-changing to maximize cache hits.
  • Pin base image tags to a specific version (node:20-alpine, not node:latest) for reproducible builds.
  • Run containers as a non-root USER and drop unnecessary capabilities.
  • Give every service a HEALTHCHECK so orchestrators can detect a hung process, not just a crashed one.
  • Keep one process per container — use Compose to link multiple single-purpose containers rather than supervising several processes inside one.
  • Schedule regular docker system prune maintenance instead of discovering disk pressure during an outage.
Security

Security Hardening

  • Non-root by default — set a USER in every Dockerfile; never run application processes as root inside the container.
  • Minimal base images — prefer alpine or distroless images to shrink the attack surface and CVE count.
  • Image scanning — run docker scout or Trivy in CI to catch known-vulnerable packages before they reach production.
  • Read-only filesystems — run with --read-only and an explicit tmpfs for the few paths that need writes.
  • Drop capabilities — start from --cap-drop=ALL and add back only what's required instead of trusting the broad default set.
  • Never mount the Docker socket into untrusted containers/var/run/docker.sock access is equivalent to root on the host.
  • Secrets — pass via Compose secrets: or an orchestrator's secret store, never baked into an image layer or a plain ENV.
Interview Questions

Interview Questions

An image is a read-only, layered filesystem plus metadata — the immutable template. A container is a running (or stopped) instance of an image with a thin writable layer on top for runtime changes. Many containers can run from the same image simultaneously.
Cheat Sheet

Cheat Sheet

docker-cheatsheet.sh
# --- Images ---
docker build -t <name>:<tag> .            # build from Dockerfile
docker images                              # list local images
docker rmi <image>                         # remove an image
docker tag <img> <registry>/<img>:<tag>    # retag for a registry

# --- Containers ---
docker run -d --name <n> -p HOST:CONT <img> # run detached, port mapped
docker ps ; docker ps -a                    # running / all containers
docker exec -it <c> sh                      # shell into a container
docker logs -f --tail 100 <c>               # follow recent logs
docker stop|start|restart|rm <c>

# --- Volumes/Networks ---
docker volume create|ls|inspect|prune <v>
docker network create|ls|inspect <net>

# --- Registry ---
docker login <registry>
docker push|pull <registry>/<img>:<tag>

# --- Compose ---
docker compose up -d --build
docker compose logs -f <service>
docker compose exec <service> sh
docker compose down -v

# --- Cleanup ---
docker system df                           # disk usage breakdown
docker system prune -a --volumes           # reclaim everything unused
Summary

Summary

Production Docker comes down to a small set of layered fundamentals: images built from cache-aware, multi-stage Dockerfiles; persistent state kept in named volumes rather than the container's writable layer; services connected over user-defined networks with built-in DNS; entire stacks declared once in Compose; and versioned images pushed through a registry with immutable tags. The CLI surface is large, but almost every production issue traces back to one of those five ideas — practice building, breaking, and cleaning up containers on a disposable host until the commands are automatic.

Resources

Resources

  • Official Docker documentation — docs.docker.com
  • Dockerfile reference and best practices — docs.docker.com/build
  • Docker Compose file reference — docs.docker.com/compose
  • Open Container Initiative specifications — opencontainers.org
  • Docker Hub image registry — hub.docker.com