Troubleshoot a Broken Docker Stack

Fix 5 real-world bugs in a broken 4-service Docker Compose stack.

Introduction

30 Second Summary

When a website goes down in the middle of the night, someone has to bring it back online. That person is the on-call DevOps engineer, diagnosing failures using nothing but terminal commands and error logs.

In this project, you will deploy a deliberately broken 4-service application stack using Docker Compose. Your job is to act as the on-call engineer, systematically diagnosing and fixing 5 real-world production bugs using the same incident response workflow that professional DevOps engineers follow.

What You'll Build

You'll repair a multi-container production stack consisting of an nginx web server, a Python API, a PostgreSQL database, and a Redis cache.

By the end of this project, you'll have:

  • A fully repaired docker-compose.yml with all 5 production bugs fixed and all services running healthy.
  • A personal troubleshooting runbook (RUNBOOK.md) documenting each incident's symptoms, diagnosis, root cause, and fix.
  • Hands-on experience with systematic incident response using Docker CLI tools for log analysis, network diagnosis, and resource inspection.
  • Secret Mission: Add a health monitoring sidecar container that automatically detects and alerts on container health failures.

Are there any prerequisites?

You'll need basic Docker knowledge (pulling images, running containers) and familiarity with YAML syntax. You don't need Python or nginx experience because the project provides all application code.

Deploy the Broken Stack

Your environment is ready to go. Now it's time to step into the role of an on-call DevOps engineer.

A 4-service application stack has gone down, and you've been handed the docker-compose.yml that was running in production. Your job is to deploy it, observe the failures, and start documenting what you see.

In this step, get ready to:
  • Create the broken 4-service stack files (nginx, API, PostgreSQL, Redis).
  • Attempt to deploy the stack and observe failures.
  • Document initial symptoms in your runbook.
Create the broken docker-compose.yml

The stack has four services: an nginx reverse proxy, a Python Flask API, a PostgreSQL database, and a Redis cache.

You'll create the application files first, then the Docker Compose configuration that wires them together. There are 5 real-world production bugs hidden across these files.

  • In VS Code, create a new file called app.py inside the api/ folder.
  • Paste the following Python application code:
import os
import time
from flask import Flask, jsonify

app = Flask(__name__)

DATABASE_URL = os.environ.get("DATABASE_URL")
REDIS_URL = os.environ.get("REDIS_URL")


def check_db_connection():
    """Attempt to connect to PostgreSQL."""
    import psycopg2
    try:
        conn = psycopg2.connect(DATABASE_URL)
        conn.close()
        return True
    except Exception:
        return False


def check_redis_connection():
    """Attempt to connect to Redis."""
    import redis
    try:
        r = redis.from_url(REDIS_URL)
        r.ping()
        return True
    except Exception:
        return False


@app.route("/")
def index():
    return jsonify({
        "service": "api",
        "status": "running",
        "database": "connected" if check_db_connection() else "disconnected",
        "cache": "connected" if check_redis_connection() else "disconnected"
    })


@app.route("/health")
def health():
    db_ok = check_db_connection()
    if not db_ok:
        return jsonify({"status": "unhealthy", "reason": "database unreachable"}), 503
    return jsonify({"status": "healthy"}), 200


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)

What does this code do?

This is the Python API that other services depend on. It exposes two routes: / (returns the service status as JSON) and /health (used by Docker for health checks).

The check_db_connection() and check_redis_connection() functions test whether the API can reach the database and cache. Both read their connection strings from environment variables.

  • Save api/app.py.

✔️ Awesome, I've got everything!

Great. Make sure the file is saved inside the api/ folder, not the project root.

ⓧ I'd like to double check the full code

import os
import time
from flask import Flask, jsonify

app = Flask(__name__)

DATABASE_URL = os.environ.get("DATABASE_URL")
REDIS_URL = os.environ.get("REDIS_URL")


def check_db_connection():
    """Attempt to connect to PostgreSQL."""
    import psycopg2
    try:
        conn = psycopg2.connect(DATABASE_URL)
        conn.close()
        return True
    except Exception:
        return False


def check_redis_connection():
    """Attempt to connect to Redis."""
    import redis
    try:
        r = redis.from_url(REDIS_URL)
        r.ping()
        return True
    except Exception:
        return False


@app.route("/")
def index():
    return jsonify({
        "service": "api",
        "status": "running",
        "database": "connected" if check_db_connection() else "disconnected",
        "cache": "connected" if check_redis_connection() else "disconnected"
    })


@app.route("/health")
def health():
    db_ok = check_db_connection()
    if not db_ok:
        return jsonify({"status": "unhealthy", "reason": "database unreachable"}), 503
    return jsonify({"status": "healthy"}), 200


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)
  • Now create a new file called Dockerfile inside the api/ folder.
  • Paste this container build definition:
FROM python:3.12-slim

WORKDIR /app

RUN pip install --no-cache-dir flask psycopg2-binary redis

COPY app.py .

EXPOSE 5000

CMD ["python", "app.py"]

What does this Dockerfile do?

It starts from a lightweight Python 3.12 image, installs the three pip packages the API needs (flask, psycopg2-binary, redis), copies the application code in, and runs it on port 5000.

  • Save api/Dockerfile.
  • Confirm both API files exist by running this in PowerShell:
ls api/

You should see app.py and Dockerfile listed in the output.

Only see one file?

Make sure both files are saved inside the api/ subfolder, not the project root. The Dockerfile must be named exactly Dockerfile with a capital D and no file extension.

help me figure out why my API files aren't showing up in the api/ folder

✔️ Awesome, I've got everything!

Nice. Both API files are in place.

ⓧ I'd like to double check the full code

FROM python:3.12-slim

WORKDIR /app

RUN pip install --no-cache-dir flask psycopg2-binary redis

COPY app.py .

EXPOSE 5000

CMD ["python", "app.py"]
  • Create a new file called default.conf inside the nginx/ folder.
  • Paste this nginx reverse proxy configuration:
upstream api_backend {
    server localhost:5000;
}

server {
    listen 80;
    server_name localhost;

    location / {
        proxy_pass http://api_backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_connect_timeout 5s;
        proxy_read_timeout 30s;
    }

    location /health {
        access_log off;
        return 200 "healthy\n";
    }
}

What does this config do?

This configures nginx as a reverse proxy. It forwards all incoming HTTP traffic on port 80 to the API backend. It also adds a /health endpoint that nginx itself responds to directly.

  • Save nginx/default.conf.
  • Confirm the file exists by running:
