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:
| Step | Module 03 (manual) | Module 04 (Compose) |
|---|---|---|
| Create network | docker network create customerapp-net | Compose creates it automatically |
| Create volume | docker volume create pgdata | Declared in volumes: section |
| Start PostgreSQL | 7-line docker run command | Defined in services.postgres |
| Start app | 7-line docker run command | Defined in services.app |
| Start Nginx | (not done yet) | Defined in services.nginx |
| Bring everything up | Run 5 commands in order | docker compose up -d |
| Tear everything down | Stop/remove 3 containers + network | docker 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), notdocker-compose(with a hyphen). The hyphenated version is Compose v1, which is deprecated.
Checkpoint:
docker compose versionreturns 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
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.
# 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
.envto your.gitignoreso 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:
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
healthcheckrunspg_isreadyevery 5 seconds — Compose uses this to know when PostgreSQL is actually ready
app
- Uses the image you built in Module 02
depends_onwithcondition: service_healthymeans 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: appensures 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_onwithcondition: service_healthyis 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-stoppedpolicy means containers come back after a Docker daemon restart or a crash, but stay down if you explicitlydocker stopthem.─────────────────────────────────────────────────
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 psshows 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/healththrough 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
| Command | What it does |
|---|---|
docker compose up -d | Create and start all services (detached) |
docker compose ps | List running services and their status |
docker compose logs -f [service] | Stream logs |
docker compose stop | Stop containers (preserves state) |
docker compose start | Start stopped containers |
docker compose restart [service] | Restart one or all services |
docker compose down | Remove containers + network |
docker compose down -v | Remove 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_isreadyis 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 respondretries— how many consecutive failures before marking the container asunhealthy
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}}'returnshealthy.
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:
- App is running:
docker compose ps app - App logs:
docker compose logs app - Nginx config uses
proxy_pass http://app:8080(container name, not IP) - 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:
.envis in~/customerapp/(same directory as the Compose file)- Variable names match exactly (case-sensitive)
- No spaces around
=in the.envfile
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
| Capability | Verification Command |
|---|---|
| Three-service stack defined in YAML | cat ~/customerapp/docker-compose.yml |
| All services running with one command | docker compose ps — three services up |
| Health check on PostgreSQL | docker inspect postgres --format '{{.State.Health.Status}}' |
| Nginx reverse proxy on port 80 | curl http://localhost/health |
Environment variables from .env file | docker compose exec app env | grep DB_HOST |
| Selective service restart | docker compose restart app |
| Single-command teardown | docker 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.