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.
# 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
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)
docker compose up, and has a working dev environment in 30 seconds. No "works on my machine."Docker Compose is the perfect local development tool. For production at scale, you'd use Kubernetes (Week 5). But Compose is used everywhere for:
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)
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: . — 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).
docker compose up, Compose creates a dedicated network (e.g., myapp_default).app container connects to hostname postgres, Docker's built-in DNS resolves it to the postgres container's IP.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.
# 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 ✓
# 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)
service_healthy condition met.service_started — just started (default — risky)service_healthy — passed health check ✅service_completed_successfully — for one-off init containers
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.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.
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!
| Feature | Named Volume | Bind Mount |
|---|---|---|
| Location | Docker managed | Your host path |
| Persists on down? | ✅ Yes | ✅ Yes (host file) |
| Live editing | No | ✅ Yes |
| Use for | DB data, uploads | Source code, config |
| Perf on Mac | Fast | Slower |
node_modules (which may have wrong platform binaries) to override the container's node_modules./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
# === 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
# .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.
docker compose up -d postgresdocker compose exec postgres pg_isreadynpm testOne docker-compose.yml. Three services. Health checks. Volumes. Live code reload. Full local dev stack from scratch.
app (build from Dockerfile), postgres (image), redis (image). Run docker compose up -d and verify all 3 are running.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.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..env with DB_PASSWORD. Add to .gitignore. Create .env.example with placeholder values for teammates.docker compose exec app sh. Run ping postgres — it resolves! Run ping redis. Service names are DNS hostnames../src:/app/src bind mount to app service. Edit a file on host. Verify change appears in container without rebuild.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 (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
dev: add docker-compose for local dev stack
3 questions · 5 minutes · service discovery, health checks, named volumes
app service connect to the postgres service?condition: service_healthy in depends_on?docker compose down (without -v)?docker compose up -d — all 3 services healthy ✓ping postgres from app container ✓down + up — postgres data survives ✓dev: add docker-compose for local dev stack ✓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
no — never restart (default)always — restart on any stop, including system rebooton-failure — only restart on non-zero exit codeunless-stopped — restart always except manual docker stopFor dev: unless-stopped means if your app crashes it restarts, but you can still stop it intentionally.
# 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
| Concept | Docker Compose | Kubernetes (Week 5) |
|---|---|---|
| Unit of work | service | Pod |
| Config file | docker-compose.yml | Deployment YAML |
| Service discovery | service name as hostname | Service name as hostname |
| Persistent data | named volumes | PersistentVolumeClaim |
| Secrets | env_file: .env | Secret resource |
| Health check | healthcheck: test: | readinessProbe / livenessProbe |
| Start after ready | depends_on: service_healthy | initContainers |
| Scale | --scale app=3 | replicas: 3 |
| Resource limits | deploy.resources.limits | resources.limits |
| Best for | Local dev, small deployments | Production at scale |
# 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
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
| Day | Topic | Key Output | Status |
|---|---|---|---|
| Day 16 | Docker Fundamentals | Namespaces, cgroups, layers — lab exercises | ✅ |
| Day 17 | Writing Dockerfiles | Bad → Good: 1.1 GB → 80 MB, 90s → 3s | ✅ |
| Day 18 ← TODAY | Docker Compose | App + DB + Redis stack with health checks | ✅ |
| Day 19 | Container Registries | GHCR push with SemVer tags + Trivy scan | Tomorrow |
| Day 20 | Container Capstone | Full CI/CD: build → test → scan → push | Fri |
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
.env.example to .env, fill in valuesdocker compose up -dlocalhost:3000docker compose down to stopAny developer, any machine, same environment.
| Problem | Cause | Fix |
|---|---|---|
| App can't connect to DB ("connection refused") | App started before DB ready, or using localhost | Add healthcheck to DB + depends_on: condition: service_healthy. Ensure DB_HOST=postgres not localhost. |
| Service stuck in "starting" (never healthy) | Healthcheck command failing | docker compose ps → check health status. docker inspect <container> → see last health check output. |
| "port already in use" on startup | Another process/container using that host port | Change host port: "5433:5432". Or stop conflicting process/container first. |
| Code changes not visible in container | Missing bind mount or wrong path | Verify ./src:/app/src in volumes. Use absolute paths if relative aren't working. Restart container after adding mount. |
| DB data lost after down + up | Using bind mount instead of named volume, or used -v | Use 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 wrong | Check .env file exists alongside docker-compose.yml. docker compose exec app env | grep DB to verify vars. |
| Old image used despite code changes | Compose cached the old built image | docker compose up -d --build forces image rebuild before starting. |
| Can't access service from host browser | Port not published or wrong host port | Check ports: in compose file. Use expose: for inter-service only (no host access). |
# 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
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:
DB_HOST=postgres not localhost. Named volumes persist. -v flag deletes volumes.