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 runbut not yet debugged it at 2 a.m.
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.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:latest — latestis 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
| Mechanism | Managed by | Typical use |
|---|---|---|
| Named volume | Docker (/var/lib/docker/volumes) | Databases, persistent app state |
| Bind mount | Host filesystem path you choose | Local dev — live-mount source code |
| tmpfs mount | Host RAM, never written to disk | Secrets 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 defaultbridge 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.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.Installation
Install Docker Engine directly on Linux servers, or Docker Desktop on a local macOS/Windows machine for development:
# 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 versionVerify the install before trusting it
Rundocker 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.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.
# --- 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:
node_modules
npm-debug.log
.git
.gitignore
.env
.env.*
dist
coverage
Dockerfile
docker-compose.yml
*.mdBuilding and tagging images
# 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
# 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 pruneNetworks
# 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-netCompose: 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:
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# 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 downRegistry: 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.
# 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.comNever deploy on the latest tag
Deployingmyapp: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.Commands
Running and managing containers
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 + removeInspecting and debugging
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 imageImages and disk cleanup
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, volumesCompose CLI reference
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 configExamples
Containerizing a Node.js API end-to-end, from source to a running, networked stack:
Write the Dockerfile and .dockerignore
Multi-stage build:npm ciandnpm run buildin abuilderstage, then copy onlydist/andnode_modulesinto a slimnode:20-alpineruntime stage running as a non-root user.Build and smoke-test locally
docker build -t myapp:dev ., thendocker run --rm -p 3000:3000 myapp:devand curllocalhost:3000/healthbefore writing any orchestration.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.Move to Compose
Translate both containers into adocker-compose.ymlwith adepends_onhealthcheck gate so the app never starts before Postgres is actually accepting connections.Tag and push to a registry
Tag the built image with the git SHA,docker loginto the target registry,docker push, then pull and run that exact tag on the target server.
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.
Confirm and scope the problem
df -hconfirms the root volume is nearly full.docker system dfshows tens of gigabytes tied up in images and build cache, not application data.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.Free space immediately
docker image prune -a -fremoves unused images,docker builder prune -fclears the build cache, anddocker system prune -a --volumes -f(run only after confirming no needed volumes are unused) reclaims the rest.Prevent recurrence
Add a scheduleddocker 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.
Common Issues
Container exits immediately after docker run
Checkdocker 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 withsudo 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 entrypointchown, 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 defaultbridge 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
- 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, notnode:latest) for reproducible builds. - Run containers as a non-root
USERand drop unnecessary capabilities. - Give every service a
HEALTHCHECKso 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 prunemaintenance instead of discovering disk pressure during an outage.
Security Hardening
- Non-root by default — set a
USERin every Dockerfile; never run application processes as root inside the container. - Minimal base images — prefer
alpineordistrolessimages to shrink the attack surface and CVE count. - Image scanning — run
docker scoutor Trivy in CI to catch known-vulnerable packages before they reach production. - Read-only filesystems — run with
--read-onlyand an explicittmpfsfor the few paths that need writes. - Drop capabilities — start from
--cap-drop=ALLand add back only what's required instead of trusting the broad default set. - Never mount the Docker socket into untrusted containers —
/var/run/docker.sockaccess 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 plainENV.
Interview Questions
Cheat Sheet
# --- 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 unusedSummary
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
- 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