DevOps · 35 Days · Week 4 Day 18 — Docker Compose
1 / 22
Week 4 · Day 18

Docker Compose

One command to spin up your entire local dev stack — Node.js app + PostgreSQL + Redis. Service discovery by name, health checks, volumes for persistence, environment variables for config.

⏱ Duration 60 min
📖 Theory 25 min
🔧 Lab 30 min
❓ Quiz 5 min
Before: 5 separate docker run commands After: docker compose up -d
Session Overview

What we cover today

01
Why Docker Compose?
Real apps = multiple containers. Compose defines your entire stack in one YAML file. One command to rule them all.
02
docker-compose.yml structure
services, image/build, ports, volumes, environment, depends_on, networks. Every key explained.
03
Service Discovery — hostname magic
Containers reach each other by service name. DB_HOST=postgres works because Compose creates DNS for every service.
04
Health checks & depends_on
condition: service_healthy — app waits until postgres is actually ready, not just started.
05
Volumes — persistent data
Named volumes survive docker compose down. Bind mounts for live code reload. docker compose down -v to wipe.
06
Essential Compose commands
up, down, logs, exec, ps, restart, build. Scale a service. Override with env files.
07
🔧 Lab — Node.js + PostgreSQL + Redis
Full local dev stack in one docker-compose.yml. DB persistence, health checks, env_file for secrets.
Part 1 of 5

Why Docker Compose? — real apps need multiple containers

❌ Without Compose — 5 commands, 5 problems
# Run a real app manually:
docker network create myapp-net

docker run -d --name postgres \
  --network myapp-net \
  -e POSTGRES_DB=myapp \
  -e POSTGRES_USER=user \
  -e POSTGRES_PASSWORD=secret \
  -v pgdata:/var/lib/postgresql/data \
  postgres:16-alpine

docker run -d --name redis \
  --network myapp-net \
  redis:7-alpine

# Wait for postgres to be ready... how long?
sleep 10  ← hoping this is enough

docker run -d --name app \
  --network myapp-net \
  -p 3000:3000 \
  -e DB_HOST=postgres \
  -e REDIS_URL=redis://redis:6379 \
  my-devops-app:latest

# Problems:
# 1. 5+ commands to remember
# 2. No health check — app may start before DB ready
# 3. Not reproducible — team members run different flags
# 4. Cleanup: docker stop + rm each one
# 5. No single source of truth
✅ With Compose — one command
docker compose up -d
# ✓ Creates network automatically
# ✓ Starts postgres + redis first
# ✓ Waits for health checks to pass
# ✓ Starts app only when DB is ready
# ✓ Same for every developer on the team
# ✓ All config in one docker-compose.yml

docker compose down
# ✓ Stops and removes all containers + network
# ✓ Volumes preserved (data safe)
What Docker Compose gives you
Single source of truth — all config in one file. Any developer clones the repo, runs docker compose up, and has a working dev environment in 30 seconds. No "works on my machine."

Dependency ordering — Compose starts services in the right order and waits for health checks.

Shared networking — services reach each other by name automatically.
Compose is for local dev — not production

Docker Compose is the perfect local development tool. For production at scale, you'd use Kubernetes (Week 5). But Compose is used everywhere for:

  • Local development environments
  • Integration testing in CI
  • Small single-server deployments
  • Quick proof-of-concept setups
Part 2 of 5

docker-compose.yml — every key explained

docker-compose.yml — annotated
services:                        ← top-level: define all containers

  app:                           ← service name (used as hostname!)
    build: .                     ← build from Dockerfile in ./
      # OR use a pre-built image:
      # image: my-devops-app:latest
    ports:
      - "3000:3000"               ← host:container port mapping
    environment:
      NODE_ENV: production
      DB_HOST: postgres          ← ← service name as hostname!
      DB_PORT: "5432"
      REDIS_URL: redis://redis:6379
    env_file:
      - .env                      ← load secrets from .env file
    depends_on:
      postgres:
        condition: service_healthy ← wait for healthcheck to pass!
      redis:
        condition: service_healthy
    volumes:
      - ./src:/app/src            ← bind mount: live code reload
    restart: unless-stopped

  postgres:                       ← service name = hostname for others
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: secret  ← better: use env_file
    volumes:
      - pgdata:/var/lib/postgresql/data ← named volume: data persists
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U appuser -d myapp"]
      interval: 5s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 3

