Overview
This tutorial walks you through building and managing multi-container applications using Docker Compose. You'll go from a blank directory to a fully functional stack with a web app, database, and reverse proxy — all wired together with a single YAML file.
Prerequisites:
- Docker Engine 20.10+ installed
- Docker Compose v2 (ships with Docker Desktop or install via
apt install docker-compose-plugin) - Basic understanding of containers and networking concepts
- Familiarity with YAML syntax
Why this matters for the CKAD: The CKAD exam tests your ability to design and deploy multi-container pods, manage ConfigMaps and Secrets, define resource limits, and handle inter-container communication. Docker Compose mirrors these patterns — services map to containers, networks map to pod networking, and volumes map to PersistentVolumeClaims. Mastering Compose builds the mental model you need before working with Kubernetes manifests.
Getting Started
Verify your installation before writing any configuration:
docker --version
docker compose versionCreate a project directory and the core file structure:
mkdir cloudninjas-app && cd cloudninjas-app
mkdir -p app nginx
touch docker-compose.yml app/Dockerfile nginx/nginx.confHere is the minimal docker-compose.yml to validate everything is wired up:
version: "3.9"
services:
web:
image: nginx:alpine
ports:
- "8080:80"
Start it and confirm it works:
docker compose up -d
curl http://localhost:8080Stop and remove containers before moving to the full stack:
docker compose downBuilding a Real Multi-Container Stack
A production-realistic stack has three concerns: application runtime, data persistence, and traffic routing. The following docker-compose.yml wires all three together.
version: "3.9"
services:
app:
build:
context: ./app
dockerfile: Dockerfile
environment:
- DB_HOST=db
- DB_PORT=5432
- DB_NAME=cloudninjas
- DB_USER=admin
- DB_PASSWORD=${DB_PASSWORD}
depends_on:
db:
condition: service_healthy
networks:
- backend
restart: unless-stopped
db:
image: postgres:15-alpine
environment:
POSTGRES_DB: cloudninjas
POSTGRES_USER: admin
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U admin -d cloudninjas"]
interval: 10s
timeout: 5s
retries: 5
networks:
- backend
restart: unless-stopped
proxy:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- app
networks:
- backend
restart: unless-stopped
volumes:
db_data:
networks:
backend:
driver: bridge
Store secrets in a .env file — never hard-code credentials in the Compose file:
echo "DB_PASSWORD=SecurePass123!" > .env
echo ".env" >> .gitignoreThe Nginx config that proxies traffic to the app service by DNS name (service name resolution is automatic on the Docker network):
# nginx/nginx.conf
server {
listen 80;
location / {
proxy_pass http://app:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_cache_bypass $http_upgrade;
}
}
Common pitfall: Using depends_on with just a service name only waits for the container to start, not for the process inside it to be ready. Always pair it with a healthcheck and condition: service_healthy when your app depends on a database being fully initialized.
Volumes, Networks, and Environment Management
Named volumes persist data beyond container lifecycles. The db_data volume above survives docker compose down but is removed with:
# Remove containers AND volumes (destructive — wipes database)
docker compose down -vUse bind mounts for development so local file changes reflect immediately inside the container:
app:
build: ./app
volumes:
- ./app:/usr/src/app # bind mount for live reload
- /usr/src/app/node_modules # anonymous volume to protect node_modules
For environment management, Compose supports multiple patterns. Prefer env_file for larger configurations:
app:
env_file:
- .env
- .env.local # overrides, not committed to git
Inspect what Compose will resolve at runtime before deploying:
# Show the fully interpolated config
docker compose config
# Show environment variables for a specific service
docker compose run --rm app envNetworking best practices:
- Define explicit named networks — avoid the default network in production stacks
- Use multiple networks to enforce isolation: put your database on a
backendnetwork not accessible to the proxy - Service names are the DNS hostnames —
db,app,proxyresolve automatically within the same network
A hardened two-network layout looks like this:
networks:
frontend:
driver: bridge
backend:
driver: bridge
internal: true # no external internet access
services:
proxy:
networks: [frontend, backend]
app:
networks: [backend]
db:
networks: [backend]
Common pitfall: The internal: true flag blocks outbound internet from that network. Your app won't be able to pull external APIs if it's only on the internal network. Design accordingly.
Exam Tips for Certified Kubernetes Application Developer (CKAD)
The CKAD does not test Docker Compose directly, but the concepts translate 1-to-1 into Kubernetes primitives that are tested heavily.
Key concept mappings:
- Compose service → Kubernetes
DeploymentorPod - environment / env_file →
ConfigMapandSecretwithenvFrom - depends_on + healthcheck →
initContainersandlivenessProbe/readinessProbe - named volume →
PersistentVolumeClaim - bind mount →
hostPathvolume (use sparingly in production) - networks → Kubernetes
NetworkPolicy - ports →
Serviceof typeNodePortorLoadBalancer
Common exam scenarios:
- Deploy a multi-container pod with a sidecar (logging, proxy) — understand how containers in the same pod share network and volumes
- Inject configuration via ConfigMap as environment variables or mounted files
- Add a readinessProbe to delay traffic until the app is healthy
- Use an initContainer to wait for a database before the main container starts
Quick reference commands for exam day:
# Generate a pod manifest quickly — then edit it
kubectl run myapp --image=nginx:alpine --dry-run=client -o yaml > pod.yaml
# Create a ConfigMap from a literal value
kubectl create configmap app-config --from-literal=DB_HOST=db --dry-run=client -o yaml
# Create a Secret from a literal value
kubectl create secret generic db-secret --from-literal=DB_PASSWORD=SecurePass123! --dry-run=client -o yaml
# Check pod logs for a specific container in a multi-container pod
kubectl logs pod/myapp -c sidecar-container
# Execute into a specific container
kubectl exec -it pod/myapp -c app -- /bin/shOn the CKAD exam, use --dry-run=client -o yaml to generate base manifests instead of writing them from scratch. This saves significant time.
Summary
Key takeaways from this tutorial:
- Docker Compose uses a single docker-compose.yml to define, network, and orchestrate multi-container stacks
- Always use healthchecks with condition: service_healthy rather than bare
depends_onfor startup ordering - Store secrets in .env files and never commit them — reference them with
${VAR}interpolation - Use named volumes for persistence and internal networks to isolate your database tier
- Every Compose concept has a direct Kubernetes equivalent — learning Compose first accelerates Kubernetes comprehension
Next steps:
- Convert this Compose stack into Kubernetes manifests using
kompose convert - Add resource limits to your Compose services (
deploy.resources.limits) to practice the same constraints required in CKAD pod specs - Explore Docker Compose watch mode (
docker compose watch) for inner-loop development workflows
Ready to go deeper? Check out the CloudNinjas CKAD Certification Study Guide at cloudninjas.ca/certifications/ckad for full exam prep, practice labs, and mock exams aligned to the current curriculum.

