Level 11 — Docker and Docker Compose
Docker packages an application together with its dependencies into an image that runs identically anywhere. This chapter teaches it from zero and — just as importantly — tells you when not to use it.
The core concepts
| Concept | What it is | Analogy |
|---|---|---|
| Image | An immutable, layered filesystem + metadata | A class |
| Container | A running instance of an image | An object |
| Dockerfile | The recipe that builds an image | The source file |
| Volume | Storage that outlives the container | A mounted disk |
| Network | A virtual network containers attach to | A switch |
| Registry | Where images are stored and shared | npm, for images |
What a container actually is
Not a virtual machine. A container is a normal Linux process with three kernel features applied:
| Kernel feature | Provides |
|---|---|
| Namespaces | Isolated view of PIDs, network, mounts, users, hostname |
| cgroups | Limits on CPU, memory, I/O |
| Union filesystem (overlay2) | Layered, copy-on-write image storage |
# SERVER — proof: a container process is visible on the host
docker run -d --name test nginx
ps aux | grep nginx # the nginx process is right there in the host's process listConsequences that matter:
- Containers share the host kernel. A kernel exploit escapes the container. Isolation is weaker than a VM's.
- Startup is milliseconds, not seconds — no OS to boot.
- Overhead is near zero — no hypervisor, no guest kernel.
- You cannot run a Linux container on a different kernel. Docker Desktop on macOS/Windows runs a hidden Linux VM.
IMAGE ARCHITECTURE MUST MATCH THE SERVER
Building on an Apple Silicon Mac produces linux/arm64 images. Running them on an x86 VPS fails with exec format error. Build for the target:
docker buildx build --platform linux/amd64 -t myapp:latest .Or build in CI on an x86 runner, which is what you should be doing anyway.
Installing Docker
Use Docker's official repository — Ubuntu's docker.io package lags well behind.
# SERVER
# 1. Remove any old versions
for pkg in docker.io docker-doc docker-compose podman-docker containerd runc; do
sudo apt remove -y $pkg 2>/dev/null || true
done
# 2. Add Docker's GPG key
sudo apt update
sudo apt 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
# 3. Add the repository
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
# 4. Install
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
# 5. Verify
sudo docker run --rm hello-world
docker compose versionRunning Docker without sudo
# SERVER
sudo usermod -aG docker deploy
newgrp docker # or log out and back in
docker ps # should work without sudoTHE docker GROUP IS EQUIVALENT TO ROOT
Any member can run:
docker run -v /:/host -it --rm alpine chroot /host shand is now root on the host, with full access to /etc/shadow, everyone's SSH keys, everything. This is documented upstream behaviour, not a vulnerability.
Only grant it to users you would already give unrestricted sudo. If you need genuinely unprivileged containers, use rootless Docker or Podman.
# SERVER
sudo systemctl enable --now docker
sudo systemctl status docker
docker infoImages
docker pull postgres:16-alpine # download
docker images # list local images
docker rmi postgres:16-alpine # remove
docker image prune # remove dangling (untagged) images
docker image prune -a # remove ALL unused images — frees a lot of disk
docker history myapp:latest # see the layers and their sizesTags — pin them
| Tag | Meaning | Production? |
|---|---|---|
postgres:latest | Whatever is newest right now | ❌ Never |
postgres:16 | Latest 16.x — gets patch updates | ⚠️ Acceptable |
postgres:16.4 | Exactly 16.4 | ✅ Good |
postgres:16.4-alpine | 16.4 on Alpine — much smaller | ✅ Good |
postgres@sha256:abc... | Exact image digest, immutable | ✅ Best for reproducibility |
:latest MAKES YOUR DEPLOYMENT NON-REPRODUCIBLE
Today postgres:latest is 17. In six months it is 18, and a rebuild silently upgrades your database across a major version — which requires a dump/restore and will fail to start on the existing data directory. You discover this at the worst possible moment.
Always pin. docker compose pull then becomes a deliberate, reviewable action.
ALPINE vs DEBIAN-SLIM FOR NODE
Alpine images are much smaller but use musl libc instead of glibc. Native Node modules with prebuilt binaries (sharp, bcrypt, better-sqlite3, Prisma's engines) often ship glibc-only builds and either fail or silently compile from source.
For Node applications, prefer node:22-slim (Debian-based, ~80 MB) over node:22-alpine unless you have verified every native dependency works. Prisma in particular needs openssl explicitly installed on Alpine.
Containers
docker run -d --name web -p 8080:80 nginx # run detached
docker ps # running containers
docker ps -a # including stopped
docker logs web # container output
docker logs -f --tail 100 web # follow last 100 lines
docker exec -it web bash # shell inside a running container
docker stop web # SIGTERM, then SIGKILL after 10s
docker start web
docker restart web
docker rm web # remove a stopped container
docker rm -f web # force-remove a running one
docker stats # live CPU/memory per container
docker inspect web # full JSON configuration
docker top web # processes insideKey docker run flags:
| Flag | Meaning |
|---|---|
-d | Detached (background) |
--name | Give it a stable name |
-p 127.0.0.1:5432:5432 | Publish a port — always prefix the bind address |
-v name:/path | Mount a named volume |
-e KEY=value | Environment variable |
--env-file .env | Load environment from a file |
--restart unless-stopped | Restart policy |
--rm | Delete the container when it exits |
--network mynet | Attach to a network |
-it | Interactive with a TTY |
A CONTAINER'S FILESYSTEM IS EPHEMERAL
docker rm deletes everything written inside the container that is not on a volume. Restarting is fine; removing loses the data. This is by design — containers are meant to be disposable — which is exactly why databases need volumes.
Volumes
docker volume create pgdata
docker volume ls
docker volume inspect pgdata
docker volume rm pgdata # ⚠️ deletes the data
docker volume prune # remove all unused volumes — ⚠️ carefulThree ways to persist data:
| Type | Syntax | Stored at | Use for |
|---|---|---|---|
| Named volume | pgdata:/var/lib/postgresql/data | /var/lib/docker/volumes/pgdata/_data | Databases — preferred |
| Bind mount | ./backups:/backups | Wherever you point it | Config files, backup output |
| tmpfs | --tmpfs /tmp | RAM only | Sensitive scratch data |
WHAT HAPPENS WHEN A CONTAINER IS DELETED
docker rm -f myapp-postgres # container gone; NAMED VOLUME SURVIVES
docker compose down # containers + network removed; volumes survive
docker compose down -v # ⚠️ VOLUMES DELETED — YOUR DATABASE IS GONE
docker volume rm pgdata # ⚠️ same
docker volume prune # ⚠️ removes every volume not attached to a container
docker system prune -a --volumes # ⚠️ removes basically everythingdocker compose down -v is the one that catches people. There is no confirmation prompt and no undo. Establish the habit: never type -v after down on a machine with production data.
Bind mounts have an ownership trap:
volumes:
- ./data:/var/lib/postgresql/data # host UID must match container UIDThe PostgreSQL container runs as UID 999. If ./data on the host is owned by UID 1000 (deploy), the container cannot write and fails to start. Named volumes avoid this entirely because Docker manages the ownership. Use named volumes for databases.
Networks
docker network create mynet
docker network ls
docker network inspect mynet
docker network connect mynet mycontainer| Driver | Behaviour |
|---|---|
bridge | Default. Private network on the host; containers reach each other by name. |
host | No isolation — container uses the host's network stack directly |
none | No networking at all |
overlay | Multi-host (Swarm/Kubernetes) |
CONTAINERS RESOLVE EACH OTHER BY SERVICE NAME
On a user-defined bridge network, Docker runs an embedded DNS server. A container can connect to postgres:5432 and Docker resolves it — no IP addresses, no links, no /etc/hosts editing.
This is why a containerised app's DATABASE_URL uses postgres as the host, while a host-based (PM2) app uses 127.0.0.1. Getting these two mixed up is a very common first-day Docker error.
# SERVER — test resolution from inside a container
docker compose exec api ping -c 2 postgres
docker compose exec api sh -c "nc -zv postgres 5432"network_mode: host DEFEATS PORT ISOLATION
With host networking, a container binding 6379 binds it on the host's 0.0.0.0 — public. It also bypasses Docker's port publishing entirely, so 127.0.0.1: prefixes have no effect. Avoid it unless you specifically need it.
Dockerfile
A production multi-stage Dockerfile for the NestJS backend:
# backend/Dockerfile
# ---- Stage 1: dependencies ----
FROM node:22-slim AS deps
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
# ---- Stage 2: build ----
FROM node:22-slim AS build
WORKDIR /app
RUN corepack enable
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm prisma generate
RUN pnpm build
RUN pnpm prune --prod
# ---- Stage 3: runtime ----
FROM node:22-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
# Install only what the runtime needs
RUN apt-get update && apt-get install -y --no-install-recommends \
openssl curl dumb-init \
&& rm -rf /var/lib/apt/lists/*
# Run as a non-root user
RUN groupadd -r nodejs && useradd -r -g nodejs nodejs
COPY --from=build --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=build --chown=nodejs:nodejs /app/dist ./dist
COPY --from=build --chown=nodejs:nodejs /app/prisma ./prisma
COPY --from=build --chown=nodejs:nodejs /app/package.json ./
USER nodejs
EXPOSE 3001
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
CMD curl -fsS http://127.0.0.1:3001/health || exit 1
# dumb-init is PID 1 and forwards signals correctly
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "dist/main.js"]Why each piece matters
| Instruction | Why |
|---|---|
| Multi-stage build | Devtools and build caches stay in earlier stages. Final image is ~200 MB instead of ~1.2 GB. |
COPY package.json pnpm-lock.yaml before COPY . . | Docker caches layers. Dependencies reinstall only when the lockfile changes, not on every source edit. This is the single biggest build-time win. |
USER nodejs | Containers run as root by default. A container escape then means host root. |
HEALTHCHECK | Compose and orchestrators use it to know when the service is genuinely ready |
dumb-init as ENTRYPOINT | See below |
--no-install-recommends + rm -rf /var/lib/apt/lists/* | Smaller image, fewer packages to have CVEs |
openssl | Prisma's query engine needs it |
PID 1 DOES NOT REAP ZOMBIES OR FORWARD SIGNALS
Node as PID 1 in a container does not receive SIGTERM the way you expect, so docker stop waits the full 10 seconds and then SIGKILLs — no graceful shutdown, in-flight requests dropped. It also does not reap zombie child processes.
Fix with dumb-init or tini as ENTRYPOINT (Docker's --init flag does the same). And use exec form (CMD ["node", "dist/main.js"]), never shell form (CMD node dist/main.js) — shell form wraps your process in /bin/sh, which swallows signals.
.dockerignore — do not skip this
node_modules
.git
.github
dist
.output
.nuxt
*.log
.env
.env.*
coverage
.vscode
.idea
README.md
Dockerfile
docker-compose*.ymlWITHOUT .dockerignore, YOUR SECRETS GO INTO THE IMAGE
COPY . . copies everything in the build context — including .env, .git (with its full history), and any private keys lying around. Anyone who can pull that image can extract them with docker history or by simply running it.
It also makes builds far slower by sending node_modules to the daemon.
Docker Compose
Compose defines a multi-container application in one file.
# /home/deploy/apps/myapp/docker-compose.yml
services:
postgres:
image: postgres:16.4-alpine
container_name: myapp-postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
PGUSER: ${POSTGRES_USER}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./backups:/backups
networks:
- backend
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
shm_size: 256mb
logging:
driver: json-file
options: { max-size: "10m", max-file: "3" }
redis:
image: redis:7.4-alpine
container_name: myapp-redis
restart: unless-stopped
command: >
redis-server
--requirepass ${REDIS_PASSWORD}
--appendonly yes
--appendfsync everysec
--maxmemory 512mb
--maxmemory-policy volatile-lru
volumes:
- redis_data:/data
networks:
- backend
healthcheck:
test: ["CMD", "redis-cli", "--no-auth-warning", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 10s
timeout: 3s
retries: 5
logging:
driver: json-file
options: { max-size: "10m", max-file: "3" }
api:
build:
context: ./backend
dockerfile: Dockerfile
image: myapp-api:${TAG:-latest}
container_name: myapp-api
restart: unless-stopped
env_file: .env
environment:
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_healthy }
ports:
- "127.0.0.1:3001:3001"
networks:
- backend
deploy:
resources:
limits: { cpus: "1.0", memory: 768M }
logging:
driver: json-file
options: { max-size: "10m", max-file: "5" }
volumes:
postgres_data:
redis_data:
networks:
backend:
driver: bridgeExplaining the key sections
ports: — HOST:CONTAINER. Always prefix with 127.0.0.1:, or omit entirely for services only other containers need. Note postgres and redis here have no ports: — they are reachable at postgres:5432 and redis:6379 inside the network and completely invisible from the host and internet. This is the ideal.
volumes: — SOURCE:TARGET. A bare name is a named volume; a path starting with . or / is a bind mount.
environment: vs env_file: — environment is inline (and appears in docker inspect); env_file reads from a file. Use env_file for secrets.
networks: — services on the same network resolve each other by service name.
restart:
| Policy | Behaviour |
|---|---|
no | Never restart (default) |
on-failure | Restart on non-zero exit |
always | Always restart, including after a manual docker stop + daemon restart |
unless-stopped | Restart unless you explicitly stopped it. Use this. |
unless-stopped respects your intent: if you stopped a container deliberately to debug, a server reboot will not silently bring it back.
depends_on with condition: service_healthy —
PLAIN depends_on ONLY WAITS FOR "STARTED", NOT "READY"
depends_on:
- postgres # ❌ starts the container, does not wait for PostgreSQL to accept connectionsYour API starts, immediately tries to connect, and crashes with ECONNREFUSED. With a restart policy it eventually succeeds after a few crash loops — which works but looks alarming in logs and slows every deploy.
condition: service_healthy waits for the healthcheck to pass. Requires a healthcheck: on the dependency.
logging: —
WITHOUT LOG LIMITS, DOCKER WILL FILL YOUR DISK
Default json-file logging is unbounded. A chatty container produces gigabytes, /var/lib/docker/containers/ fills the disk, and then PostgreSQL cannot write, Nginx cannot log, and nothing works.
Set limits per service as above, or globally in /etc/docker/daemon.json:
{
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" }
}Then sudo systemctl restart docker. Global settings apply only to newly created containers.
$${...} in healthchecks — Compose performs variable substitution on the YAML. $$ escapes it so the literal $ reaches the shell inside the container.
Compose commands
docker compose up -d # create and start everything
docker compose up -d --build # rebuild images first
docker compose down # stop and remove containers + network (volumes SAFE)
docker compose down -v # ⚠️ ALSO DELETES VOLUMES
docker compose ps # status
docker compose logs -f # follow all logs
docker compose logs -f --tail 100 api
docker compose exec api sh # shell in a running service
docker compose run --rm api pnpm prisma migrate deploy # one-off task
docker compose restart api
docker compose pull # fetch newer images
docker compose config # ⭐ render the fully-resolved config
docker compose topdocker compose config IS THE BEST DEBUGGING COMMAND
It prints the final YAML after variable substitution, .env loading, and override-file merging. If an environment variable is empty or a port binding is wrong, you will see it immediately — rather than guessing.
docker compose config | grep -A3 portsEnvironment file for Compose
Compose automatically reads .env in the same directory for ${VAR} substitution:
# .env — mode 600, gitignored
POSTGRES_USER=myapp
POSTGRES_PASSWORD=xxxxx
POSTGRES_DB=myapp_production
REDIS_PASSWORD=yyyyy
TAG=v1.4.2TWO DIFFERENT .env USES
Compose's .env fills ${VAR} in the YAML file itself. A service's env_file: sets variables inside the container. They can be the same file, but understand which mechanism you are relying on — ${VAR} in docker-compose.yml does not reach the container unless you also pass it via environment: or env_file:.
When to use Docker — and when not to
Use Docker for PostgreSQL/Redis when
- You run multiple projects needing different major versions
- Your development environment is already Compose-based and parity matters
- You want the whole stack defined declaratively in Git
- You are moving toward Kubernetes later
Prefer native for PostgreSQL/Redis when
- Single server, single project — this is most people
- You want systemd to manage lifecycle and boot ordering
- You want
pg_dumpandpsqldirectly onPATH - You want data in a conventional path your backup tooling already handles
- You want zero risk of
docker compose down -v
Use Docker for the application when
- Your build has awkward system dependencies (ImageMagick, Puppeteer, Python tooling)
- You want CI to produce one immutable artifact deployed everywhere
- You want instant rollback by re-tagging an image
- Multiple apps on one host need different Node versions
Prefer PM2 for the application when
- It is a plain Node app with no exotic system dependencies
- You want the simplest possible deploy (
git pull && pnpm build && pm2 reload) - You want PM2's zero-downtime cluster reload without an orchestrator
- Your team is small and Docker is one more thing to learn
THE PRAGMATIC RECOMMENDATION FOR THIS STACK
Native PostgreSQL + native Redis + PM2 for the Node apps + Nginx on the host.
This is what Level 25 builds. It has the fewest moving parts, the clearest failure modes, and everything is managed by systemd.
Adopt Docker when you have a concrete reason — a second project with conflicting versions, a build needing system libraries, or a genuine need for immutable artifacts and instant rollback. Do not adopt it because it feels more professional; a Compose file you do not fully understand is a liability during an incident.
A reasonable middle ground many people land on: Docker for stateful services (PostgreSQL/Redis) with named volumes, PM2 for the Node apps. You get pinned database versions and easy local parity, while keeping the fast PM2 reload loop for application code.
DO NOT RUN TWO POSTGRESQL INSTANCES BY ACCIDENT
If you apt install postgresql and also run a Compose PostgreSQL publishing 5432, the second fails to bind — or worse, you connect to the wrong one and wonder why your data is missing. Check before adding:
sudo ss -tulpn | grep -E '5432|6379'
systemctl is-active postgresql redis-serverDocker security
# SERVER — audit exposure
docker ps --format "table {{.Names}}\t{{.Ports}}\t{{.Status}}"| Practice | Why |
|---|---|
Bind published ports to 127.0.0.1 | Docker bypasses UFW (Level 5) |
USER nonroot in every Dockerfile | Containers are root by default; an escape becomes host root |
| Pin image tags/digests | Reproducibility and no surprise major upgrades |
| Never bake secrets into images | docker history reveals build args and layer contents |
--read-only filesystem where possible | Blocks an attacker writing a payload |
--cap-drop=ALL | Drop Linux capabilities the app does not need |
no-new-privileges | Prevents setuid escalation inside the container |
| Resource limits | One container cannot OOM the whole host |
Never mount /var/run/docker.sock | That is handing over root on the host |
| Scan images | docker scout cves myapp:latest or Trivy |
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
read_only: true
tmpfs:
- /tmpMOUNTING THE DOCKER SOCKET IS ROOT ACCESS
volumes:
- /var/run/docker.sock:/var/run/docker.sock # ❌Anything inside that container can start a privileged container mounting the host filesystem. Watchtower, some CI runners, and various "convenience" images ask for this. Understand that you are granting host root when you do.
Disk management
Docker accumulates images, containers, volumes, and build cache relentlessly.
docker system df # what is using space
docker system df -v # detailed breakdown# Safe cleanup
docker container prune # remove stopped containers
docker image prune # remove dangling images
docker builder prune # remove build cache — often the biggest win
# Aggressive — read carefully
docker image prune -a # remove ALL images not used by a container
docker system prune -a # containers + images + networks + build cache
docker system prune -a --volumes # ⚠️ ALSO DELETES VOLUMES — never on a prod DB hostdocker system prune -a --volumes DELETES YOUR DATABASE
It removes every volume not currently attached to a running container. If your PostgreSQL container happens to be stopped at that moment, the data goes with it.
Safe automated cleanup, with an age filter and no volumes:
# /etc/cron.weekly/docker-cleanup
docker image prune -af --filter "until=168h"
docker builder prune -af --filter "until=168h"
docker container prune -f --filter "until=168h"Troubleshooting
| Problem | Cause | Diagnose | Fix |
|---|---|---|---|
permission denied /var/run/docker.sock | Not in the docker group, or group not applied | groups | sudo usermod -aG docker $USER; log out/in |
port is already allocated | Another process or container has it | sudo ss -tulpn | grep <port> | Stop the other, or change the mapping |
| Container exits immediately | Main process crashed or completed | docker logs <name> | Read the logs — the answer is there |
exec format error | Architecture mismatch (arm64 image on amd64) | docker inspect <img> | grep Arch | Rebuild with --platform linux/amd64 |
API cannot reach postgres | Not on the same network, or wrong hostname | docker compose exec api ping postgres | Same networks:; use the service name |
Data lost after down | -v was used, or no volume defined | docker volume ls | Restore from backup; add a named volume |
| Disk full | Images, logs, build cache | docker system df | Prune; add logging limits |
| Changes not appearing | Old image still in use | docker compose images | docker compose up -d --build |
no space left on device during build | Build cache | docker builder prune | Prune |
| Healthcheck always failing | Wrong URL, or curl not in the image | docker inspect <c> and read .State.Health | Install curl, or use a Node one-liner |
docker stop takes 10s every time | Signals not reaching the app | — | Use dumb-init + exec-form CMD |
| Container OOM-killed | Memory limit or host RAM | docker inspect <c> | grep OOMKilled | Raise limits, or fix the leak |
# The general debugging sequence
docker compose ps
docker compose logs --tail 100 <service>
docker compose config
docker compose exec <service> sh
docker inspect <container> | less
docker events --since 10m # what has Docker been doing?Production Checklist — Level 11
- [ ] Docker installed from the official repository,
systemctl enabled - [ ] Only trusted users are in the
dockergroup (understanding it equals root) - [ ] Every image tag is pinned — no
:latestanywhere - [ ] Every published port is prefixed
127.0.0.1:, or not published at all - [ ] Verified port bindings with
docker psand its--formatoutput - [ ] Databases use named volumes, not bind mounts
- [ ]
.dockerignoreexcludes.env,.git,node_modules - [ ] Dockerfiles use multi-stage builds and end with
USER nonroot - [ ]
dumb-init/tinias ENTRYPOINT; exec-formCMD - [ ]
HEALTHCHECKdefined;depends_onusescondition: service_healthy - [ ]
restart: unless-stoppedon every long-running service - [ ] Log rotation configured (
max-size,max-file) globally or per service - [ ] Memory/CPU limits set so one container cannot take down the host
- [ ] No container mounts
/var/run/docker.sock - [ ] Secrets come from
env_file, never baked into images - [ ] Weekly cleanup cron without
--volumes - [ ] I know that
docker compose down -vdestroys the database - [ ] Volume backup strategy in place (Level 22)