volumes:
  pgdata:                         ← declare named volume (persists on down)
Key sections explained
  • services: — each key is one container. The key name becomes both the container name and the DNS hostname.
  • build: . — build image from Dockerfile in current dir. image: to use pre-built.
  • ports:HOST:CONTAINER. Only expose what you need.
  • environment: — inline env vars. For secrets: use env_file:.
  • depends_on: — start order + optional health check waiting.
  • volumes: — mount points. Named (persistent) or bind (host path).
  • restart:unless-stopped auto-restarts on crash or reboot.
💡 build vs image
build: . — runs docker build using Dockerfile in current directory. Good for your own services.

image: postgres:16-alpine — pulls pre-built image from registry. Good for third-party services (DB, Redis, nginx).
Part 3 of 5

Service Discovery — containers talk by name

docker-compose network (myapp_default) app 172.20.0.2 :3000 hostname: app postgres 172.20.0.3 :5432 hostname: postgres redis 172.20.0.4 :6379 hostname: redis // Inside the 'app' container: const db = new Pool({ host: 'postgres', // ← service name = hostname! port: 5432, database: 'myapp' });
How service discovery works
When you run docker compose up, Compose creates a dedicated network (e.g., myapp_default).

Every service is added to that network. Compose also creates a DNS entry for each service name. So when the app container connects to hostname postgres, Docker's built-in DNS resolves it to the postgres container's IP.

No hardcoded IPs needed. Service names are stable.
localhost vs service name

Common mistake:

DB_HOST=localhost  ← WRONG inside Docker
   localhost = the container itself!

DB_HOST=postgres   ← CORRECT
   postgres = the postgres service on Compose network

This is the #1 mistake when moving from local dev (without Compose) to Compose.

Part 4 of 5

Health checks & depends_on — wait for ready, not just started

⚠ The "started ≠ ready" problem
# depends_on WITHOUT condition:
depends_on:
  - postgres
# Compose starts postgres container
# Then IMMEDIATELY starts app container
# But postgres takes 3-5 seconds to initialize!
# App tries to connect → "connection refused"
# App crashes → keeps crashing → error loop

# depends_on WITH service_healthy:
depends_on:
  postgres:
    condition: service_healthy
# Compose starts postgres container
# Runs healthcheck every 5 seconds
# pg_isready passes → postgres is truly ready
# THEN starts app container
# App connects successfully on first try ✓
healthcheck syntax
# PostgreSQL health check
healthcheck:
  test: ["CMD-SHELL", "pg_isready -U appuser -d myapp"]
  interval: 5s    ← check every 5 seconds
  timeout: 5s    ← fail if takes > 5 seconds
  retries: 5      ← unhealthy after 5 failures
  start_period: 10s ← grace period on startup

# Redis health check
healthcheck:
  test: ["CMD", "redis-cli", "ping"]
  # redis-cli ping returns PONG when ready

# HTTP app health check
healthcheck:
  test: ["CMD-SHELL", "curl -f http://localhost:3000/health || exit 1"]
  interval: 10s
  retries: 3

# Check health status
docker compose ps
# Service   Status             Health
# app       running            healthy
# postgres  running            healthy
# redis     running (healthy)
Health check states
starting — container running, health check hasn't passed yet
healthy — health check passed. service_healthy condition met.
unhealthy — health check failed N times in a row

service_started — just started (default — risky)
service_healthy — passed health check ✅
service_completed_successfully — for one-off init containers
start_period vs interval
  • interval — how often to run the check. 5s = check every 5 sec.
  • timeout — how long the check can take before counting as failure.
  • retries — how many consecutive failures = unhealthy.
  • start_period — grace period after container starts. Failures during this period don't count toward retries. Set to how long your app takes to initialise.