ls nginx/

You should see default.conf in the output.

File not showing up?

Double check the file is inside the nginx/ subfolder. The filename must be exactly default.conf (lowercase, with the .conf extension).

help me find where my nginx config file ended up

✔️ Awesome, I've got everything!

Perfect. The nginx config is ready.

ⓧ I'd like to double check the full code

upstream api_backend {
    server localhost:5000;
}

server {
    listen 80;
    server_name localhost;

    location / {
        proxy_pass http://api_backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_connect_timeout 5s;
        proxy_read_timeout 30s;
    }

    location /health {
        access_log off;
        return 200 "healthy\n";
    }
}
  • Now create the main event: a file called docker-compose.yml in your project root (not inside any subfolder).
  • Paste this Docker Compose configuration:
services:
  nginx:
    image: nginx:1.30.2
    ports:
      - "8080:80"
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - api
    networks:
      - frontend

  api:
    build: ./api
    environment:
      - DATABASE_URL=postgresql://appuser:apppass@localhost:5432/appdb
      - REDIS_URL=redis://cache:6379/0
    deploy:
      resources:
        limits:
          memory: 32M
    depends_on:
      - db
      - cache
    networks:
      - backend

  db:
    image: postgres:16
    environment:
      - POSTGRES_USER=appuser
      - POSTGRES_PASSWORD=apppass
      - POSTGRES_DB=appdb
    volumes:
      - pgdata:/var/lib/postgresql/data
    networks:
      - db_network

  cache:
    image: redis:7-alpine
    command: ["redis-server", "--maxmemory", "64mb", "--maxmemory-policy", "allkeys-lru"]
    networks:
      - backend

volumes:
  pgdata:

networks:
  frontend:
  backend:
  db_network:

What does this compose file define?

This defines four services that form a typical web application stack:

  • nginx acts as the public-facing reverse proxy on port 8080.
  • api is the Python Flask application built from the api/ directory.
  • db is a PostgreSQL 16 database with persistent storage.
  • cache is a Redis instance for caching, capped at 64MB.

The file also defines three custom networks and a named volume for database persistence. Somewhere in these 50+ lines, 5 production bugs are hiding.

  • Save docker-compose.yml.
  • Validate the YAML syntax by running:
docker compose config --quiet

If the command returns with no output and no error, your YAML syntax is valid.

Why no errors if there are bugs?

The docker compose config --quiet command only checks YAML structure. It confirms the file is well-formed, but it cannot catch logical bugs like wrong hostnames or insufficient memory limits. Those only surface at runtime.

Seeing a YAML error?

YAML is sensitive to indentation. Use exactly 2 spaces per level, never tabs. Check that each key-value pair lines up with its siblings.

help me fix my docker-compose.yml YAML syntax error

✔️ Awesome, I've got everything!

Your compose file is syntactically valid. Time to deploy it.

ⓧ I'd like to double check the full code

services:
  nginx:
    image: nginx:1.30.2
    ports:
      - "8080:80"
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - api
    networks:
      - frontend

  api:
    build: ./api
    environment:
      - DATABASE_URL=postgresql://appuser:apppass@localhost:5432/appdb
      - REDIS_URL=redis://cache:6379/0
    deploy:
      resources:
        limits:
          memory: 32M
    depends_on:
      - db
      - cache
    networks:
      - backend

  db:
    image: postgres:16
    environment:
      - POSTGRES_USER=appuser
      - POSTGRES_PASSWORD=apppass
      - POSTGRES_DB=appdb
    volumes:
      - pgdata:/var/lib/postgresql/data
    networks:
      - db_network

  cache:
    image: redis:7-alpine
    command: ["redis-server", "--maxmemory", "64mb", "--maxmemory-policy", "allkeys-lru"]
    networks:
      - backend

volumes:
  pgdata:

networks:
  frontend:
  backend:
  db_network:
Attempt to start the stack

With all 4 files in place, it's time to deploy. You already know this stack has bugs, so expect failures.

  • Start all services in detached mode by running:
docker compose up -d

Docker will build the API image and pull any remaining images before attempting to start all 4 containers. This may take a minute or two on the first run.

What should I see?

You will see Docker pull layers, build the API image, and then attempt to start each container. Some containers will start briefly and then exit. Others may enter restart loops. This is expected.

  • Watch the container states by running:
docker compose ps -a

You should see a table showing all 4 services with their current STATUS. Look for containers marked as Exited or in a Restarting loop.

What do the statuses mean?

  • Up means the container is running (though it might still be broken internally).
  • Exited (1) means the application crashed with an error.
  • Exited (137) means the container was killed (often out of memory).
  • Restarting means Docker keeps trying to bring the container back up but it keeps crashing.

Seeing 'image not found' errors?

Make sure Docker Desktop is running. If you see errors about missing images, run docker compose up -d again. Transient network issues can interrupt image pulls.

help me fix Docker image pull errors

Document initial symptoms

A real on-call engineer starts every incident by recording what they see before changing anything. This prevents you from losing track of the original failure state.

  • Create a new file called RUNBOOK.md in your project root.
  • Paste this runbook template:
# Production Incident Runbook

## Quick-Reference Debug Checklist

1. Check container status and exit codes: `docker compose ps -a`
2. Read service logs: `docker compose logs --tail=50 <service>`
3. Inspect container state: `docker inspect <container> --format='{{json .State}}'`
4. Verify network connectivity: `docker compose exec <service> nslookup <target>`
5. Check resource usage: `docker stats --no-stream`
6. Validate compose config: `docker compose config --quiet`

---

## Initial State (Observed on First Deploy)

<!-- Fill in docker compose ps -a output here -->

What is this runbook for?

The quick-reference checklist gives you a systematic triage workflow to follow for every incident. Instead of guessing, you work through each diagnostic command in order.

The "Initial State" section is where you'll paste the output of docker compose ps -a so you have a snapshot of the failure before you start fixing things.

  • Save RUNBOOK.md.
  • Now capture the initial state by running this command again:
docker compose ps -a
  • Copy the full output table from your terminal.
  • Paste it into the ## Initial State (Observed on First Deploy) section of your RUNBOOK.md, replacing the HTML comment.
  • Save RUNBOOK.md again.

Your runbook now has the exact failure state recorded. You'll add diagnosis and fix sections for each incident as you work through them.

All containers showing as 'Up'?

Wait 15-20 seconds and run docker compose ps -a again. Some containers take a moment to crash. If after 30 seconds everything still shows Up, try hitting http://localhost:8080 in a browser to trigger the nginx-to-API connection failure.

