docker & composeguide
Start / Overview
Start here

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.

build run Dockerfile Image (layers) Containers
Jump to:

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.

Why is installation split by OS instead of one script?

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.

About this guide

Put together by Atharv Yadav. If this was useful, there's more documentation and tool walkthroughs in the same style over at the links below.

Next: What Compose adds →
Start

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.

compose.yaml
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.

NamingThe old standalone 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.
Next: How it fits together →
Start

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 vs virtual machine
ContainerVirtual machine
Shares host kernelYesNo — runs its own
Startup timeUnder a second, usuallyTens of seconds or more
Typical sizeMegabytesGigabytes
Isolation mechanismKernel namespaces & cgroupsHardware-level virtualization
Layers

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?

Next: Requirements →
Install

Requirements

Same table regardless of the toggle above — check this once, then move to Install.

PlatformMinimumNotes
WindowsWindows 10 64-bit build 19041+ or Windows 11WSL2 backend is the current recommendation over Hyper-V
macOSmacOS 12+, Intel or Apple SiliconDocker Desktop, same as Windows conceptually — no WSL step
Linux64-bit kernel 3.10+Native install, no VM layer, generally best performance
WSLWSL2, same Windows build as aboveTwo install paths — covered on the Install page
VirtualizationIntel VT-x or AMD-V needs to be enabled in BIOS/UEFI for Docker Desktop or WSL2 to start, regardless of platform.
Next: Install Docker →
Install

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.

  1. Install WSL2 first, from an admin PowerShell:
    wsl --install
    Restart when prompted.
  2. Install Docker Desktop, either from docker.com or with winget:
    winget install -e --id Docker.DockerDesktop
  3. Run the installer, keeping "Use WSL 2 instead of Hyper-V" checked.
  4. Launch Docker Desktop and wait for the whale icon in the system tray to settle — that means the daemon is up.
  5. Verify from PowerShell or Windows Terminal:
    docker --version
    docker compose version
    docker run hello-world
Silent / scripted install
"Docker Desktop Installer.exe" install --quiet --accept-license
If you'd rather not use Docker Desktop
OptionWhat it is
Rancher DesktopFree, open-source alternative — container runtime plus optional Kubernetes, also runs on WSL2
Podman DesktopDaemonless, rootless engine with a Docker-compatible CLI
WSL, native installSwitch the toggle above to WSL — skip Docker Desktop entirely
LicensingDocker Desktop is free for personal use, education and small businesses. Larger companies need a paid subscription. A native install inside WSL sidesteps this and is fully open source.

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.

  1. 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
  2. Or install with Homebrew, if you use it:
    brew install --cask docker
  3. Drag Docker to Applications, then open it once from Launchpad or Spotlight and accept the permissions prompts.
  4. Wait for the whale icon in the menu bar to stop animating — that means the daemon is up.
  5. Verify from Terminal:
    docker --version
    docker compose version
    docker run hello-world
If you'd rather not use Docker Desktop
OptionWhat it is
ColimaLightweight, open-source Docker runtime for macOS — CLI-only, runs in a Lima VM, no GUI or license question
Rancher DesktopFree, open-source alternative with an optional Kubernetes cluster
Podman DesktopDaemonless, rootless engine with a Docker-compatible CLI
bash — Colima
brew install colima docker docker-compose
colima start
docker run hello-world
LicensingSame as Windows — Docker Desktop is free for personal use, education and small businesses. Larger companies need a paid subscription. Colima sidesteps this and is fully open source.
Apple Silicon and imagesMost official images publish both 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.

bash
# 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.

bash
# 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.

bash
sudo pacman -Syu --needed docker docker-compose docker-buildx
sudo systemctl enable --now docker.service
bash
sudo zypper install -y docker docker-compose docker-buildx
sudo systemctl enable --now docker
sh
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
OpenRC, not systemdsystemctl 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.

  1. Install Docker Desktop on Windows.
  2. Open Docker Desktop → Settings → Resources → WSL Integration.
  3. Turn on integration for your distro (Ubuntu, Debian, whichever you use).
  4. Open your WSL distro — docker and docker compose already work, no install step needed:
    docker --version
    docker compose version