💡 Why Kubernetes uses the same concept
K8s has readinessProbe and livenessProbe — the same idea. A pod is only added to the Service load balancer when its readinessProbe passes. Compose's service_healthy is the same concept at smaller scale.
Part 5 of 5

Volumes — named volumes vs bind mounts

Volume patterns in Compose
services:
  postgres:
    volumes:
      # Named volume — Docker managed, PERSISTS on down
      - pgdata:/var/lib/postgresql/data

  app:
    volumes:
      # Bind mount — host path mapped to container
      # Changes on host immediately visible in container
      # Perfect for live code reload in development
      - ./src:/app/src        ← host ./src → container /app/src
      - ./public:/app/public  ← multiple mounts OK

      # Anonymous volume — don't mount node_modules from host
      - /app/node_modules     ← keeps container's node_modules

volumes:
  pgdata:            ← declare named volume
                         stored at: /var/lib/docker/volumes/

# === Volume lifecycle ===
docker compose up -d           # volumes created if not exist
docker compose down            # containers removed, VOLUMES KEPT
docker compose down -v         # containers + volumes removed
docker volume ls               # list all volumes
docker volume inspect pgdata   # see volume details + mountpoint
docker volume rm myapp_pgdata  # delete specific volume

# Connect to postgres with data still there:
docker compose up -d
docker compose exec postgres \
  psql -U appuser -d myapp
# Your data is still there after down + up!
Named volume vs bind mount
FeatureNamed VolumeBind Mount
LocationDocker managedYour host path
Persists on down?✅ Yes✅ Yes (host file)
Live editingNo✅ Yes
Use forDB data, uploadsSource code, config
Perf on MacFastSlower
The node_modules trick
When using bind mount for code, you don't want the host's node_modules (which may have wrong platform binaries) to override the container's node_modules.

Solution: anonymous volume on /app/node_modules. Docker prioritises the anonymous volume over the bind mount for that specific path.
volumes:
  - ./src:/app/src           ← bind: live code
  - /app/node_modules        ← anon: container's node_modules
Essential Commands

docker compose commands — the daily toolkit

Up, down, logs, exec
# === Start / Stop ===
docker compose up              # start (foreground, all logs)
docker compose up -d           # start detached (background)
docker compose up --build      # rebuild images before starting
docker compose up -d app       # start only 'app' service

docker compose down            # stop + remove containers + network
docker compose down -v         # + remove volumes (wipe DB!)
docker compose down --rmi all  # + remove images

docker compose stop            # stop without removing
docker compose start           # start stopped containers
docker compose restart app     # restart one service

# === Status ===
docker compose ps              # service status + health
docker compose top             # processes in each container

# === Logs ===
docker compose logs            # all service logs
docker compose logs -f         # follow all (live)
docker compose logs -f app     # follow one service
docker compose logs --tail 50  # last 50 lines

# === Exec into running containers ===
docker compose exec app sh         # shell in app
docker compose exec postgres \
  psql -U appuser -d myapp         # postgres CLI
docker compose exec redis \
  redis-cli                        # redis CLI

# === Build ===
docker compose build               # build all images
docker compose build app           # rebuild one service
docker compose build --no-cache    # full rebuild

# === Scale (run multiple instances) ===
docker compose up -d --scale app=3 # run 3 app instances
Environment files
# .env file (alongside docker-compose.yml)
DB_PASSWORD=s3cr3tpassword
JWT_SECRET=myjwtsecret
STRIPE_KEY=sk_test_abc123

# docker-compose.yml
services:
  app:
    env_file:
      - .env          ← loads all vars from .env
    environment:
      DB_HOST: postgres  ← inline (non-secret)

# .env is gitignored! Never commit it.
# Provide .env.example with placeholder values.
💡 Compose for CI testing
Compose works beautifully in GitHub Actions for integration tests:
docker compose up -d postgres
docker compose exec postgres pg_isready
npm test
Real database, real tests, no mocking needed.
Hands-On Lab

🔧 Node.js + PostgreSQL + Redis Stack

One docker-compose.yml. Three services. Health checks. Volumes. Live code reload. Full local dev stack from scratch.