help me understand why my containers haven't crashed yet

You've deployed the broken stack and captured its failure state. Next up, you'll dig into exit codes and logs to start diagnosing exactly what went wrong.

Triage with Exit Codes and Logs

Your stack is down, and multiple containers have failed in different ways. In a real production incident, the first thing an on-call engineer does is triage. You need to quickly determine which services are broken and how they failed before diving into fixes.

Docker gives you two powerful starting points for triage: exit codes and container logs. Exit codes tell you the category of failure. Logs tell you the specific error message. Together, they let you diagnose issues without guessing.

In this step, get ready to:
  • Read and interpret container exit codes for every failed service.
  • Analyze container logs to pinpoint specific error messages.
  • Diagnose and fix Incident 1: an environment variable misconfiguration in the API service.
Read exit codes systematically

Every container that stops assigns an exit code. This number tells you the category of failure without needing to read a single log line.

  • Check the status of all containers in your project by running this command:
docker compose ps -a

You should see a table showing each service, its current state, and an exit code for any stopped containers.

What do exit codes mean?

Exit codes are how containers communicate their cause of death. Here are the most common ones you'll encounter:

  • 0 = success (clean exit, the process finished normally).
  • 1 = application error (the app crashed due to a bug or misconfiguration).
  • 137 = OOM killed (the container ran out of memory and was forcibly stopped).
  • 143 = SIGTERM (the container received a graceful shutdown signal).
  • Look at the output from docker compose ps -a and identify which containers show exit code 1 and which show exit code 137.

You should notice the api service showing exit code 1 (application error) or 137 (OOM killed). The exit codes tell you where to focus your investigation.

Analyze container logs

Exit codes tell you the category of failure. Logs tell you the exact error message. For each failing service, you need to pull the most recent log output to see what went wrong right before it crashed.

  • View the last 50 lines of logs from the API service by running:
docker compose logs --tail=50 api

What does this command do?

  • docker compose logs retrieves the stdout/stderr output from a service's container.
  • --tail=50 limits the output to the 50 most recent lines, keeping things manageable.
  • api targets only the API service (you can replace this with any service name).
  • Repeat for the other services to build a full picture of failures by running:
docker compose logs --tail=50 nginx
docker compose logs --tail=50 db
docker compose logs --tail=50 cache

Container exits too quickly?

If a container crashes before producing log output, you can run it in the foreground to see the error in real time:

docker compose up api

This attaches the terminal directly to the container so you can watch it fail. Press Ctrl+C to stop it when you've seen the error.

In the API logs, you should see an error message about a connection being refused to localhost:5432. This is your first clue.

Not seeing any log output?

  • Make sure you ran docker compose up -d in the previous step. Containers must have attempted to start at least once to produce logs.
  • Confirm you're in the correct project directory (the folder containing docker-compose.yml).

Why aren't my Docker Compose logs showing anything?

Diagnose and fix Incident 1: environment variable misconfiguration

The API container exited with code 1 (application error), and its logs show a connection refused error pointing at localhost:5432. Inside a Docker Compose network, localhost refers to the container itself, not to other services. The API is trying to connect to a database running inside its own container, which does not exist.

Why doesn't localhost work between containers?

Each container in Docker Compose is its own isolated network namespace. When the API container says localhost, it means "myself." To reach another container, you use that container's service name as the hostname.

Docker Compose creates a built-in DNS server that resolves service names to the correct container IP addresses. So db resolves to the PostgreSQL container, cache resolves to the Redis container, and so on.

  • In docker-compose.yml, scroll to the api service's environment section.
  • Find this line:
      - DATABASE_URL=postgresql://appuser:apppass@localhost:5432/appdb
  • Replace localhost with db (the service name of the PostgreSQL container), so the line now looks like this:
      - DATABASE_URL=postgresql://appuser:apppass@db:5432/appdb
  • Save docker-compose.yml.
  • Verify your compose file syntax is valid by running:
docker compose config --quiet

If the command produces no output and returns to your prompt, the file is valid. Any syntax errors would be printed here.

Seeing a YAML syntax error?

  • Check that your indentation uses spaces, not tabs. YAML does not allow tab characters.
  • Make sure the DATABASE_URL line starts with - (dash followed by a space) and is indented at the same level as the REDIS_URL line below it.

Help me fix a YAML syntax error in my docker-compose.yml

Now document this incident in your runbook. This builds your personal reference for future debugging.

  • Open RUNBOOK.md and replace the ## Initial State (Observed on First Deploy) section (including the HTML comment) with the following:
## Incident 1: API Environment Variable Misconfiguration

**Symptom**: API container exits immediately with exit code 1

**Diagnosis**:
- `docker compose ps -a` shows api exited with code 1
- `docker compose logs api` shows connection refused to localhost:5432

**Root Cause**: DATABASE_URL pointed to `localhost` instead of the Docker Compose service name `db`

**Fix**: Changed `DATABASE_URL=postgresql://appuser:apppass@localhost:5432/appdb` to `DATABASE_URL=postgresql://appuser:apppass@db:5432/appdb`

Why document incidents this way?

This four-part format (Symptom, Diagnosis, Root Cause, Fix) is the standard structure used in production incident postmortems. It makes your runbook useful months later when you've forgotten the details.

Recording the exact commands you ran during diagnosis is just as valuable as recording the fix. Next time you see exit code 1, you'll know exactly where to look.

  • Save RUNBOOK.md.

✔️ Awesome, I've got everything!

Great. Double check you've saved both docker-compose.yml and RUNBOOK.md.

ⓧ I'd like to double check the full code

Here's what your docker-compose.yml should look like now (only the DATABASE_URL has changed):

services:
  nginx:
    image: nginx:1.30.2
    ports:
      - "8080:80"
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - api
    networks:
      - frontend

  api:
    build: ./api
    environment:
      - DATABASE_URL=postgresql://appuser:apppass@db:5432/appdb
      - REDIS_URL=redis://cache:6379/0
    deploy:
      resources:
        limits:
          memory: 32M
    depends_on:
      - db
      - cache
    networks:
      - backend

  db:
    image: postgres:16
    environment:
      - POSTGRES_USER=appuser
      - POSTGRES_PASSWORD=apppass
      - POSTGRES_DB=appdb
    volumes:
      - pgdata:/var/lib/postgresql/data
    networks:
      - db_network

  cache:
    image: redis:7-alpine
    command: ["redis-server", "--maxmemory", "64mb", "--maxmemory-policy", "allkeys-lru"]
    networks:
      - backend

