Docker & Docker Compose,
explained without the fluff.
A container packages an app with everything it needs to run, so it behaves the same on your laptop, a teammate's laptop and a server. Docker builds and runs those containers. Compose runs several of them together as one application. That's the whole idea — everything past this page is detail.
Pick an OS above
The Windows / Linux / WSL toggle up top changes the Install and Verify pages and a few notes elsewhere in the guide.
Copy commands directly
Every code block has a copy button. Commands are current as of mid-2026 Docker releases.
Read a page at a time
Use the sidebar, not your scroll wheel — each topic is its own page, plus a "next" link at the bottom to keep moving.
Because the setups are genuinely different, not just cosmetically. On Windows, most people want Docker Desktop. On Linux, you install a native daemon with your distro's package manager. On WSL, you get an actual choice: share Windows' Docker Desktop daemon or run the Engine natively inside the distro with nothing installed on the Windows side at all. Flattening that into one script would hide the decision instead of explaining it — so it stays split and the toggle at the top switches the whole guide to match.
What Compose adds
Real applications are rarely a single container. Compose lets you describe a whole stack — an API, a database, a cache — in one file and bring it all up together, wired onto the same network.
services:
web:
build: .
ports:
- "8080:80"
depends_on:
- db
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: example
volumes:
- db-data:/var/lib/postgresql/data
volumes:
db-data:Run docker compose up and both services start on a private network where they can reach each other by service name — web connects to db at the hostname db, no IP addresses involved.
docker-compose (hyphenated, Python, v1) is deprecated. Current Docker ships Compose as a built-in plugin — the command is docker compose, no hyphen and it's what every example in this guide uses.How it fits together
Docker is a client-server system. Three parts come up constantly:
Docker CLI
The docker command you type. It sends requests to the daemon — it doesn't build or run anything itself.
Docker daemon (dockerd)
The background service that actually builds images and runs containers.
Registry
Where images are stored remotely — Docker Hub by default or something private like GHCR or a self-hosted registry.
| Container | Virtual machine | |
|---|---|---|
| Shares host kernel | Yes | No — runs its own |
| Startup time | Under a second, usually | Tens of seconds or more |
| Typical size | Megabytes | Gigabytes |
| Isolation mechanism | Kernel namespaces & cgroups | Hardware-level virtualization |
Every instruction in a Dockerfile produces a read-only layer. Layers stack via a union filesystem and identical layers are cached and reused across images — this is why builds and pulls get fast after the first time. A running container just adds one writable layer on top; delete the container and only that layer disappears.
Quick check
Why does a container start faster than a VM?
Requirements
Same table regardless of the toggle above — check this once, then move to Install.
| Platform | Minimum | Notes |
|---|---|---|
| Windows | Windows 10 64-bit build 19041+ or Windows 11 | WSL2 backend is the current recommendation over Hyper-V |
| macOS | macOS 12+, Intel or Apple Silicon | Docker Desktop, same as Windows conceptually — no WSL step |
| Linux | 64-bit kernel 3.10+ | Native install, no VM layer, generally best performance |
| WSL | WSL2, same Windows build as above | Two install paths — covered on the Install page |
Install Docker
This page's content depends on the toggle at the top of the screen — currently showing WindowsLinuxmacOSWSL instructions.
Docker Desktop is the standard way to run Docker on Windows. It manages a small Linux VM behind the scenes (via WSL2) and gives you the daemon, the CLI, Compose and a GUI.
- Install WSL2 first, from an admin PowerShell:
Restart when prompted.
wsl --install
- Install Docker Desktop, either from docker.com or with winget:
winget install -e --id Docker.DockerDesktop
- Run the installer, keeping "Use WSL 2 instead of Hyper-V" checked.
- Launch Docker Desktop and wait for the whale icon in the system tray to settle — that means the daemon is up.
- Verify from PowerShell or Windows Terminal:
docker --version docker compose version docker run hello-world
"Docker Desktop Installer.exe" install --quiet --accept-license
| Option | What it is |
|---|---|
| Rancher Desktop | Free, open-source alternative — container runtime plus optional Kubernetes, also runs on WSL2 |
| Podman Desktop | Daemonless, rootless engine with a Docker-compatible CLI |
| WSL, native install | Switch the toggle above to WSL — skip Docker Desktop entirely |
Docker on macOS also runs through Docker Desktop — there's no native Linux daemon option like there is on Linux, since macOS's kernel isn't Linux. Desktop runs a lightweight Linux VM behind the scenes (via Apple's Virtualization framework) and gives you the daemon, the CLI, Compose and a GUI.
- Download Docker Desktop for Mac from docker.com — pick the Apple Silicon or Intel build depending on your chip. Check which one you have:
uname -m # arm64 → Apple Silicon build, x86_64 → Intel build - Or install with Homebrew, if you use it:
brew install --cask docker
- Drag Docker to Applications, then open it once from Launchpad or Spotlight and accept the permissions prompts.
- Wait for the whale icon in the menu bar to stop animating — that means the daemon is up.
- Verify from Terminal:
docker --version docker compose version docker run hello-world
| Option | What it is |
|---|---|
| Colima | Lightweight, open-source Docker runtime for macOS — CLI-only, runs in a Lima VM, no GUI or license question |
| Rancher Desktop | Free, open-source alternative with an optional Kubernetes cluster |
| Podman Desktop | Daemonless, rootless engine with a Docker-compatible CLI |
brew install colima docker docker-compose colima start docker run hello-world
arm64 and amd64 builds and Docker pulls the right one automatically. If an image is amd64-only, it'll still run under emulation (slower) — add --platform linux/amd64 to force it or build multi-platform images yourself with buildx (see the Buildx page).On Linux, Docker runs natively — no VM layer. Pick your package manager below. All of them install the same four components: docker-ce (the engine), docker-ce-cli, containerd.io and docker-compose-plugin.
# remove any old or conflicting packages first for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do sudo apt-get remove -y $pkg 2>/dev/null done # add Docker's apt repository sudo apt-get update sudo apt-get install -y ca-certificates curl sudo install -m 0755 -d /etc/apt/keyrings sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc sudo chmod a+r /etc/apt/keyrings/docker.asc echo \ "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \ 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 sudo apt-get update sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
On Debian, use debian instead of ubuntu in both URLs.
# remove conflicting packages sudo dnf remove -y docker docker-client docker-client-latest docker-common \ docker-latest docker-latest-logrotate docker-logrotate docker-engine podman runc # add Docker's repo — swap centos for rhel or fedora as needed sudo dnf -y install dnf-plugins-core sudo dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo # install sudo dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin # start it sudo systemctl enable --now docker
RHEL, Rocky and Alma can all use the rhel repo URL. Plain Fedora uses .../linux/fedora/docker-ce.repo.
Docker is in Arch's official repos — no third-party repo needed.
sudo pacman -Syu --needed docker docker-compose docker-buildx sudo systemctl enable --now docker.service
sudo zypper install -y docker docker-compose docker-buildx sudo systemctl enable --now docker
apk update apk add docker docker-compose docker-cli-buildx # Alpine uses OpenRC, not systemd rc-update add docker boot service docker start adduser $USER docker
systemctl won't exist on Alpine — use rc-update and service as shown.Auto-detects your distro. Convenient for quick VMs and dev boxes, not recommended for production — no version pinning, runs as root.
curl -fsSL https://get.docker.com -o get-docker.sh less get-docker.sh # read it before running anything sudo sh get-docker.sh
There are two genuinely different ways to run Docker inside WSL. Pick one — they don't need to be combined.
If you already have (or don't mind having) Docker Desktop on Windows, this is the least setup: Docker Desktop runs its engine inside WSL2 and exposes it to any distro you enable, with nothing to install inside Linux at all.
- Install Docker Desktop on Windows.
- Open Docker Desktop → Settings → Resources → WSL Integration.
- Turn on integration for your distro (Ubuntu, Debian, whichever you use).
- Open your WSL distro —
dockeranddocker composealready work, no install step needed:docker --version docker compose version
If you'd rather not have Docker Desktop on Windows at all, install the real Docker Engine directly inside your WSL2 distro. This runs exactly like Docker on a native Linux box, managed by systemd, with nothing touching the Windows side.
WSL doesn't run systemd by default and Docker's service management expects it. Edit /etc/wsl.conf inside the distro:
sudo tee /etc/wsl.conf <<'EOF' [boot] systemd=true EOF
Then from PowerShell: wsl --shutdown and reopen the distro.
# remove anything that might conflict for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do sudo apt remove -y $pkg done # prerequisites sudo apt install -y ca-certificates curl gnupg lsb-release # add Docker's GPG key 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 # add the repository 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 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
Running a different WSL distro? Use the matching package manager from the Linux toggle — the commands are identical once you're inside the distro, WSL doesn't change them.
sudo systemctl enable --now docker docker run hello-world
~/projects, not /mnt/c/.... Crossing between the Windows and Linux filesystems is noticeably slower and will make bind-mounted dev containers feel sluggish.sudo update-alternatives --set iptables /usr/sbin/iptables-legacy sudo update-alternatives --set ip6tables /usr/sbin/ip6tables-legacy sudo service docker restart
Verify & configure
Same idea everywhere: confirm Docker actually works, then make it convenient to use daily.
docker --version docker compose version docker run hello-world
Docker Desktop starts the daemon automatically and can be set to launch on login from its settings. There's no user-group step on Windows — that's a Linux/WSL-native concept.
sudo groupadd docker 2>/dev/null # usually already exists sudo usermod -aG docker $USER newgrp docker # or log out and back in
sudo systemctl enable docker.service sudo systemctl enable containerd.service
docker run hello-world docker info docker system df
docker --version docker compose version docker run hello-world
Docker Desktop starts the daemon automatically and can be set to launch at login from its settings. No user-group step here either — same as Windows, that's a Linux/WSL concept.
If you used the shared Docker Desktop daemon, there's nothing to configure — permissions and startup are handled on the Windows side already.
If you installed natively inside WSL, it's the same as a Linux install:
sudo usermod -aG docker $USER newgrp docker sudo systemctl enable --now docker docker run hello-world
wsl --shutdown from Windows, the whole VM stops and the daemon goes with it. It restarts automatically (via systemd) the next time you open the distro.Images vs containers
An image is a read-only template. A container is a running (or stopped) instance of that image, with its own writable layer and its own process.
# pull an image docker pull nginx:latest # see what's local docker images # create + start a container from it docker run -d --name web -p 8080:80 nginx:latest # running containers docker ps # all containers, including stopped ones docker ps -a
CLI essentials
Twelve commands cover most day-to-day work.
| Command | Does |
|---|---|
docker run <img> | Create and start a container |
docker ps [-a] | List running (or all) containers |
docker exec -it <c> bash | Shell into a running container |
docker logs -f <c> | Stream logs |
docker stop / start / restart <c> | Lifecycle control |
docker rm <c> | Delete a stopped container |
docker images | List local images |
docker rmi <img> | Delete an image |
docker pull / push <img> | Move an image to/from a registry |
docker build -t <name> . | Build an image from a Dockerfile |
docker inspect <c> | Full metadata as JSON |
docker cp <c>:/path ./local | Copy files in or out |
docker run \ -d \ # detached, runs in background --name myapp \ # friendly name -p 3000:3000 \ # host:container port mapping -e NODE_ENV=prod \ # environment variable -v $(pwd):/app \ # bind mount current dir into /app --restart unless-stopped \ node:20-alpine
Quick check
Which command opens a shell in a container that's already running?
Volumes & bind mounts
Containers are ephemeral — delete one and its writable layer is gone. Data that needs to survive gets attached from outside.
| Type | Managed by | Best for |
|---|---|---|
| Named volume | Docker | Databases, anything Docker should own |
| Bind mount | You, any host path | Local dev — live-edit source code |
| tmpfs mount | RAM only | Secrets, scratch space |
# named volume docker volume create app-data docker run -v app-data:/var/lib/data postgres:16 # bind mount docker run -v /home/me/project:/app -w /app node:20 # shorthand for "current directory" docker run -v $(pwd):/app -w /app node:20 # tmpfs docker run --tmpfs /run node:20
Networking basics
| Driver | Behavior |
|---|---|
bridge (default) | Private network on the host; publish ports to reach containers from outside |
host | Container shares the host's network stack directly — works on Linux/WSL only; Desktop on this OS doesn't support it the same way |
none | No networking |
overlay | Multi-host networking for Swarm |
# publish container port 80 to host port 8080 docker run -p 8080:80 nginx # bind to one host interface only docker run -p 127.0.0.1:8080:80 nginx docker network ls docker network inspect bridge
-p HOST:CONTAINER. -p 8080:80 means "reach container port 80 via host port 8080" — easy to flip by accident.Env vars & .env
# inline docker run -e NODE_ENV=production -e PORT=3000 myapp # from a file docker run --env-file .env myapp
NODE_ENV=production PORT=3000 DATABASE_URL=postgres://user:pass@db:5432/app
ENV in a Dockerfile gets baked into every layer and stays visible via docker history. Pass secrets at runtime instead.Dockerfile anatomy
FROM node:20-alpine # base image WORKDIR /app # cwd for everything after COPY package*.json ./ # dependency manifests first RUN npm ci --omit=dev # cached if package.json unchanged COPY . . # rest of the source ENV NODE_ENV=production EXPOSE 3000 # documents the port, doesn't publish it USER node # drop root CMD ["node", "server.js"] # default command
| Instruction | Purpose |
|---|---|
FROM | Base image |
WORKDIR | Working directory for what follows |
COPY / ADD | Copy files in — prefer COPY, ADD has surprising auto-extract/URL behavior |
RUN | Execute a command at build time, creates a layer |
ENV | Environment variable, persists into the container |
ARG | Build-time-only variable, not in the final image |
EXPOSE | Informational — documents the listening port |
USER | Switch to a non-root user |
ENTRYPOINT | Fixed executable that always runs |
CMD | Default arguments, overridable at run time |
HEALTHCHECK | Command Docker runs periodically to judge health |
docker build -t myapp:1.0 . docker build --build-arg VERSION=1.0 -t myapp .
.dockerignore
Everything in the build context is sent to the daemon before the build starts. Exclude what the image doesn't need.
node_modules .git .env *.log dist .vscode Dockerfile README.md **/__pycache__
Build cache & layers
Docker caches each layer. If an instruction and its inputs haven't changed, the cached result is reused — but the moment one layer changes, every layer after it rebuilds. Order the Dockerfile from least-changing to most-changing.
Good order
Copy dependency manifests, install deps, then copy source. Dependencies change rarely, source changes daily.
Bad order
COPY . . before installing dependencies — any source edit reruns the expensive install step every time.
Extra speed
BuildKit cache mounts persist package-manager caches across builds — see the Buildx page.
docker history myapp:1.0 # see the layers and their sizes
docker build --no-cache -t myapp .Multi-stage builds
Compilers and dev dependencies bloat an image and widen its attack surface. Multi-stage builds compile in a throwaway stage and copy only the final artifact into a slim runtime stage.
# build stage FROM golang:1.22 AS builder WORKDIR /src COPY . . RUN go build -o /out/server . # runtime stage — no Go toolchain FROM alpine:3.20 COPY --from=builder /out/server /usr/local/bin/server USER nobody CMD ["server"]
golang build image. Same idea applies to Node, Java and Python builds.Tagging & registries
# tag format: [registry/]namespace/repo[:tag] docker build -t myuser/myapp:1.0 . docker login docker push myuser/myapp:1.0 # tag + push to a different registry docker tag myapp:1.0 ghcr.io/myuser/myapp:1.0 docker login ghcr.io -u myuser docker push ghcr.io/myuser/myapp:1.0 docker pull myuser/myapp:1.0
:latest in productionIt's a moving target. Pin explicit versions or digests for anything deployed.compose.yaml anatomy
name: myapp
services:
api:
build:
context: .
dockerfile: Dockerfile
image: myapp-api:dev
ports:
- "3000:3000"
environment:
- NODE_ENV=development
env_file: .env
volumes:
- ./src:/app/src
depends_on:
db:
condition: service_healthy
networks:
- backend
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: devpass
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
networks:
- backend
networks:
backend:
volumes:
db-data:| Top-level key | Purpose |
|---|---|
services | Each container to run — the name doubles as its DNS hostname on the network |
networks | Custom networks; same-network services resolve each other by name |
volumes | Named volumes, persisted across up/down |
secrets | File-based secret injection |
Compose commands
docker compose up # start, attached — logs in terminal docker compose up -d # start, detached docker compose up --build # rebuild images first docker compose down # stop and remove containers + network docker compose down -v # also remove named volumes — destructive docker compose ps docker compose logs -f api docker compose exec api sh docker compose restart api docker compose build docker compose stop / start docker compose config # validate & print the resolved config
Env files & overrides
Compose auto-merges compose.yaml with compose.override.yaml when present — a shared base file plus a local dev-only override.
services:
api:
volumes:
- ./src:/app/src # live-reload mount, dev only
environment:
- DEBUG=true# stack multiple files explicitly docker compose -f compose.yaml -f compose.prod.yaml up -d docker compose --env-file .env.production up -d
Variables inside compose.yaml can reference a .env file in the same directory automatically with ${VARIABLE}, no flag needed for the default case.
Profiles
Tag services with profiles to make them opt-in — useful for debug tools or admin UIs you don't want running by default.
services:
api:
build: .
db:
image: postgres:16
pgadmin:
image: dpage/pgadmin4
profiles: ["debug"]docker compose up -d # pgadmin is skipped docker compose --profile debug up -d # pgadmin included
Healthchecks & depends_on
Plain depends_on only waits for a container to start, not for the app inside it to be ready. Pair it with a healthcheck and a condition.
services:
api:
build: .
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
db:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
cache:
image: redis:7-alpineWorked example — a small API with a database
Everything from this section, in one consistent project: a Node/Express API, Postgres, a multi-stage Dockerfile, a compose.yaml, an env file and a GitHub Actions workflow that builds and pushes the image. Every file below is real and consistent with the others — download it as a starting point.
docker compose up
notes-api/ ├── package.json ├── src/ │ └── server.js ├── Dockerfile ├── .dockerignore ├── .env.example ├── compose.yaml └── .github/workflows/docker.yml
{
"name": "notes-api",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js"
},
"dependencies": {
"express": "^4.19.2",
"pg": "^8.11.5"
}
}// minimal API — health check + a notes table it creates on first boot
import express from 'express';
import pg from 'pg';
const { Pool } = pg;
const app = express();
app.use(express.json());
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
await pool.query(
'CREATE TABLE IF NOT EXISTS notes (id SERIAL PRIMARY KEY, body TEXT NOT NULL)'
);
app.get('/health', async (req, res) => {
try {
await pool.query('SELECT 1');
res.json({ status: 'ok' });
} catch (err) {
res.status(500).json({ status: 'db unreachable' });
}
});
app.get('/notes', async (req, res) => {
const { rows } = await pool.query('SELECT id, body FROM notes ORDER BY id DESC');
res.json(rows);
});
app.post('/notes', async (req, res) => {
const { body } = req.body;
const { rows } = await pool.query(
'INSERT INTO notes (body) VALUES ($1) RETURNING id, body',
[body]
);
res.status(201).json(rows[0]);
});
const port = process.env.PORT || 3000;
app.listen(port, () => console.log(`notes-api listening on ${port}`));# build stage FROM node:20-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm install COPY . . # runtime stage FROM node:20-alpine WORKDIR /app ENV NODE_ENV=production COPY package*.json ./ RUN npm install --omit=dev COPY --from=builder /app/src ./src EXPOSE 3000 USER node HEALTHCHECK --interval=10s --timeout=3s CMD wget -qO- http://localhost:3000/health || exit 1 CMD ["node", "src/server.js"]
Uses npm install rather than npm ci so the project runs without a committed lockfile — swap to npm ci once you've generated a real package-lock.json locally.
node_modules .git .env *.log .github
PORT=3000 DATABASE_URL=postgres://app:app@db:5432/notes POSTGRES_USER=app POSTGRES_PASSWORD=app POSTGRES_DB=notes
Copy this to .env and adjust before running — .env itself stays out of git via .dockerignore and .gitignore both.
name: notes-api
services:
api:
build: .
image: notes-api:dev
ports:
- "3000:3000"
env_file: .env
depends_on:
db:
condition: service_healthy
networks:
- backend
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
interval: 5s
timeout: 3s
retries: 5
networks:
- backend
networks:
backend:
volumes:
db-data:cp .env.example .env docker compose up -d --build curl http://localhost:3000/health
name: Build and Push
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:latest
cache-from: type=gha
cache-to: type=gha,mode=maxVolumes, deeper
docker volume inspect app-data docker volume ls -f dangling=true docker volume rm app-data # read-only bind mount docker run -v $(pwd)/config:/etc/app:ro myapp
services:
db:
volumes:
- db-data:/var/lib/postgresql/data
volumes:
db-data:
driver: localdocker run --rm -v app-data:/data -v $(pwd):/backup alpine \ tar czf /backup/app-data-backup.tar.gz -C /data .
Networking, deeper
# custom bridge network — containers on it get name-based DNS, # unlike the default bridge docker network create app-net docker run -d --name db --network app-net postgres:16 docker run -d --name api --network app-net -e DB_HOST=db myapp # 'api' now reaches 'db' by hostname, no IP needed docker network connect app-net some-container docker network inspect app-net
bridge network only reach each other by IP. Create a custom network for name-based discovery — Compose does this automatically.Resource limits & restarts
docker run --memory=512m --cpus=1.5 --restart unless-stopped myapp
services:
api:
image: myapp
restart: unless-stopped
deploy:
resources:
limits:
cpus: "1.5"
memory: 512M
reservations:
memory: 256M| Restart policy | Behavior |
|---|---|
no | Never restart (default) |
on-failure[:N] | Restart only on non-zero exit, optional attempt cap |
always | Always restart, including after a manual stop and daemon restart |
unless-stopped | Always restart, except after a manual stop |
Logging drivers
The default json-file driver has no size cap by default — it can fill a disk over months of uptime.
services:
api:
image: myapp
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"docker logs -f --tail 100 mycontainer # other drivers: local, syslog, journald, gelf, fluentd, awslogs, splunk, none docker run --log-driver=syslog myapp
Buildx & multi-platform
BuildKit is the modern build engine — parallel layer builds, cache mounts, secret mounts — and is the default in current Docker. Buildx is its CLI front-end and it can build for architectures other than the one you're on, like producing arm64 images from an amd64 machine.
docker buildx create --name multi --use docker buildx inspect --bootstrap docker buildx build --platform linux/amd64,linux/arm64 \ -t myuser/myapp:1.0 --push .
# syntax=docker/dockerfile:1
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm cidocker buildx build --secret id=npmrc,src=$HOME/.npmrc -t myapp .
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ciDocker Swarm
Docker's built-in orchestrator, for clustering without Kubernetes' complexity. It reuses an existing compose.yaml almost as-is.
docker swarm init
docker swarm join-token worker # run the output on other machines
docker stack deploy -c compose.yaml mystack
docker stack services mystack
docker service scale mystack_api=4
docker stack rm mystackCompose vs Swarm vs Kubernetes — when to move on
Three tools that all "run your containers," aimed at different points on the same road. Most projects pass through this order roughly in sequence and plenty stop at step one forever — that's not a failure, it's usually the right call.
| Tool | Runs on | Reach for it when |
|---|---|---|
| Compose | One machine | Local dev, a single-server app, a small side project. No cluster, no failover — if the box dies, everything on it dies. |
| Swarm | A cluster of machines you manage | You've outgrown one server and want redundancy, but don't want to learn a large new system. Reuses your existing compose.yaml. |
| Kubernetes | A cluster, usually managed (EKS/GKE/AKS) | You need autoscaling, complex rollout strategies, a large ecosystem of operators/controllers or you're joining a team that already runs it. |
Staying on Compose is fine
If one server handles your traffic comfortably, added orchestration is added failure modes, not added safety.
Swarm is a good middle step
Multi-machine redundancy without learning a second config language — your compose.yaml mostly just works.
Kubernetes when the team needs it
Worth the overhead once you have multiple services, multiple environments and people whose job is operating the cluster.
Security
Don't run as root
Add USER appuser in the Dockerfile. A container compromise stays non-root.
Rootless Docker
Run the daemon itself as a non-root user — a daemon escape still can't reach host root.
Scan images
docker scout or Trivy catch known CVEs in base images and dependencies before you ship.
# non-root user, Debian-based RUN useradd -m appuser USER appuser # non-root user, Alpine-based RUN adduser -D appuser USER appuser # read-only root filesystem at runtime docker run --read-only --tmpfs /tmp myapp # drop all capabilities, add back only what's needed docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE myapp docker scout cves myapp:1.0 curl -fsSL https://get.docker.com/rootless | sh
services:
api:
image: myapp
secrets:
- db_password
secrets:
db_password:
file: ./secrets/db_password.txtInside the container the secret is mounted read-only at /run/secrets/db_password — never an env var, never in a build layer.
CI/CD — build & push
name: Build and Push
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:latest
cache-from: type=gha
cache-to: type=gha,mode=maxThe cache-from/cache-to: type=gha pair reuses layer cache between CI runs, so builds after the first are noticeably faster.
Debugging containers
docker inspect <container> # mounts, network, env, state docker stats # live CPU / memory / network docker top <container> # processes inside it docker diff <container> # filesystem changes vs the image docker events # live daemon event stream docker exec -it <container> sh # shell in — use sh if bash isn't installed # container won't start? check exit code + last logs docker ps -a docker logs <container> # debugging a container with no shell at all (distroless / scratch) docker run -it --rm --pid=container:<container> --net=container:<container> \ --cap-add SYS_PTRACE busybox sh
Common errors
| Error | Fix |
|---|---|
| Cannot connect to the Docker daemon |
Open Docker Desktop and wait for it to finish starting.
sudo systemctl start docker
|
| Permission denied on /var/run/docker.sock |
Not applicable — no user-group concept on Desktop installs.
Your user isn't in the docker group — see the Verify & configure page, then log out and back in.
|
| Port is already allocated | Something else owns that host port — docker ps to find it or change your -p mapping. |
| No space left on device | Disk full of old images or layers — see the Cleanup page. |
| Container exits immediately | Check docker logs — the main process finished or crashed. It needs to keep running in the foreground. |
| Build re-downloads everything every time | Layer order is invalidating the cache — see Build cache & layers. |
Cleanup & prune
docker system df # what's using disk space docker container prune # remove stopped containers docker image prune # remove dangling images docker image prune -a # remove all unused images docker volume prune # remove unused volumes — careful docker network prune # remove unused networks docker builder prune # clear build cache docker system prune -a --volumes # everything unused
--volumes deletes unused named volumes, including databases nobody's touched recently but still needs. Run docker system df -v first.Cheat sheet
Every command from this guide, organized and copyable.
Images
docker build -t name .docker imagesdocker pull image:tagdocker push image:tagdocker rmi imagedocker tag src dstdocker history imageContainers
docker run -d -p 8080:80 imgdocker ps -adocker stop iddocker rm iddocker exec -it id shdocker logs -f iddocker cp id:/p ./localVolumes & networks
docker volume create namedocker volume lsdocker network create netdocker network inspect netCompose
docker compose up -ddocker compose down -vdocker compose builddocker compose psdocker compose logs -f svcdocker compose exec svc shdocker compose configBuild (Buildx)
docker buildx create --usedocker buildx build --platform linux/amd64,linux/arm64 -t x --push .docker build --no-cache -t x .Swarm
docker swarm initdocker stack deploy -c f.yaml namedocker service scale s=4Debug & inspect
docker inspect iddocker statsdocker diff idCleanup
docker system dfdocker image prune -adocker system prune -a --volumesInstall — quick reference
winget install -e --id Docker.DockerDesktopbrew install --cask dockerwsl --installcurl -fsSL https://get.docker.com | shsudo usermod -aG docker $USERBest practices
Use slim/alpine base images
Smaller attack surface, faster pulls, less to patch.
One process per container
Compose multiple containers instead of running several processes in one.
Order layers by change frequency
Dependencies before source code, to maximize cache hits.
Don't run as root
USER in every Dockerfile unless there's a specific reason not to.
Pin versions
Explicit tags or digests, not :latest, for anything deployed.
Secrets at runtime
Never ENV or ARG for credentials — use secret mounts or Compose secrets:.
Add healthchecks
Let orchestrators know when a container is actually ready, not just started.
Prune on a schedule
Cron a docker system prune -af --filter "until=168h" on long-lived hosts.
.dockerignore everything unneeded
Faster builds, no accidental secrets in the image context.