Skip to main content

Module 04 — Docker Compose

In Module 03 you ran two containers manually — each with a long docker run command full of flags. It worked, but imagine doing that every time you restart the stack, or explaining those commands to a teammate.

Docker Compose solves this by capturing your entire multi-container application in a single YAML file. One command brings everything up. One command tears it down. The file is version-controlled alongside your code, so the deployment is reproducible.

In this module you add a third service — Nginx as a reverse proxy — and define all three (PostgreSQL, Go app, Nginx) in a docker-compose.yml with health checks, dependency ordering, and environment variable management.

Everything runs on app-server (192.168.56.12).


1. What Docker Compose Replaces

Compare the manual workflow from Module 03 with Compose:

StepModule 03 (manual)Module 04 (Compose)
Create networkdocker network create customerapp-netCompose creates it automatically
Create volumedocker volume create pgdataDeclared in volumes: section
Start PostgreSQL7-line docker run commandDefined in services.postgres
Start app7-line docker run commandDefined in services.app
Start Nginx(not done yet)Defined in services.nginx
Bring everything upRun 5 commands in orderdocker compose up -d
Tear everything downStop/remove 3 containers + networkdocker compose down

Compose does not replace Docker — it is a layer on top that calls the same Docker API. Everything you learned in Modules 01–03 still applies.


2. Install Docker Compose

Docker Compose v2 ships as a Docker CLI plugin. On modern Docker installations it is already available:

docker compose version

Expected output:

Docker Compose version v2.x.x

If the command is not found, install the plugin:

sudo apt-get update
sudo apt-get install -y docker-compose-plugin

Note: The command is docker compose (with a space), not docker-compose (with a hyphen). The hyphenated version is Compose v1, which is deprecated.

Checkpoint: docker compose version returns v2.x or higher.


3. Create the Nginx Configuration

Before writing the Compose file, you need an Nginx config for the reverse proxy. This is similar to the Fundamentals track setup on web-server, but adapted for Docker networking — Nginx reaches the app by container name instead of IP address.

mkdir -p ~/customerapp/docker/nginx
~/customerapp/docker/nginx/default.conf
server {
listen 80;
server_name _;

location / {
proxy_pass http://app:8080;
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_set_header X-Forwarded-Proto $scheme;
}
}

The key difference from the Fundamentals track: proxy_pass http://app:8080 uses the container name app instead of an IP address. Docker's internal DNS resolves app to the app container's IP on the shared network.


4. Create the Environment File

Compose can read environment variables from a .env file. This keeps secrets out of the YAML file and out of version control.

~/customerapp/.env
# Database credentials
POSTGRES_USER=appuser
POSTGRES_PASSWORD=apppassword123
POSTGRES_DB=customerdb

# App configuration
DB_HOST=postgres
DB_PORT=5432
DB_USER=appuser
DB_PASSWORD=apppassword123
DB_NAME=customerdb

Important: Add .env to your .gitignore so credentials are not committed to the repository. For this training environment the values are not sensitive, but building the habit matters.

echo ".env" >> ~/customerapp/.gitignore

5. Write the docker-compose.yml

This is the core of the module. Create the file:

~/customerapp/docker-compose.yml
services:
postgres:
image: postgres:16-alpine
container_name: postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- pgdata:/var/lib/postgresql/data
- ./docker/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
networks:
- customerapp-net

app:
image: customerapp:v1
container_name: customerapp
restart: unless-stopped
ports:
- "8080:8080"
environment:
DB_HOST: ${DB_HOST}
DB_PORT: ${DB_PORT}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
DB_NAME: ${DB_NAME}
depends_on:
postgres:
condition: service_healthy
networks:
- customerapp-net

nginx:
image: nginx:alpine
container_name: nginx
restart: unless-stopped
ports:
- "80:80"
volumes:
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- app
networks:
- customerapp-net

volumes:
pgdata:

networks:
customerapp-net:

What each section does

services — defines the three containers. Each service maps to one docker run command.

postgres

  • Uses the official image with env vars from .env
  • Mounts the named volume and the init script (same as Module 03)
  • The healthcheck runs pg_isready every 5 seconds — Compose uses this to know when PostgreSQL is actually ready