volumes:
  pgdata:

networks:
  frontend:
  backend:
  db_network:

And here's your full RUNBOOK.md:

# Production Incident Runbook

## Quick-Reference Debug Checklist

1. Check container status and exit codes: `docker compose ps -a`
2. Read service logs: `docker compose logs --tail=50 <service>`
3. Inspect container state: `docker inspect <container> --format='{{json .State}}'`
4. Verify network connectivity: `docker compose exec <service> nslookup <target>`
5. Check resource usage: `docker stats --no-stream`
6. Validate compose config: `docker compose config --quiet`

---

## Incident 1: API Environment Variable Misconfiguration

**Symptom**: API container exits immediately with exit code 1

**Diagnosis**:
- `docker compose ps -a` shows api exited with code 1
- `docker compose logs api` shows connection refused to localhost:5432

**Root Cause**: DATABASE_URL pointed to `localhost` instead of the Docker Compose service name `db`

**Fix**: Changed `DATABASE_URL=postgresql://appuser:apppass@localhost:5432/appdb` to `DATABASE_URL=postgresql://appuser:apppass@db:5432/appdb`

Incident 1 is resolved. You've fixed the environment variable so the API can find the database by its service name. But the stack still has more bugs lurking. Next up, you'll tackle networking and DNS failures that prevent services from talking to each other even when the configuration looks correct.

Fix Networking and DNS Failures

You fixed the first incident by correcting an environment variable. But the stack still has networking problems. Even with the right connection string, containers need to be on the same Docker Compose network and use service names (not localhost) to find each other.

In this step, you'll fix two networking incidents: a reverse proxy that can't reach the API, and a database stranded on the wrong network. Then you'll verify that every service can resolve every other service by name.

In this step, get ready to:
  • Fix the nginx reverse proxy 502 Bad Gateway error.
  • Resolve network isolation between the API and database.
  • Verify DNS resolution between containers.
Diagnose Incident 2 (Service Discovery Failure)

When nginx proxies traffic to a backend, it needs to know where that backend lives. Inside a Docker Compose network, each service gets its own hostname matching its service name. Using localhost inside a container points to that container itself, not to other services.

  • Check what nginx is currently configured to proxy to by running this command:
docker compose exec nginx cat /etc/nginx/conf.d/default.conf

What does this command do?

This prints the active nginx configuration from inside the running container. You're looking at the upstream block to see where nginx sends traffic.

You should see the upstream block pointing to server localhost:5000. That's the problem. Nginx is trying to reach the API on its own loopback address instead of the api service.

  • Confirm the error in the logs by running:
docker compose logs nginx

You should see messages containing "connect() failed" or "Connection refused" in the output.

  • In nginx/default.conf, find this line inside the upstream api_backend block:
    server localhost:5000;
  • Replace localhost with the Docker Compose service name api, so the line looks like this:
    server api:5000;

Why api instead of localhost?

Docker Compose creates a built-in DNS server that resolves service names to container IP addresses. When nginx uses api:5000, Docker's DNS returns the API container's internal IP.

Using localhost inside any container always means "this container". It never reaches another service.

The config fix alone isn't enough. Nginx also needs to be on the same network as the API service to resolve its name. Right now, nginx is only on the frontend network while the API is on backend.

  • In docker-compose.yml, find the nginx service's networks section:
    networks:
      - frontend
  • Add - backend below - frontend, so the section looks like this:
    networks:
      - frontend
      - backend

Why does nginx need two networks?

Nginx sits between the outside world and your internal services. It needs the frontend network for external port exposure and the backend network to reach the API. This is a common pattern in production. The reverse proxy bridges public and private layers.

  • Save both nginx/default.conf and docker-compose.yml.

Not sure where to find the networks section?

In docker-compose.yml, look inside the nginx: service block. The networks: key is at the same indent level as image:, ports:, and volumes:.

Help me find the networks section for the nginx service in my docker-compose.yml.

Diagnose Incident 3 (Network Isolation)

Even after fixing the nginx config, the API still can't reach the database. The DATABASE_URL points to db correctly (from your Incident 1 fix), but the two containers are on completely separate networks.

  • List all Docker networks to see the isolation by running:
docker network ls

You should see three project networks: one ending in _frontend, one ending in _backend, and one ending in _db_network.

  • Inspect the backend network to confirm the db container is missing:
docker network inspect $(docker network ls --filter name=backend -q)

What should I see?

The Containers section of the output shows which containers are attached. You should see the api and cache containers listed, but NOT db. The database is stranded on its own db_network with no way to reach the API.

  • In docker-compose.yml, find the db service's networks section:
    networks:
      - db_network
  • Replace - db_network with - backend, so the section looks like this:
    networks:
      - backend
  • Scroll to the bottom of docker-compose.yml and find the top-level networks: section:
networks:
  frontend:
  backend:
  db_network:
  • Delete the db_network: line entirely, so the section looks like this:
networks:
  frontend:
  backend:
  • Save docker-compose.yml.

Seeing a YAML syntax error?

YAML is very sensitive to indentation. Make sure each networks: key inside a service is indented with exactly 4 spaces. The top-level networks: key at the bottom of the file should have zero indentation.

Help me fix a YAML indentation error in my docker-compose.yml networks section.

✔️ Awesome, I've got everything!

Great. Make sure you've saved both nginx/default.conf and docker-compose.yml.

ⓧ I'd like to double check the full code

Here is what nginx/default.conf should look like after your Incident 2 fix:

upstream api_backend {
    server api:5000;
}

server {
    listen 80;
    server_name localhost;

    location / {
        proxy_pass http://api_backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_connect_timeout 5s;
        proxy_read_timeout 30s;
    }

    location /health {
        access_log off;
        return 200 "healthy\n";
    }
}

Here is what docker-compose.yml should look like after your Incident 2 and Incident 3 fixes:

services:
  nginx:
    image: nginx:1.30.2
    ports:
      - "8080:80"
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - api
    networks:
      - frontend
      - backend

  api:
    build: ./api
    environment:
      - DATABASE_URL=postgresql://appuser:apppass@db:5432/appdb
      - REDIS_URL=redis://cache:6379/0
    deploy:
      resources:
        limits:
          memory: 32M
    depends_on:
      - db
      - cache
    networks:
      - backend

  db:
    image: postgres:16
    environment:
      - POSTGRES_USER=appuser
      - POSTGRES_PASSWORD=apppass
      - POSTGRES_DB=appdb
    volumes:
      - pgdata:/var/lib/postgresql/data
    networks:
      - backend

  cache:
    image: redis:7-alpine
    command: ["redis-server", "--maxmemory", "64mb", "--maxmemory-policy", "allkeys-lru"]
    networks:
      - backend