Trade-offOne shared daemon across Windows and every WSL distro — convenient, but it means installing and keeping Docker Desktop around on the Windows side.

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.

1. Enable systemd

WSL doesn't run systemd by default and Docker's service management expects it. Edit /etc/wsl.conf inside the distro:

/etc/wsl.conf
sudo tee /etc/wsl.conf <<'EOF'
[boot]
systemd=true
EOF

Then from PowerShell: wsl --shutdown and reopen the distro.

2. Install Docker Engine (Ubuntu/Debian-based WSL distros)
bash
# 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.

3. Start it and verify
sudo systemctl enable --now docker
docker run hello-world
Nothing on the Windows sideThis is the whole install. No Docker Desktop, no tray icon, no Windows-side process — Docker only exists inside this WSL distro, same as it would on a bare Linux server.
Keep project files inside WSLStore your code under ~/projects, not /mnt/c/.... Crossing between the Windows and Linux filesystems is noticeably slower and will make bind-mounted dev containers feel sluggish.
iptables error on some WSL kernelsIf the daemon won't start because of an iptables error, switch to the legacy backend:
sudo update-alternatives --set iptables /usr/sbin/iptables-legacy
sudo update-alternatives --set ip6tables /usr/sbin/ip6tables-legacy
sudo service docker restart
Next: Verify & configure →
Install

Verify & configure

Same idea everywhere: confirm Docker actually works, then make it convenient to use daily.

Confirm it's running
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.

Run docker without sudo
sudo groupadd docker 2>/dev/null   # usually already exists
sudo usermod -aG docker $USER
newgrp docker                       # or log out and back in
Start on boot
sudo systemctl enable docker.service
sudo systemctl enable containerd.service
Confirm it works
docker run hello-world
docker info
docker system df
Confirm it's running
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.

Keep project files on the Mac sideIf you're bind-mounting into a container, avoid deeply nested iCloud-synced folders (like Desktop or Documents when iCloud Drive is on) — the sync layer can make file-watching and hot-reload flaky. A plain folder under your home directory works best.

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
Docker doesn't survive a WSL shutdown by itselfIf you run 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.
Next: Images vs containers →
Core concepts

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
One image, many containersThe same image can be run any number of times — each run is an independent container with its own writable layer. None of them affect each other or the underlying image.
Next: CLI essentials →
Core concepts

CLI essentials

Twelve commands cover most day-to-day work.

CommandDoes
docker run <img>Create and start a container
docker ps [-a]List running (or all) containers
docker exec -it <c> bashShell 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 imagesList 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 ./localCopy files in or out
Common run flags
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?

Next: Volumes & bind mounts →
Core concepts

Volumes & bind mounts

Containers are ephemeral — delete one and its writable layer is gone. Data that needs to survive gets attached from outside.

TypeManaged byBest for
Named volumeDockerDatabases, anything Docker should own
Bind mountYou, any host pathLocal dev — live-edit source code
tmpfs mountRAM onlySecrets, 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
Next: Networking basics →
Core concepts

Networking basics

HOST MACHINE bridge network web db -p 8080:80 you :8080 web and db resolve each other by name over the bridge
DriverBehavior
bridge (default)Private network on the host; publish ports to reach containers from outside
hostContainer shares the host's network stack directly — works on Linux/WSL only; Desktop on this OS doesn't support it the same way
noneNo networking
overlayMulti-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
Order matters-p HOST:CONTAINER. -p 8080:80 means "reach container port 80 via host port 8080" — easy to flip by accident.
Next: Env vars & .env →
Core concepts

Env vars & .env

# inline
docker run -e NODE_ENV=production -e PORT=3000 myapp

# from a file
docker run --env-file .env myapp
.env
NODE_ENV=production
PORT=3000
DATABASE_URL=postgres://user:pass@db:5432/app
Don't bake secrets into imagesENV in a Dockerfile gets baked into every layer and stays visible via docker history. Pass secrets at runtime instead.
Next: Dockerfile anatomy →
Building images

Dockerfile anatomy