app

  • Uses the image you built in Module 02
  • depends_on with condition: service_healthy means Compose waits until the postgres health check passes before starting the app — this solves the startup timing problem from Module 03

nginx

  • Bind-mounts the Nginx config file
  • depends_on: app ensures Nginx starts after the app (simple dependency, no health check required)
  • Exposes port 80 for external access

volumes — declares pgdata as a named volume. Compose creates it on first run.

networks — declares customerapp-net. Compose creates the network and attaches all services to it automatically.

★ Insight ─────────────────────────────────────

  • depends_on with condition: service_healthy is the Compose answer to the "postgres isn't ready yet" race condition from Module 03. Without it, the app would start immediately and crash trying to connect to a database that's still initializing.
  • The restart: unless-stopped policy means containers come back after a Docker daemon restart or a crash, but stay down if you explicitly docker stop them. ─────────────────────────────────────────────────

6. Launch the Stack

First, clean up any containers left over from Module 03:

docker stop customerapp postgres 2>/dev/null
docker rm customerapp postgres 2>/dev/null
docker network rm customerapp-net 2>/dev/null
docker volume rm pgdata 2>/dev/null

Now bring up the entire stack:

cd ~/customerapp
docker compose up -d

Compose creates the network, volume, and all three containers in dependency order:

[+] Running 4/4
✔ Network customerapp-net Created
✔ Volume "pgdata" Created
✔ Container postgres Healthy
✔ Container customerapp Started
✔ Container nginx Started

Notice that Compose waited for postgres to become Healthy before starting customerapp. No more race conditions.

Verify all services are running

docker compose ps

Expected output:

NAME           IMAGE               STATUS                  PORTS
customerapp customerapp:v1 Up 30 seconds 0.0.0.0:8080->8080/tcp
nginx nginx:alpine Up 28 seconds 0.0.0.0:80->80/tcp
postgres postgres:16-alpine Up 35 seconds (healthy) 5432/tcp

All three containers are up, and postgres shows (healthy).

Checkpoint: docker compose ps shows three services running. Postgres status includes (healthy).


7. Test the Stack

7.1 Direct app access (port 8080)

curl http://localhost:8080/health
{"database":"connected","status":"healthy"}

7.2 Access through Nginx (port 80)

curl http://localhost/health
{"database":"connected","status":"healthy"}

Same response, but this request went through Nginx first. The path: browser → Nginx (:80) → app (:8080) → postgres (:5432).

7.3 Full login flow through Nginx

# Login
curl -c cookies.txt -X POST http://localhost/api/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin123"}'

# List customers
curl -b cookies.txt http://localhost/api/customers

7.4 Access from your Mac

Open a browser and go to http://192.168.56.12 (port 80, no need to specify). The Customer Information App UI loads — served through the containerized Nginx reverse proxy.

Checkpoint: curl http://localhost/health through Nginx returns the same healthy response as direct access on port 8080.


8. Docker Compose Commands

Now that the stack is running, learn the commands you will use daily.

View logs

All services:

docker compose logs

A specific service:

docker compose logs postgres

Follow logs in real time (like tail -f):

docker compose logs -f app

Press Ctrl+C to stop following.

Stop and start

Stop all containers (keeps containers and volumes):

docker compose stop

Start them again:

docker compose start

Restart a single service

docker compose restart app

This stops and starts only the app container. Postgres and Nginx keep running.

Tear down the stack

Remove containers and the network (keeps volumes and images):

docker compose down

Remove containers, network, and volumes (destroys all data):

docker compose down -v

Command summary

CommandWhat it does
docker compose up -dCreate and start all services (detached)
docker compose psList running services and their status
docker compose logs -f [service]Stream logs
docker compose stopStop containers (preserves state)
docker compose startStart stopped containers
docker compose restart [service]Restart one or all services
docker compose downRemove containers + network
docker compose down -vRemove containers + network + volumes
docker compose exec [service] [cmd]Run a command in a running container

9. Health Checks in Detail

Health checks are the mechanism that makes depends_on with condition: service_healthy work. They also let Docker restart unhealthy containers automatically (with restart: unless-stopped).

How the postgres health check works

healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
  • test — the command Docker runs inside the container. pg_isready is a PostgreSQL utility that returns exit code 0 when the database is accepting connections.
  • interval — how often to run the check (every 5 seconds)
  • timeout — how long to wait for the check to respond
  • retries — how many consecutive failures before marking the container as unhealthy