volumes:
  pgdata:

networks:
  frontend:
  backend:
Verify DNS resolution

Both networking fixes are in place. Time to restart the stack and prove that every service can resolve every other service by name.

  • Tear down the current stack and bring it back up with your fixes by running:
docker compose down
docker compose up -d
  • Check that the containers are running by running:
docker compose ps -a

You should see all four services listed. Some may still be restarting (Incidents 4 and 5 are not yet fixed), but that's expected.

  • Verify that the API container can resolve the database hostname by running:
docker compose exec api getent hosts db

What does this command do?

The getent hosts command queries the system's name resolution (including Docker's built-in DNS) for a given hostname. If it returns an IP address and the hostname, DNS resolution is working.

You should see output like 172.x.x.x db showing that the API can now find the database on the shared backend network.

  • Verify that the API can also resolve the cache service:
docker compose exec api getent hosts cache

You should see a similar IP address and hostname response confirming connectivity.

Seeing "no such service" or "service is not running"?

The docker compose exec command only works on running containers. If the API container keeps crashing (exit code 137 from the still-unfixed OOM issue), it may not stay running long enough to exec into.

Try running docker compose ps -a to check container states. If the API is restarting, wait a few seconds and try the exec command again during one of its brief running moments.

Help me run docker compose exec when my API container keeps restarting.

Now document both incidents in your runbook.

  • Add the following Incident 2 and Incident 3 entries to the bottom of RUNBOOK.md:
---

## Incident 2: Nginx Reverse Proxy 502 Bad Gateway

**Symptom**: HTTP requests to localhost:8080 return 502 Bad Gateway

**Diagnosis**:
- `docker compose exec nginx cat /etc/nginx/conf.d/default.conf` shows upstream pointing to `localhost:5000`
- `docker compose logs nginx` shows "connect() failed - Connection refused"

**Root Cause**: Nginx config used `localhost` as upstream instead of the Docker service name `api`

**Fix**: Changed upstream server from `server localhost:5000` to `server api:5000`

---

## Incident 3: Network Isolation Between Services

**Symptom**: API cannot reach the database despite correct DATABASE_URL

**Diagnosis**:
- `docker network ls` shows two separate networks
- `docker network inspect` shows api and db on different networks

**Root Cause**: Services were placed on isolated custom networks with no shared network

**Fix**: Ensured api and db are both on the `backend` network

What does this code do?

Each incident entry follows the same structure: symptom, diagnosis commands, root cause, and fix. This format makes your runbook scannable during future incidents. When you're under pressure at 2am, you want answers fast.

  • Save RUNBOOK.md.

Two down, two to go. Your services can now find each other by name across the network. Next up, you'll tackle the infrastructure-level failures: an API container being killed for using too much memory, and a database that isn't ready when the API tries to connect.

Resolve Resource and Dependency Issues

You have fixed the networking and DNS issues across your stack. Services can now find each other by name. But two infrastructure-level problems remain: the API container keeps crashing with no obvious application error, and even when it does start, it sometimes fails because the database is not ready yet.

These are the trickiest production bugs because they are intermittent. Sometimes the stack starts fine, sometimes it does not. In this step, you will diagnose a memory constraint killing your API and a startup race condition between services.

In this step, get ready to:
  • Identify and fix an OOM-killed container by increasing its memory limit.
  • Resolve a dependency race condition with health checks and conditional startup ordering.
  • Verify the entire 4-service stack starts cleanly with all containers healthy.
Diagnose Incident 4 (OOM kill)

Your API container keeps restarting, and docker compose ps -a shows it exiting with code 137. Exit code 137 means the container received a SIGKILL signal. In Docker, this almost always means the container exceeded its memory limit and was killed by the kernel's Out-Of-Memory (OOM) killer.

  • Confirm the OOM kill by running this command (replace <container> with your API container name or ID from docker compose ps -a):
docker inspect --format='{{.State.OOMKilled}}' <container>

What does this command do?

This command queries the container's internal state metadata. The OOMKilled field returns true if the Linux kernel's OOM killer terminated the process because it exceeded its allocated memory. Exit code 137 is 128 + 9 (SIGKILL).

  • You should see true in the output, confirming the API was OOM killed.

The current memory limit is set to 32M, which is far too low for a Python application running Flask with database and cache connections. You need to increase it.

  • In docker-compose.yml, find the api service's deploy section. Find this block:
    deploy:
      resources:
        limits:
          memory: 32M
  • Replace it with the following to increase the limit to 256M and add a memory reservation of 128M:
    deploy:
      resources:
        limits:
          memory: 256M
        reservations:
          memory: 128M

What is a memory reservation?

The limits.memory value is the hard ceiling. If the container exceeds it, the OOM killer terminates it. The reservations.memory value is a soft guarantee. Docker will try to ensure at least that much memory is available when scheduling the container.

  • Save docker-compose.yml.
  • Document this incident in your RUNBOOK.md by adding the following section after Incident 3:
---

## Incident 4: API Container OOM Killed

**Symptom**: API container restarts repeatedly with exit code 137

**Diagnosis**:
- `docker compose ps -a` shows exit code 137
- `docker inspect --format='{{.State.OOMKilled}}' <container>` returns `true`

**Root Cause**: Memory limit set too low (32M) for Python application

**Fix**: Increased memory limit to 256M in deploy.resources.limits

What does this entry document?

You are recording the exact diagnostic path: what you observed (exit code 137), how you confirmed it (docker inspect showing OOMKilled is true), what caused it (32M is too low), and what you changed. This is the format real incident response teams use in post-mortems.

  • Save RUNBOOK.md.

OOMKilled still showing false?

If you see false, your container might not have hit the memory limit yet. Try restarting the stack with docker compose up -d and wait for the API to restart a few times before inspecting again. The 32M limit is tight enough that it should trigger within a few seconds of the API loading its Python dependencies.

Also make sure you are inspecting the correct container. Run docker compose ps -a and use the full container name shown in the output.

Help me figure out why my container isn't showing OOMKilled true.

Diagnose Incident 5 (dependency race condition)