⏱ 30 minutes
Docker Desktop running ✓
postgres:16-alpine pre-pulled ✓
🔧 Lab — Steps

Build your local dev stack

1
Create docker-compose.yml with 3 services
Define app (build from Dockerfile), postgres (image), redis (image). Run docker compose up -d and verify all 3 are running.
2
Add healthchecks + depends_on condition
Add healthcheck to postgres and redis. Set depends_on: postgres: condition: service_healthy on app. Watch docker compose ps — app should wait until postgres is healthy.
3
Add named volume for postgres persistence
Add pgdata:/var/lib/postgresql/data volume. Run docker compose down then docker compose up -d. Connect to postgres — data still there. Then try docker compose down -v and verify data is gone.
4
Add env_file for secrets
Create .env with DB_PASSWORD. Add to .gitignore. Create .env.example with placeholder values for teammates.
5
Test service discovery
Exec into the app container: docker compose exec app sh. Run ping postgres — it resolves! Run ping redis. Service names are DNS hostnames.
6
Add bind mount for live code reload
Add ./src:/app/src bind mount to app service. Edit a file on host. Verify change appears in container without rebuild.
🔧 Lab — Complete Code

Full docker-compose.yml

docker-compose.yml
services:

  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      NODE_ENV: development
      DB_HOST: postgres
      DB_PORT: "5432"
      DB_NAME: myapp
      DB_USER: appuser
      REDIS_URL: redis://redis:6379
    env_file:
      - .env
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    volumes:
      - ./src:/app/src
      - /app/node_modules
    restart: unless-stopped

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U appuser -d myapp"]
      interval: 5s
      timeout: 5s
      retries: 5
      start_period: 10s
    ports:
      - "5432:5432"  ← for local psql clients

  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 3

volumes:
  pgdata:
.env + lab commands
# === .env (gitignored!) ===
DB_PASSWORD=devsecretpassword

# === .env.example (committed) ===
DB_PASSWORD=your_password_here

# === .gitignore add ===
.env

# === Lab commands ===
# Start everything
docker compose up -d

# Watch status + health
docker compose ps

# Follow all logs
docker compose logs -f

# Connect to postgres
docker compose exec postgres \
  psql -U appuser -d myapp

# Test service discovery
docker compose exec app sh
ping postgres  ← resolves to postgres IP!
exit

# Check redis
docker compose exec redis redis-cli ping
# PONG

# Test persistence
docker compose down    ← volumes kept
docker compose up -d
# postgres data still there!

docker compose down -v ← volumes deleted
# postgres data gone

# Clean up
docker compose down -v --rmi local
💡 Commit message
dev: add docker-compose for local dev stack
Knowledge Check

Quiz Time

3 questions · 5 minutes · service discovery, health checks, named volumes

QUESTION 1 OF 3
In Docker Compose, how does the app service connect to the postgres service?
A
Using the host machine's IP address (192.168.x.x)
B
Using the service name "postgres" as the hostname — Compose creates DNS for every service
C
Using localhost and different port numbers
D
Using the container ID hash
QUESTION 2 OF 3
What is the purpose of condition: service_healthy in depends_on?
A
It starts all services simultaneously for better performance
B
It makes one service wait until another passes its healthcheck before starting — prevents connection errors when the DB isn't ready
C
It automatically restarts failed services
D
It shares environment variables between services
QUESTION 3 OF 3
What happens to named volume data when you run docker compose down (without -v)?
A
Data is always deleted — containers and volumes are the same thing
B
Data persists — named volumes are not removed without the -v flag
C
Data is backed up to a temporary directory automatically
D
Data is committed to a new image layer
Day 18 — Complete

What you learned today

📋
Compose File
services, image/build, ports, environment, env_file, depends_on, volumes, healthcheck.
🔍
Service Discovery
Service name = DNS hostname. DB_HOST=postgres not localhost. Automatic on compose network.
❤️
Health Checks
service_healthy waits for real readiness. pg_isready for postgres. redis-cli ping for redis.
💾
Volumes
Named volumes persist on down. Bind mounts for live reload. down -v to wipe everything.
Day 18 Action Items
  1. docker compose up -d — all 3 services healthy ✓
  2. Service discovery: ping postgres from app container ✓
  3. Persistence: down + up — postgres data survives ✓
  4. Commit: dev: add docker-compose for local dev stack
