Skip to main content
PProfileMatch

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

  1. A compose file for three services
  2. Why healthchecks change start-up order
  3. Habits that separate a demo from something you can run
  4. Smaller images and scanning for known vulnerabilities
  5. Common mistakes and the symptoms they cause
  6. How this shows up in real projects and interviews

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

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

Terminal
# .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 down

Output

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

HabitHowWhy it matters
Cap log sizelogging options max-size and max-fileThe default json-file driver keeps logs forever, and a chatty service can fill the disk
Restart policyrestart: unless-stoppedBrings services back after a crash or a host reboot without masking a stopped-on-purpose container
Resource limitsMemory and CPU limits under the service's deploy.resources or the mem_limit keyA leak in one container should not starve the rest of the host
Pin versionspostgres:16-alpine, not postgres:latestReproducible builds and no surprise major upgrades
Log to stdoutWrite logs to standard output and error, not to files inside the containerDocker 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.

Terminal
# 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:dev

Results 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 ECONNREFUSED or the database system is starting up for the first seconds, then works on restart. Fix: add a healthcheck and condition: service_healthy.
  • Running `docker compose down -v` casually. Symptom: the database is empty after a routine restart. Fix: drop -v unless 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 version is obsolete in current Compose. Fix: delete it.
  • Healthcheck command missing from the image. Symptom: the container stays unhealthy although the app is fine, because curl is 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.

Ride this lesson

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.