Even with the correct DATABASE_URL and shared network, the API sometimes crashes on startup with a "connection refused" error. This happens because Docker Compose starts containers in parallel. The API tries to connect to PostgreSQL before the database has finished initializing.

The default depends_on only waits for the container to start. It does not wait for the application inside to be ready. To fix this, you need health checks that verify each service is actually accepting connections.

  • Confirm the race condition by checking the API logs:
docker compose logs --tail=50 api

What should I see?

Look for lines mentioning psycopg2.OperationalError or "could not connect" errors. These appear because the API started before PostgreSQL finished its initialization sequence. The API tries to connect, fails, and crashes.

The fix involves adding health checks to every service and using conditional depends_on so containers wait for their dependencies to be fully ready. This is a significant restructuring of your compose file.

  • Replace the entire contents of docker-compose.yml with the following fixed version:
services:
  nginx:
    image: nginx:1.30.2
    ports:
      - "8080:80"
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      api:
        condition: service_healthy
    networks:
      - frontend
      - backend
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:80"]
      interval: 10s
      timeout: 5s
      retries: 3

  api:
    build: ./api
    environment:
      - DATABASE_URL=postgresql://appuser:apppass@db:5432/appdb
      - REDIS_URL=redis://cache:6379/0
    deploy:
      resources:
        limits:
          memory: 256M
        reservations:
          memory: 128M
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
    networks:
      - backend
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:5000/health"]
      interval: 10s
      timeout: 5s
      retries: 3

  db:
    image: postgres:16
    environment:
      - POSTGRES_USER=appuser
      - POSTGRES_PASSWORD=apppass
      - POSTGRES_DB=appdb
    volumes:
      - pgdata:/var/lib/postgresql/data
    networks:
      - backend
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
      interval: 5s
      timeout: 5s
      retries: 5

  cache:
    image: redis:7-alpine
    command: ["redis-server", "--maxmemory", "64mb", "--maxmemory-policy", "allkeys-lru"]
    networks:
      - backend
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 3

volumes:
  pgdata:

networks:
  frontend:
  backend:

What changed in this file?

Four key changes fix the race condition and add observability to the entire stack:

  • The db service now has a healthcheck using pg_isready -U appuser -d appdb. This command checks whether PostgreSQL is accepting connections.
  • The api service's depends_on now uses condition: service_healthy for db. Docker Compose will not start the API until PostgreSQL's health check passes.
  • Health checks are added to api, nginx, and cache services as well. This lets the nginx service wait for the API to be healthy before starting.
  • The cache service uses condition: service_started because Redis starts almost instantly. It does not need a health-based wait.
  • Save docker-compose.yml.

Seeing a YAML syntax error?

YAML is sensitive to indentation. Make sure all healthcheck blocks are indented at the same level as image, ports, and networks (4 spaces). Run docker compose config --quiet to validate your file. If it returns silently with no output, the syntax is correct.

Help me fix my docker-compose.yml syntax error.

  • Document Incident 5 in your RUNBOOK.md by adding this section after Incident 4:
---

## Incident 5: Database Dependency Race Condition

**Symptom**: API crashes on startup with "connection refused" even after env var fix

**Diagnosis**:
- `docker compose logs api` shows psycopg2.OperationalError: could not connect
- PostgreSQL container is still initializing when API tries to connect

**Root Cause**: No health check on PostgreSQL; depends_on only waits for container start, not app readiness

**Fix**: Added healthcheck using `pg_isready -U appuser -d appdb` and changed depends_on to `condition: service_healthy`

Why does depends_on need a condition?

Without a condition, depends_on only guarantees that the dependent container has started. A PostgreSQL container can take several seconds to initialize its data directory and start accepting connections. By the time the API starts and tries to connect, PostgreSQL is still booting up.

With condition: service_healthy, Docker Compose polls the health check and only starts the dependent service after the health check passes. This eliminates the race condition entirely.

  • Save RUNBOOK.md.
Bring the full stack up healthy

With all 5 incidents resolved, it is time to bring the entire stack up from scratch and verify every service is healthy.

  • Tear down the current stack by running:
docker compose down
  • Start the full stack fresh by running:
docker compose up -d

What is happening during startup?

Docker Compose now starts services in dependency order. First db and cache start. Once the db health check passes (pg_isready succeeds), the api starts. Once the api health check passes (/health returns 200), nginx starts. This cascading startup ensures no service connects to an unready dependency.

  • Wait about 30 seconds for all health checks to pass, then verify the stack by running:
docker compose ps
  • You should see all 4 services listed with a status of Up and (healthy) next to each one. No containers should show Exit or (unhealthy).

Services not showing healthy?

Health checks take time. If you see (health: starting), wait another 30 seconds and run docker compose ps again. The database health check retries up to 5 times at 5-second intervals, so it can take up to 25 seconds to pass.

If a service shows (unhealthy), check its logs with docker compose logs <service> for specific error messages. Make sure the YAML indentation in your docker-compose.yml matches exactly.

Help me figure out why my services aren't becoming healthy.

✔️ Awesome, I've got everything!

Great. Double check you have saved both docker-compose.yml and RUNBOOK.md.

ⓧ I'd like to double check the full code

Here is the complete docker-compose.yml at this point:

services:
  nginx:
    image: nginx:1.30.2
    ports:
      - "8080:80"
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      api:
        condition: service_healthy
    networks:
      - frontend
      - backend
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:80"]
      interval: 10s
      timeout: 5s
      retries: 3

  api:
    build: ./api
    environment:
      - DATABASE_URL=postgresql://appuser:apppass@db:5432/appdb
      - REDIS_URL=redis://cache:6379/0
    deploy:
      resources:
        limits:
          memory: 256M
        reservations:
          memory: 128M
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
    networks:
      - backend
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:5000/health"]
      interval: 10s
      timeout: 5s
      retries: 3

  db:
    image: postgres:16
    environment:
      - POSTGRES_USER=appuser
      - POSTGRES_PASSWORD=apppass
      - POSTGRES_DB=appdb
    volumes:
      - pgdata:/var/lib/postgresql/data
    networks:
      - backend
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
      interval: 5s
      timeout: 5s
      retries: 5

  cache:
    image: redis:7-alpine
    command: ["redis-server", "--maxmemory", "64mb", "--maxmemory-policy", "allkeys-lru"]
    networks:
      - backend
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 3

volumes:
  pgdata:

networks:
  frontend:
  backend:

Here is the complete RUNBOOK.md at this point:

# Production Incident Runbook

## Quick-Reference Debug Checklist

