Docker Compose and Production Habits: Healthchecks, Logs and Scanning
DevOpsLesson 4 of 45 min read
What you'll learn
- Describe a multi-service application in a compose.yaml file and run it with one command
- Order service start-up with healthchecks and depends_on conditions
- Cap log growth and set a restart policy
- Shrink images and scan them for known vulnerabilities as part of your workflow
On this page
Last lesson ended with a pile of docker run flags. Six months later nobody remembers which network the cache joined or which port was published, and the new hire's setup guide is a wiki page that is wrong in three places. Docker Compose replaces that wiki page with a file you can read, review and version. We will use a metro-card top-up service: a web API, a Postgres database and a Redis cache.
A compose file for three services
services:
api:
build: .
image: metro-topup-api:dev
ports:
- "8080:3000"
environment:
DATABASE_URL: "postgres://topup:${DB_PASSWORD:?set DB_PASSWORD in .env}@db:5432/topup"
REDIS_URL: "redis://cache:6379"
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
interval: 15s
timeout: 3s
retries: 3
start_period: 10s
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: topup
POSTGRES_PASSWORD: "${DB_PASSWORD:?set DB_PASSWORD in .env}"
POSTGRES_DB: topup
volumes:
- topup-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U topup -d topup"]
interval: 5s
timeout: 3s
retries: 10
restart: unless-stopped
cache:
image: redis:7-alpine
restart: unless-stopped
volumes:
topup-data:Read it top to bottom. Each key under services is a service name, and that name is also its DNS hostname, so the API reaches Postgres at db and Redis at cache with no network configuration at all, because Compose creates a shared network per project automatically. Only api publishes a port; the database and cache are reachable from inside the project and nowhere else. The ${DB_PASSWORD:?...} syntax reads a variable from your shell or from a .env file beside the compose file, and aborts with your message if it is missing, which beats starting a database with an empty password.
# .env (next to compose.yaml, ignored by git)
# DB_PASSWORD=change-me-local-only
docker compose up -d --build
docker compose ps
docker compose logs -f api
docker compose downOutput
NAME SERVICE STATUS PORTS metro-topup-api-1 api Up 40 seconds (healthy) 0.0.0.0:8080->3000/tcp metro-topup-cache-1 cache Up 45 seconds 6379/tcp metro-topup-db-1 db Up 45 seconds (healthy) 5432/tcp
Names and timings in that table are illustrative and depend on your project directory name and Docker version. Note that docker compose down stops and removes containers and the network but keeps named volumes. Adding -v deletes the volumes too, which means the database contents. Use it only when you really want a clean slate.
Why healthchecks change start-up order
By default depends_on only waits for the other container to be started, not ready. Postgres takes a moment to initialise after its process launches, so an API that connects instantly crashes with connection refused. A healthcheck is a command Docker runs repeatedly inside the container; its exit code moves the container between starting, healthy and unhealthy. With condition: service_healthy, Compose holds the API back until Postgres reports healthy. The same mechanism feeds docker ps output and, on platforms that use it, restart decisions.
Habits that separate a demo from something you can run
| Habit | How | Why it matters |
|---|---|---|
| Cap log size | logging options max-size and max-file | The default json-file driver keeps logs forever, and a chatty service can fill the disk |
| Restart policy | restart: unless-stopped | Brings services back after a crash or a host reboot without masking a stopped-on-purpose container |
| Resource limits | Memory and CPU limits under the service's deploy.resources or the mem_limit key | A leak in one container should not starve the rest of the host |
| Pin versions | postgres:16-alpine, not postgres:latest | Reproducible builds and no surprise major upgrades |
| Log to stdout | Write logs to standard output and error, not to files inside the container | Docker and every log shipper collect stdout for you |
Smaller images and scanning for known vulnerabilities
Every package in an image is a package that can have a known vulnerability. Fewer packages mean fewer findings and less to patch. The cheapest wins are the ones from the Dockerfile lesson: multi-stage builds, slim or alpine bases, and no build tools in the final stage. Distroless images go further by omitting even a shell, at the cost of harder debugging. Check where the weight actually is with docker image ls and docker history.
Scanning compares the packages and libraries in an image against vulnerability databases. Docker's own docker scout and the open-source Trivy are common choices. Run them in CI so a new critical finding fails the build.
# Docker Scout: summarise known CVEs in a local image
docker scout cves metro-topup-api:dev
# Trivy: fail (exit code 1) when HIGH or CRITICAL issues are found
trivy image --severity HIGH,CRITICAL --exit-code 1 metro-topup-api:devResults change daily as databases update, so treat a scan as a snapshot. Expect some findings that have no available fix or that live in code your app never executes. Triage by severity and reachability instead of chasing a count of zero, and rebuild regularly so base-image patches reach you.
Common mistakes and the symptoms they cause
- `depends_on` without a healthcheck. Symptom: the API logs
ECONNREFUSEDorthe database system is starting upfor the first seconds, then works on restart. Fix: add a healthcheck andcondition: service_healthy. - Running `docker compose down -v` casually. Symptom: the database is empty after a routine restart. Fix: drop
-vunless you mean it. - Port already in use. Symptom:
Bind for 0.0.0.0:8080 failed: port is already allocated. Fix: stop whatever holds it or change the host side of the mapping. - Bad YAML indentation. Symptom: an error such as
yaml: line 12: did not find expected key. Fix: use two-space indentation consistently and never tabs. - Leaving a top-level `version:` key. Symptom: a warning that the attribute
versionis obsolete in current Compose. Fix: delete it. - Healthcheck command missing from the image. Symptom: the container stays
unhealthyalthough the app is fine, becausecurlis not installed in the slim image. Fix: use a tool the image has, or add one deliberately.
How this shows up in real projects and interviews
Compose is the everyday tool for local development stacks and for CI jobs that need a real database. It is also used for small single-host deployments, with the honest limitation that it does not spread a service across several machines or heal itself when a host dies. That gap is exactly what Kubernetes addresses. Interviewers like asking how you would make a container "production ready"; a good answer touches healthchecks, log limits, a non-root user, pinned versions, secrets handled outside the image, and scanning, in that kind of concrete order.
Try it yourself
Write a compose.yaml for any two-service app you can run (for example a small web app and a database). Add a healthcheck to the database, make the web service wait for it with service_healthy, add log size limits, and keep the database port unpublished. Then run docker compose up -d, check docker compose ps, and stop and restart the stack to confirm the data survives.
Show a hint
Use docker compose config to validate the file and print the fully resolved result before starting anything.
Key takeaways
- A compose file describes services, networks and volumes declaratively, and service names double as hostnames.
- depends_on only waits for start; combine it with healthchecks and service_healthy to wait for readiness.
- Cap logs, set restart policies and resource limits, and pin image versions before relying on a stack.
- Smaller images with fewer packages mean fewer vulnerabilities to triage.
- Scan images in CI, triage by severity and reachability, and rebuild regularly.
Test yourself
A trick from this lesson's ride: Unbounded logs eat the disk: cap with size and file count so old pages get torn out. Four more tricks like it wait along the route, each followed by a question.
Frequently asked questions
What is Docker Compose used for?
Docker Compose describes a multi-container application (services, networks, volumes, configuration) in one YAML file so you can start the whole stack with docker compose up. It is widely used for local development, test environments in CI and small single-host deployments.
How do I make a Docker container wait for the database to be ready?
Add a healthcheck to the database service, then use depends_on with condition: service_healthy on the dependent service. Plain depends_on only waits for the container to start, not for the database to accept connections, so your application should also retry its connection.
How do I scan a Docker image for vulnerabilities?
Use a scanner such as docker scout cves IMAGE or trivy image IMAGE. They compare installed OS packages and libraries against vulnerability databases. Run one in CI with a severity threshold so serious new findings fail the build, and rebuild often to pick up patched base images.