Skip to content

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 ​

ConceptWhat it isAnalogy
ImageAn immutable, layered filesystem + metadataA class
ContainerA running instance of an imageAn object
DockerfileThe recipe that builds an imageThe source file
VolumeStorage that outlives the containerA mounted disk
NetworkA virtual network containers attach toA switch
RegistryWhere images are stored and sharednpm, for images

What a container actually is ​

Not a virtual machine. A container is a normal Linux process with three kernel features applied:

Kernel featureProvides
NamespacesIsolated view of PIDs, network, mounts, users, hostname
cgroupsLimits on CPU, memory, I/O
Union filesystem (overlay2)Layered, copy-on-write image storage
bash
# 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 list

Consequences 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:

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

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

Running Docker without sudo ​

bash
# SERVER
sudo usermod -aG docker deploy
newgrp docker        # or log out and back in
docker ps            # should work without sudo

THE docker GROUP IS EQUIVALENT TO ROOT

Any member can run:

bash
docker run -v /:/host -it --rm alpine chroot /host sh

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

bash
# SERVER
sudo systemctl enable --now docker
sudo systemctl status docker
docker info

Images ​

bash
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 sizes

Tags — pin them ​

TagMeaningProduction?
postgres:latestWhatever is newest right now❌ Never
postgres:16Latest 16.x — gets patch updates⚠️ Acceptable
postgres:16.4Exactly 16.4✅ Good
postgres:16.4-alpine16.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 ​

bash
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 inside

Key docker run flags:

FlagMeaning
-dDetached (background)
--nameGive it a stable name
-p 127.0.0.1:5432:5432Publish a port — always prefix the bind address
-v name:/pathMount a named volume
-e KEY=valueEnvironment variable
--env-file .envLoad environment from a file
--restart unless-stoppedRestart policy
--rmDelete the container when it exits
--network mynetAttach to a network
-itInteractive 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 ​

bash
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 — ⚠️ careful

Three ways to persist data:

TypeSyntaxStored atUse for
Named volumepgdata:/var/lib/postgresql/data/var/lib/docker/volumes/pgdata/_dataDatabases — preferred
Bind mount./backups:/backupsWherever you point itConfig files, backup output
tmpfs--tmpfs /tmpRAM onlySensitive scratch data

WHAT HAPPENS WHEN A CONTAINER IS DELETED

bash
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 everything

docker 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:

yaml
volumes:
  - ./data:/var/lib/postgresql/data     # host UID must match container UID

The 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 ​

bash
docker network create mynet
docker network ls
docker network inspect mynet
docker network connect mynet mycontainer
DriverBehaviour
bridgeDefault. Private network on the host; containers reach each other by name.
hostNo isolation — container uses the host's network stack directly
noneNo networking at all
overlayMulti-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.

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

dockerfile
# 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 ​

InstructionWhy
Multi-stage buildDevtools 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 nodejsContainers run as root by default. A container escape then means host root.
HEALTHCHECKCompose and orchestrators use it to know when the service is genuinely ready
dumb-init as ENTRYPOINTSee below
--no-install-recommends + rm -rf /var/lib/apt/lists/*Smaller image, fewer packages to have CVEs
opensslPrisma'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*.yml

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

yaml
# /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: bridge

Explaining 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:

PolicyBehaviour
noNever restart (default)
on-failureRestart on non-zero exit
alwaysAlways restart, including after a manual docker stop + daemon restart
unless-stoppedRestart 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"

yaml
depends_on:
  - postgres        # ❌ starts the container, does not wait for PostgreSQL to accept connections

Your 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:

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 ​

bash
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 top

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

bash
docker compose config | grep -A3 ports

Environment file for Compose ​

Compose automatically reads .env in the same directory for ${VAR} substitution:

bash
# .env — mode 600, gitignored
POSTGRES_USER=myapp
POSTGRES_PASSWORD=xxxxx
POSTGRES_DB=myapp_production
REDIS_PASSWORD=yyyyy
TAG=v1.4.2

TWO 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_dump and psql directly on PATH
  • 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:

bash
sudo ss -tulpn | grep -E '5432|6379'
systemctl is-active postgresql redis-server

Docker security ​

bash
# SERVER — audit exposure
docker ps --format "table {{.Names}}\t{{.Ports}}\t{{.Status}}"
PracticeWhy
Bind published ports to 127.0.0.1Docker bypasses UFW (Level 5)
USER nonroot in every DockerfileContainers are root by default; an escape becomes host root
Pin image tags/digestsReproducibility and no surprise major upgrades
Never bake secrets into imagesdocker history reveals build args and layer contents
--read-only filesystem where possibleBlocks an attacker writing a payload
--cap-drop=ALLDrop Linux capabilities the app does not need
no-new-privilegesPrevents setuid escalation inside the container
Resource limitsOne container cannot OOM the whole host
Never mount /var/run/docker.sockThat is handing over root on the host
Scan imagesdocker scout cves myapp:latest or Trivy
yaml
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    read_only: true
    tmpfs:
      - /tmp

MOUNTING THE DOCKER SOCKET IS ROOT ACCESS

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

bash
docker system df                  # what is using space
docker system df -v               # detailed breakdown
bash
# 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 host

docker 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:

bash
# /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 ​

ProblemCauseDiagnoseFix
permission denied /var/run/docker.sockNot in the docker group, or group not appliedgroupssudo usermod -aG docker $USER; log out/in
port is already allocatedAnother process or container has itsudo ss -tulpn | grep <port>Stop the other, or change the mapping
Container exits immediatelyMain process crashed or completeddocker logs <name>Read the logs — the answer is there
exec format errorArchitecture mismatch (arm64 image on amd64)docker inspect <img> | grep ArchRebuild with --platform linux/amd64
API cannot reach postgresNot on the same network, or wrong hostnamedocker compose exec api ping postgresSame networks:; use the service name
Data lost after down-v was used, or no volume defineddocker volume lsRestore from backup; add a named volume
Disk fullImages, logs, build cachedocker system dfPrune; add logging limits
Changes not appearingOld image still in usedocker compose imagesdocker compose up -d --build
no space left on device during buildBuild cachedocker builder prunePrune
Healthcheck always failingWrong URL, or curl not in the imagedocker inspect <c> and read .State.HealthInstall curl, or use a Node one-liner
docker stop takes 10s every timeSignals not reaching the app—Use dumb-init + exec-form CMD
Container OOM-killedMemory limit or host RAMdocker inspect <c> | grep OOMKilledRaise limits, or fix the leak
bash
# 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 docker group (understanding it equals root)
  • [ ] Every image tag is pinned — no :latest anywhere
  • [ ] Every published port is prefixed 127.0.0.1:, or not published at all
  • [ ] Verified port bindings with docker ps and its --format output
  • [ ] Databases use named volumes, not bind mounts
  • [ ] .dockerignore excludes .env, .git, node_modules
  • [ ] Dockerfiles use multi-stage builds and end with USER nonroot
  • [ ] dumb-init/tini as ENTRYPOINT; exec-form CMD
  • [ ] HEALTHCHECK defined; depends_on uses condition: service_healthy
  • [ ] restart: unless-stopped on 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 -v destroys the database
  • [ ] Volume backup strategy in place (Level 22)

Next: Level 12 — Building the Application →