1. Check container status and exit codes: `docker compose ps -a`
2. Read service logs: `docker compose logs --tail=50 <service>`
3. Inspect container state: `docker inspect <container> --format='{{json .State}}'`
4. Verify network connectivity: `docker compose exec <service> nslookup <target>`
5. Check resource usage: `docker stats --no-stream`
6. Validate compose config: `docker compose config --quiet`

---

## Incident 1: API Environment Variable Misconfiguration

**Symptom**: API container exits immediately with exit code 1

**Diagnosis**:
- `docker compose ps -a` shows api exited with code 1
- `docker compose logs api` shows connection refused to localhost:5432

**Root Cause**: DATABASE_URL pointed to `localhost` instead of the Docker Compose service name `db`

**Fix**: Changed `DATABASE_URL=postgresql://appuser:apppass@localhost:5432/appdb` to `DATABASE_URL=postgresql://appuser:apppass@db:5432/appdb`

---

## Incident 2: Nginx Reverse Proxy 502 Bad Gateway

**Symptom**: HTTP requests to localhost:8080 return 502 Bad Gateway

**Diagnosis**:
- `docker compose exec nginx cat /etc/nginx/conf.d/default.conf` shows upstream pointing to `localhost:5000`
- `docker compose logs nginx` shows "connect() failed - Connection refused"

**Root Cause**: Nginx config used `localhost` as upstream instead of the Docker service name `api`

**Fix**: Changed upstream server from `server localhost:5000` to `server api:5000`

---

## Incident 3: Network Isolation Between Services

**Symptom**: API cannot reach the database despite correct DATABASE_URL

**Diagnosis**:
- `docker network ls` shows two separate networks
- `docker network inspect` shows api and db on different networks

**Root Cause**: Services were placed on isolated custom networks with no shared network

**Fix**: Ensured api and db are both on the `backend` network

---

## Incident 4: API Container OOM Killed

**Symptom**: API container restarts repeatedly with exit code 137

**Diagnosis**:
- `docker compose ps -a` shows exit code 137
- `docker inspect --format='{{.State.OOMKilled}}' <container>` returns `true`

**Root Cause**: Memory limit set too low (32M) for Python application

**Fix**: Increased memory limit to 256M in deploy.resources.limits

---

## Incident 5: Database Dependency Race Condition

**Symptom**: API crashes on startup with "connection refused" even after env var fix

**Diagnosis**:
- `docker compose logs api` shows psycopg2.OperationalError: could not connect
- PostgreSQL container is still initializing when API tries to connect

**Root Cause**: No health check on PostgreSQL; depends_on only waits for container start, not app readiness

**Fix**: Added healthcheck using `pg_isready -U appuser -d appdb` and changed depends_on to `condition: service_healthy`

All 5 incidents are resolved and your production stack is running clean. Next up, you will document everything into a polished runbook and validate that the stack survives a full restart from scratch.

Document Your Runbook

All 5 bugs are fixed and every service is healthy. That's the hard part done.

Now you need to turn your debugging experience into a reusable reference. A good runbook captures the exact commands and reasoning so that next time (or the next engineer) doesn't start from scratch. In this step, you'll finalize your documentation and prove the stack starts cleanly from zero.

In this step, get ready to:
  • Review your RUNBOOK.md for completeness across all 5 incidents.
  • Add a quick-reference debug checklist at the top.
  • Validate the full stack starts cleanly from a cold start.
Complete your incident documentation

Each incident entry in your runbook should follow a consistent four-part structure. This makes it scannable during a real outage when you're under pressure.

  • Open RUNBOOK.md in your editor.
  • Review each of the 5 incident sections and confirm every entry has these four parts: Symptom, Diagnosis, Root Cause, and Fix.

What makes a good incident entry?

The Symptom describes what you observed (not what you guessed). The Diagnosis lists the exact commands you ran to investigate. The Root Cause is the single underlying problem. The Fix is the specific change you made.

This structure mirrors how professional Site Reliability Engineers document postmortems. It separates observation from interpretation, which prevents future engineers from skipping diagnostic steps.

  • Verify that your Incident 1 entry includes the original broken value and the corrected value for DATABASE_URL.
  • Verify that your Incident 4 entry mentions exit code 137 and the docker inspect --format='{{.State.OOMKilled}}' command.
  • Verify that your Incident 5 entry documents both the healthcheck addition and the condition: service_healthy fix.

✔️ Awesome, I've got everything!

Great. Make sure you've saved RUNBOOK.md before moving on.

ⓧ I'd like to double check the full code

# Production Incident Runbook

## Quick-Reference Debug Checklist

1. Check container status and exit codes: `docker compose ps -a`
2. Read service logs: `docker compose logs --tail=50 <service>`
3. Inspect container state: `docker inspect <container> --format='{{json .State}}'`
4. Verify network connectivity: `docker compose exec <service> nslookup <target>`
5. Check resource usage: `docker stats --no-stream`
6. Validate compose config: `docker compose config --quiet`

---

## Incident 1: API Environment Variable Misconfiguration

**Symptom**: API container exits immediately with exit code 1

**Diagnosis**:
- `docker compose ps -a` shows api exited with code 1
- `docker compose logs api` shows connection refused to localhost:5432

**Root Cause**: DATABASE_URL pointed to `localhost` instead of the Docker Compose service name `db`

**Fix**: Changed `DATABASE_URL=postgresql://appuser:apppass@localhost:5432/appdb` to `DATABASE_URL=postgresql://appuser:apppass@db:5432/appdb`

---

## Incident 2: Nginx Reverse Proxy 502 Bad Gateway

**Symptom**: HTTP requests to localhost:8080 return 502 Bad Gateway

**Diagnosis**:
- `docker compose exec nginx cat /etc/nginx/conf.d/default.conf` shows upstream pointing to `localhost:5000`
- `docker compose logs nginx` shows "connect() failed - Connection refused"

**Root Cause**: Nginx config used `localhost` as upstream instead of the Docker service name `api`

**Fix**: Changed upstream server from `server localhost:5000` to `server api:5000`

---

## Incident 3: Network Isolation Between Services

**Symptom**: API cannot reach the database despite correct DATABASE_URL

**Diagnosis**:
- `docker network ls` shows two separate networks
- `docker network inspect` shows api and db on different networks

**Root Cause**: Services were placed on isolated custom networks with no shared network

**Fix**: Ensured api and db are both on the `backend` network

---

## Incident 4: API Container OOM Killed

**Symptom**: API container restarts repeatedly with exit code 137