Dockerfile
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
InstructionPurpose
FROMBase image
WORKDIRWorking directory for what follows
COPY / ADDCopy files in — prefer COPY, ADD has surprising auto-extract/URL behavior
RUNExecute a command at build time, creates a layer
ENVEnvironment variable, persists into the container
ARGBuild-time-only variable, not in the final image
EXPOSEInformational — documents the listening port
USERSwitch to a non-root user
ENTRYPOINTFixed executable that always runs
CMDDefault arguments, overridable at run time
HEALTHCHECKCommand Docker runs periodically to judge health
docker build -t myapp:1.0 .
docker build --build-arg VERSION=1.0 -t myapp .
Next: .dockerignore →
Building images

.dockerignore

Everything in the build context is sent to the daemon before the build starts. Exclude what the image doesn't need.

.dockerignore
node_modules
.git
.env
*.log
dist
.vscode
Dockerfile
README.md
**/__pycache__
Next: Build cache & layers →
Building images

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.

FROM node:20 — cached COPY package.json — cached COPY . . — changed ⚡ everything below rebuilds One changed layer invalidates everything stacked after it — reordering so source code copies last keeps the expensive install step cached far more often.

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 .
Next: Multi-stage builds →
Building images

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.

STAGE 1 — builder (~1GB) go compiler + source /out/server (12MB binary) COPY --from=builder STAGE 2 — runtime (~13MB) alpine + server binary only
Dockerfile
# 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"]
Typical impactA Go binary built this way can ship under 15MB instead of the 1GB+ you'd get shipping the full golang build image. Same idea applies to Node, Java and Python builds.
Next: Tagging & registries →
Building images

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
Avoid bare :latest in productionIt's a moving target. Pin explicit versions or digests for anything deployed.
Next: compose.yaml anatomy →
Docker Compose

compose.yaml anatomy

compose.yaml
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 keyPurpose
servicesEach container to run — the name doubles as its DNS hostname on the network
networksCustom networks; same-network services resolve each other by name
volumesNamed volumes, persisted across up/down
secretsFile-based secret injection
Next: Compose commands →
Docker Compose

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
Next: Env files & overrides →
Docker Compose

Env files & overrides

Compose auto-merges compose.yaml with compose.override.yaml when present — a shared base file plus a local dev-only override.

compose.override.yaml
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.

Next: Profiles →
Docker Compose

Profiles

Tag services with profiles to make them opt-in — useful for debug tools or admin UIs you don't want running by default.

compose.yaml
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
Next: Healthchecks & depends_on →
Docker Compose

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.

compose.yaml
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-alpine
Next: Worked example →
Docker Compose

Worked 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.

7 files, ready to docker compose up
Project layout
notes-api/
├── package.json
├── src/
│   └── server.js
├── Dockerfile
├── .dockerignore
├── .env.example
├── compose.yaml
└── .github/workflows/docker.yml
package.json
package.json
{
  "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"
  }
}
src/server.js
src/server.js
// 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}`));
Dockerfile
Dockerfile
# 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.

.dockerignore
.dockerignore
node_modules
.git
.env
*.log
.github
.env.example
.env.example
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.

compose.yaml
compose.yaml
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
CI — build & push on merge to main
.github/workflows/docker.yml
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=max
Why this shapeThe Dockerfile mirrors the multi-stage lesson, compose.yaml mirrors the anatomy and healthcheck lessons and the workflow mirrors the CI/CD page — same patterns, just wired together into one thing you could actually deploy. The zip download pulls these exact code blocks, so the file and the page can't drift apart.
Next: Volumes, deeper →
Intermediate

Volumes, 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
compose.yaml
services:
  db:
    volumes:
      - db-data:/var/lib/postgresql/data
volumes:
  db-data:
    driver: local
Backing up a named volume
docker run --rm -v app-data:/data -v $(pwd):/backup alpine \
  tar czf /backup/app-data-backup.tar.gz -C /data .
Next: Networking, deeper →
Intermediate

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
Default bridge has no DNSContainers on Docker's default bridge network only reach each other by IP. Create a custom network for name-based discovery — Compose does this automatically.
Next: Resource limits & restarts →
Intermediate

Resource limits & restarts