Tomorrow — Day 19
Container Registries

Docker Hub vs GHCR vs ACR vs ECR. Image tagging strategy (SemVer + SHA). Push to GHCR with proper tags. Trivy vulnerability scanning in CI.
GHCR SemVer tags Trivy CI scan
📌 Reference

docker-compose.yml — complete key reference

All service keys
services:
  myservice:
    image: nginx:alpine      ← pre-built image
    build:                  ← or build from Dockerfile
      context: .
      dockerfile: Dockerfile.prod
      args:
        NODE_ENV: production
    container_name: my-nginx  ← override auto name
    ports:
      - "8080:80"            ← host:container
      - "127.0.0.1:443:443" ← bind to specific IP
    expose: ["80"]          ← only to other containers
    environment:
      - NODE_ENV=production  ← list form
      DB_HOST: postgres     ← map form
    env_file: .env
    volumes:
      - ./html:/usr/share/nginx/html:ro  ← :ro = read-only
      - mydata:/app/data     ← named volume
    networks:
      - frontend
      - backend
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped  ← no|always|on-failure|unless-stopped
    profiles: ["debug"]      ← only start with --profile debug
    deploy:
      resources:
        limits:
          cpus: '0.50'
          memory: 512M

networks:
  frontend:
  backend:
    internal: true         ← no external access

volumes:
  mydata:
    driver: local
restart policies
  • no — never restart (default)
  • always — restart on any stop, including system reboot
  • on-failure — only restart on non-zero exit code
  • unless-stopped — restart always except manual docker stop

For dev: unless-stopped means if your app crashes it restarts, but you can still stop it intentionally.

profiles — optional services
# Only starts with --profile debug
services:
  pgadmin:
    image: dpage/pgadmin4
    profiles: ["debug"]

# Normal start (no pgadmin):
docker compose up -d

# Start with debug tools:
docker compose --profile debug up -d
📌 Concepts Map

Docker Compose vs Kubernetes — same ideas, different scale

Concept Docker Compose Kubernetes (Week 5)
Unit of workservicePod
Config filedocker-compose.ymlDeployment YAML
Service discoveryservice name as hostnameService name as hostname
Persistent datanamed volumesPersistentVolumeClaim
Secretsenv_file: .envSecret resource
Health checkhealthcheck: test:readinessProbe / livenessProbe
Start after readydepends_on: service_healthyinitContainers
Scale--scale app=3replicas: 3
Resource limitsdeploy.resources.limitsresources.limits
Best forLocal dev, small deploymentsProduction at scale
💡 Compose is a great Kubernetes learning tool
The concepts in Compose (service discovery, health checks, volumes, networking) are the same ideas in Kubernetes — just simpler syntax. Learning Compose well makes Week 5 (Kubernetes) much more intuitive.
📌 Advanced Pattern

Compose in CI — integration tests with real databases

docker-compose.test.yml
# Separate compose file for CI testing
services:
  postgres-test:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: testdb
      POSTGRES_USER: testuser
      POSTGRES_PASSWORD: testpass
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U testuser"]
      interval: 3s
      retries: 10

  test-runner:
    build: .
    command: npm test
    environment:
      DB_HOST: postgres-test
      DB_URL: postgresql://testuser:testpass@postgres-test:5432/testdb
      NODE_ENV: test
    depends_on:
      postgres-test:
        condition: service_healthy
GitHub Actions with Compose
name: Integration Tests

on: [push, pull_request]

jobs:
  integration:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Start test database
        run: |
          docker compose -f docker-compose.test.yml \
            up -d postgres-test

      - name: Wait for postgres healthy
        run: |
          docker compose -f docker-compose.test.yml \
            exec -T postgres-test \
            pg_isready -U testuser

      - name: Run integration tests
        run: |
          docker compose -f docker-compose.test.yml \
            run --rm test-runner

      - name: Clean up
        if: always()
        run: docker compose -f docker-compose.test.yml down -v