**Diagnosis**:
- `docker compose ps -a` shows exit code 137
- `docker inspect --format='{{.State.OOMKilled}}' <container>` returns `true`

**Root Cause**: Memory limit set too low (32M) for Python application

**Fix**: Increased memory limit to 256M in deploy.resources.limits

---

## Incident 5: Database Dependency Race Condition

**Symptom**: API crashes on startup with "connection refused" even after env var fix

**Diagnosis**:
- `docker compose logs api` shows psycopg2.OperationalError: could not connect
- PostgreSQL container is still initializing when API tries to connect

**Root Cause**: No health check on PostgreSQL; depends_on only waits for container start, not app readiness

**Fix**: Added healthcheck using `pg_isready -U appuser -d appdb` and changed depends_on to `condition: service_healthy`
Add a quick-reference debug checklist

When an outage hits, you need a muscle-memory sequence of commands. A checklist at the top of your runbook gives you that sequence without scrolling.

  • At the very top of RUNBOOK.md (below the title), confirm you have the following quick-reference checklist. If it's not there, add it now:
## Quick-Reference Debug Checklist

1. Check container status and exit codes: `docker compose ps -a`
2. Read service logs: `docker compose logs --tail=50 <service>`
3. Inspect container state: `docker inspect <container> --format='{{json .State}}'`
4. Verify network connectivity: `docker compose exec <service> nslookup <target>`
5. Check resource usage: `docker stats --no-stream`
6. Validate compose config: `docker compose config --quiet`

Why this order matters

The checklist follows a triage funnel. You start broad (which containers are even running?) and narrow down (is it a network issue? a resource issue? a config issue?). Each command eliminates a category of failures before moving to the next.

Step 1 catches dead containers. Step 2 catches application errors. Step 3 catches state anomalies like OOM kills. Steps 4-6 catch infrastructure problems. This mirrors how experienced DevOps engineers triage in production.

  • Save RUNBOOK.md.
Validate the final stack from a cold start

The ultimate test of your fixes is whether the stack starts cleanly from nothing. If it does, every bug is truly resolved and your compose file is production-ready.

  • Tear down the running stack by running this command:
docker compose down

What does this command do?

This stops and removes all containers and networks created by your compose file. Named volumes (like pgdata) are preserved by default, so your database data persists across restarts.

  • Bring the stack back up from scratch by running:
docker compose up -d
  • Wait about 15-20 seconds for the health checks to pass, then verify all services are healthy by running:
docker compose ps
  • You should see all 4 services (nginx, api, db, cache) with a status of Up and health showing (healthy).

Services not all showing healthy?

If any service shows (health: starting), wait another 15 seconds and run docker compose ps again. Health checks need a few intervals to pass.

If a service shows (unhealthy) or has exited, check its logs with docker compose logs --tail=50 <service> and compare your compose file against the double-check tab above.

help me debug why my service isn't reaching healthy status after docker compose up

  • As a final confirmation, open your browser and navigate to http://localhost:8080.
  • You should see a JSON response showing the API is running with the database connected and the cache connected.

Your runbook is complete and your stack starts cleanly from zero. You've proven that every fix is permanent and self-contained in the compose file. Next up, tackle the Secret Mission to add automated health monitoring.

Secret mission

Add a Health Monitor Sidecar

Your stack is fixed and running healthy. But what happens when the next failure hits? In production, you don't wait for users to report outages. You add automated monitoring that catches failures the moment they happen. In this secret mission, you'll build a 5th container that watches the others and alerts you when something goes wrong.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally using Docker Desktop, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Docker containers (nginx, api, db, cache, monitor).
  • Docker images (nginx:1.30.2, postgres:16, redis:7-alpine, docker:cli, and custom-built api and monitor images).
  • Docker volume (pgdata).
  • Docker networks (frontend, backend).
  • Project folder on your Desktop (containing docker-compose.yml, api/, monitor/, nginx/, RUNBOOK.md).
  • Docker Desktop application.

Keep everything running

No action needed. Choose this if you are still actively building or want to experiment further with your stack.

  • Your containers will continue running in the background and restart automatically if Docker Desktop is running.
  • Docker Desktop stays installed and available for future projects.
  • Your project files, runbook, and compose configuration remain on your Desktop.

Pause - I'll come back to this later

Stop all running containers to free up memory and CPU, but keep your files and images intact so you can restart quickly later.

  • Stop the running stack by opening PowerShell, navigating to your project folder, and running:
docker compose stop
  • This stops all containers without removing them. Your volumes, networks, and images stay intact.
  • To start everything again later, navigate to your project folder and run:
docker compose start
  • Docker Desktop can remain installed. It uses minimal resources when no containers are running.

Delete - I don't want to use this again

Remove all project resources completely and start fresh. This frees up all disk space used by images, volumes, and project files.

  • Stop and remove all containers, networks, volumes, and images for this project by opening PowerShell, navigating to your project folder, and running:
docker compose down -v --rmi all
  • This removes all containers, the pgdata volume, the frontend and backend networks, and all images used by the services.
  • Delete the project folder from your Desktop by running:
cd ~\Desktop
Remove-Item -Recurse -Force .\docker-troubleshoot

Folder name may differ

Replace docker-troubleshoot with whatever you named your project folder in Step 1.

  • To uninstall Docker Desktop entirely, open the Windows Start menu and go to Settings then Apps then Apps & features.
  • Select Docker Desktop from the list and click Uninstall.
  • Click Uninstall again to confirm.

Uninstalling Docker Desktop removes everything

Uninstalling Docker Desktop removes all Docker components including Docker Engine, Docker CLI, and Docker Compose. It also removes any remaining containers, images, and volumes stored on your machine.

Nice Work!

Nice Work!

You just responded to a multi-service production outage and fixed it like a pro. You deployed a deliberately broken 4-service stack, diagnosed 5 real-world failures using systematic incident response, and brought every container back to a healthy state.

You've learned how to:

  • Deploy and repair a multi-container Docker Compose stack by diagnosing exit codes, OOM kills, DNS failures, race conditions, and environment variable misconfigurations.
  • Use a systematic troubleshooting workflow (docker compose ps -a, docker compose logs, docker inspect, docker network inspect) to triage container failures the way on-call DevOps engineers do.
  • Create a production-ready troubleshooting runbook documenting symptoms, diagnostic commands, root causes, and fixes for each incident.
  • Built a health monitoring sidecar container that automatically detects and alerts on container health failures using the Docker socket.

Ready to quiz yourself?