Check health status

docker inspect postgres --format '{{.State.Health.Status}}'
healthy

View the health check log:

docker inspect postgres --format '{{json .State.Health}}' | python3 -m json.tool

This shows the last few health check results with timestamps and exit codes.

Why this matters for Kubernetes

In Kubernetes (Modules 10+), you will configure readiness probes and liveness probes — same concept, different syntax. Getting comfortable with health checks now makes the Kubernetes versions intuitive.

Checkpoint: docker inspect postgres --format '{{.State.Health.Status}}' returns healthy.


10. Rebuild and Update Services

When you change your application code, you need to rebuild the image and update the running container.

10.1 Make a code change

Edit main.go — for example, change the log message:

sed -i 's/Server starting on :8080/Server ready on :8080/' ~/customerapp/main.go

10.2 Rebuild the image

docker build -t customerapp:v2 ~/customerapp/

10.3 Update the Compose file

Edit docker-compose.yml to use the new tag:

  app:
image: customerapp:v2

10.4 Apply the change

docker compose up -d

Compose detects that only the app service changed and recreates just that container:

 ✔ Container postgres    Running
✔ Container customerapp Recreated
✔ Container nginx Running

Postgres and Nginx were not touched — only the app container was replaced. This is how you do rolling updates with Compose.

10.5 Verify

docker compose logs app --tail 5

You should see the new log message: Server ready on :8080.

Checkpoint: After changing the image tag and running docker compose up -d, only the app container is recreated.


11. Exec Into Containers

Sometimes you need to debug a running container. docker compose exec opens a shell or runs a command inside a service.

Open a psql session

docker compose exec postgres psql -U appuser -d customerdb

Run a query:

SELECT * FROM users;

Type \q to exit.

Open a shell in the app container

docker compose exec app sh

The app image is based on Alpine, so the shell is sh (not bash). Explore:

ls /
cat /etc/os-release
exit

Check Nginx config inside the container

docker compose exec nginx cat /etc/nginx/conf.d/default.conf

This confirms your bind-mounted config file is in place.


12. Troubleshooting

service "app" depends on "postgres" which has no healthcheck

The health check is missing from the postgres service definition. Verify the healthcheck block is indented under postgres: in docker-compose.yml.

App starts before postgres is ready (connection refused)

Check that the depends_on block uses condition: service_healthy, not just a simple dependency:

# Wrong — does not wait for health
depends_on:
- postgres

# Right — waits for healthy status
depends_on:
postgres:
condition: service_healthy

Nginx returns 502 Bad Gateway

Nginx started but cannot reach the app. Check:

  1. App is running: docker compose ps app
  2. App logs: docker compose logs app
  3. Nginx config uses proxy_pass http://app:8080 (container name, not IP)
  4. Both services are on the same network (they are if defined in the same Compose file)

Environment variables not substituted

Compose reads .env from the same directory as docker-compose.yml. Verify:

  1. .env is in ~/customerapp/ (same directory as the Compose file)
  2. Variable names match exactly (case-sensitive)
  3. No spaces around = in the .env file

Port 80 already in use

Another process is using port 80. Check with:

sudo lsof -i :80

Stop the conflicting process or change the Nginx port mapping to 8081:80 in docker-compose.yml.


13. What You Have Now

CapabilityVerification Command
Three-service stack defined in YAMLcat ~/customerapp/docker-compose.yml
All services running with one commanddocker compose ps — three services up
Health check on PostgreSQLdocker inspect postgres --format '{{.State.Health.Status}}'
Nginx reverse proxy on port 80curl http://localhost/health
Environment variables from .env filedocker compose exec app env | grep DB_HOST
Selective service restartdocker compose restart app
Single-command teardowndocker compose down

The complete project structure on app-server:

~/customerapp/
├── main.go
├── go.mod
├── go.sum
├── static/
├── Dockerfile
├── docker-compose.yml
├── .env
├── .gitignore
├── .dockerignore
└── docker/
├── init.sql
└── nginx/
└── default.conf

Next up: Module 05 — Container Registry — push your images to a registry so they can be pulled from any machine in the cluster.