💡 Real DB = better tests
With Compose in CI, your integration tests hit a real PostgreSQL instance. No mocking, no in-memory SQLite. The same DB engine as production. Catches SQL dialect issues, index behaviour, transaction handling.
Week 4 Progress

Week 4 — 3 of 5 days complete

Day Topic Key Output Status
Day 16Docker FundamentalsNamespaces, cgroups, layers — lab exercises
Day 17Writing DockerfilesBad → Good: 1.1 GB → 80 MB, 90s → 3s
Day 18 ← TODAYDocker ComposeApp + DB + Redis stack with health checks
Day 19Container RegistriesGHCR push with SemVer tags + Trivy scanTomorrow
Day 20Container CapstoneFull CI/CD: build → test → scan → pushFri
Your project files so far
my-devops-app/
├── src/
│   └── index.js
├── Dockerfile              ← Day 17 optimised
├── .dockerignore           ← Day 17
├── docker-compose.yml      ← Day 18 ← new!
├── .env                    ← Day 18 (gitignored)
├── .env.example            ← Day 18 (committed)
├── .github/workflows/
│   ├── ci.yml
│   ├── test.yml
│   └── docker.yml
The local dev flow
  1. Clone repo
  2. Copy .env.example to .env, fill in values
  3. docker compose up -d
  4. App running at localhost:3000
  5. Edit code → see changes immediately (bind mount)
  6. docker compose down to stop

Any developer, any machine, same environment.

📌 Troubleshooting

Common Compose issues & fixes

Problem Cause Fix
App can't connect to DB ("connection refused")App started before DB ready, or using localhostAdd healthcheck to DB + depends_on: condition: service_healthy. Ensure DB_HOST=postgres not localhost.
Service stuck in "starting" (never healthy)Healthcheck command failingdocker compose ps → check health status. docker inspect <container> → see last health check output.
"port already in use" on startupAnother process/container using that host portChange host port: "5433:5432". Or stop conflicting process/container first.
Code changes not visible in containerMissing bind mount or wrong pathVerify ./src:/app/src in volumes. Use absolute paths if relative aren't working. Restart container after adding mount.
DB data lost after down + upUsing bind mount instead of named volume, or used -vUse named volume syntax: pgdata:/var/lib/postgresql/data and declare in top-level volumes:.
Environment variable not passed through.env file missing or variable name wrongCheck .env file exists alongside docker-compose.yml. docker compose exec app env | grep DB to verify vars.
Old image used despite code changesCompose cached the old built imagedocker compose up -d --build forces image rebuild before starting.
Can't access service from host browserPort not published or wrong host portCheck ports: in compose file. Use expose: for inter-service only (no host access).
📌 Day 18 Quick Reference

Everything at a glance

Essential commands
# Lifecycle
docker compose up -d            # start all, detached
docker compose up -d --build    # rebuild + start
docker compose down             # stop + remove (keep volumes)
docker compose down -v          # stop + remove + wipe volumes
docker compose restart app      # restart one service

# Status + Debug
docker compose ps               # status + health
docker compose logs -f          # follow all logs
docker compose logs -f app      # follow one service
docker compose exec app sh      # shell in container
docker compose exec postgres \
  psql -U appuser myapp         # psql directly

# Connectivity test
docker compose exec app \
  ping postgres                 # service discovery works?
docker compose exec app \
  wget -qO- http://localhost:3000/health

# Cleanup
docker compose down -v --rmi local  # nuclear option
Minimal 3-service stack
services:
  app:
    build: .
    ports: ["3000:3000"]
    environment:
      DB_HOST: postgres
    depends_on:
      postgres:
        condition: service_healthy

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user"]
      interval: 5s
      retries: 5

  redis:
    image: redis:7-alpine

volumes:
  pgdata:
💡 Remember
Service name = hostname. DB_HOST=postgres not localhost. Named volumes persist. -v flag deletes volumes.