docker run --memory=512m --cpus=1.5 --restart unless-stopped myapp
compose.yaml
services:
  api:
    image: myapp
    restart: unless-stopped
    deploy:
      resources:
        limits:
          cpus: "1.5"
          memory: 512M
        reservations:
          memory: 256M
Restart policyBehavior
noNever restart (default)
on-failure[:N]Restart only on non-zero exit, optional attempt cap
alwaysAlways restart, including after a manual stop and daemon restart
unless-stoppedAlways restart, except after a manual stop
Next: Logging drivers →
Intermediate

Logging drivers

The default json-file driver has no size cap by default — it can fill a disk over months of uptime.

compose.yaml
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
Next: Buildx & multi-platform →
Advanced

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 .
Cache mounts
Dockerfile
# syntax=docker/dockerfile:1
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci
Secret mounts
docker buildx build --secret id=npmrc,src=$HOME/.npmrc -t myapp .
Dockerfile
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci
Next: Docker Swarm →
Advanced

Docker 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 mystack
Swarm vs KubernetesSwarm is simpler to operate and ships free with Docker Engine — a reasonable fit for small to mid-size clusters. Kubernetes has a much larger ecosystem and is the more common choice at scale. More on that on the next page.
Next: Compose vs Swarm vs K8s →
Advanced

Compose 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.

ToolRuns onReach for it when
ComposeOne machineLocal dev, a single-server app, a small side project. No cluster, no failover — if the box dies, everything on it dies.
SwarmA cluster of machines you manageYou've outgrown one server and want redundancy, but don't want to learn a large new system. Reuses your existing compose.yaml.
KubernetesA 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.
The honest versionKubernetes is not "Docker Compose but bigger" — it's a materially larger system to operate, with its own vocabulary (pods, services, ingresses, operators) on top of what you've already learned here. Don't reach for it because it's popular; reach for it when Compose or Swarm are visibly not enough for what you're running. A huge number of real production services never need to leave Compose.

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.

Next: Security →
Advanced

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
Compose secrets — file-based, never in env or logs
compose.yaml
services:
  api:
    image: myapp
    secrets:
      - db_password

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

Inside the container the secret is mounted read-only at /run/secrets/db_password — never an env var, never in a build layer.

Next: CI/CD →
Advanced

CI/CD — build & push

.github/workflows/docker.yml
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=max

The cache-from/cache-to: type=gha pair reuses layer cache between CI runs, so builds after the first are noticeably faster.

Next: Debugging containers →
Advanced

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
Next: Common errors →
Troubleshoot

Common errors

ErrorFix
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 allocatedSomething else owns that host port — docker ps to find it or change your -p mapping.
No space left on deviceDisk full of old images or layers — see the Cleanup page.
Container exits immediatelyCheck docker logs — the main process finished or crashed. It needs to keep running in the foreground.
Build re-downloads everything every timeLayer order is invalidating the cache — see Build cache & layers.
Next: Cleanup & prune →
Troubleshoot

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
Check what you're removing--volumes deletes unused named volumes, including databases nobody's touched recently but still needs. Run docker system df -v first.
Next: Cheat sheet →
Reference

Cheat sheet

Every command from this guide, organized and copyable.

Images

docker build -t name .
docker images
docker pull image:tag
docker push image:tag
docker rmi image
docker tag src dst
docker history image

Containers

docker run -d -p 8080:80 img
docker ps -a
docker stop id
docker rm id
docker exec -it id sh
docker logs -f id
docker cp id:/p ./local

Volumes & networks

docker volume create name
docker volume ls
docker network create net
docker network inspect net

Compose

docker compose up -d
docker compose down -v
docker compose build
docker compose ps
docker compose logs -f svc
docker compose exec svc sh
docker compose config

Build (Buildx)

docker buildx create --use
docker buildx build --platform linux/amd64,linux/arm64 -t x --push .
docker build --no-cache -t x .

Swarm

docker swarm init
docker stack deploy -c f.yaml name
docker service scale s=4

Debug & inspect

docker inspect id
docker stats
docker diff id

Cleanup

docker system df
docker image prune -a
docker system prune -a --volumes

Install — quick reference

winget install -e --id Docker.DockerDesktop
brew install --cask docker
wsl --install
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
Next: Best practices →
Reference

Best 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.

esc