Deploying Applications with Docker Compose
You've got an app container, a Postgres container and a Redis container, and you're starting them with three long docker run commands you keep in a notes file. Then a teammate runs them in the wrong order, the app boots before the database is up, and it crashes with ECONNREFUSED. Or someone forgets the --network flag and nothing can find anything.
Docker Compose fixes that by putting the whole stack in one YAML file you start with one command. This post walks through a real docker-compose.yml, a multi-stage Dockerfile, networking, secrets, volumes, separate dev and prod configs, and a simple deploy script, plus the places where Compose stops being the right tool.
This guide assumes you have Docker Engine (20.10+) and Docker Compose v2 installed. Run docker compose version to verify. All examples use the modern docker compose command (note: no hyphen), which is now built into Docker Desktop and Docker Engine.
Why Docker Compose Exists
Without Compose, a multi-container setup means a pile of docker run commands with long flags for networks, volumes and environment variables. They're easy to get wrong, awkward to share, and hard to reproduce exactly on another machine.
With Compose you describe the stack in a docker-compose.yml file (services, networks, volumes, environment variables, dependencies) and Compose creates and starts everything for you. What you get out of that:
- Reproducibility: Anyone on the team can spin up the exact same environment with
docker compose up - Service isolation: Each container runs in its own process space but communicates via a shared internal network
- Dependency management: Compose starts services in the right order using
depends_on - Single source of truth: Infrastructure-as-code that lives alongside your application code
- Easy teardown:
docker compose downremoves containers, networks, and optionally volumes
A typical Docker Compose application stack with internal networking
Anatomy of a docker-compose.yml File
Compose reads docker-compose.yml (or compose.yaml, the newer preferred name). Here's a full one for a Node.js app with PostgreSQL, Redis and nginx in front.
version: "3.9"
services:
# ββ Application βββββββββββββββββββββββββββββββββββββ
app:
build:
context: .
dockerfile: Dockerfile
container_name: myapp
restart: unless-stopped
environment:
NODE_ENV: production
DATABASE_URL: postgresql://postgres:secret@db:5432/myapp
REDIS_URL: redis://redis:6379
ports:
- "3000:3000"
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
networks:
- app-network
volumes:
- ./uploads:/app/uploads
# ββ Database βββββββββββββββββββββββββββββββββββββββββ
db:
image: postgres:16-alpine
container_name: myapp_db
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: secret
POSTGRES_DB: myapp
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
networks:
- app-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
# ββ Cache βββββββββββββββββββββββββββββββββββββββββββββ
redis:
image: redis:7-alpine
container_name: myapp_redis
restart: unless-stopped
command: redis-server --appendonly yes
volumes:
- redis_data:/data
networks:
- app-network
# ββ Reverse Proxy βββββββββββββββββββββββββββββββββββββ
nginx:
image: nginx:alpine
container_name: myapp_nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/ssl:/etc/nginx/ssl:ro
depends_on:
- app
networks:
- app-network
# ββ Named Volumes ββββββββββββββββββββββββββββββββββββββ
volumes:
postgres_data:
redis_data:
# ββ Networks βββββββββββββββββββββββββββββββββββββββββββ
networks:
app-network:
driver: bridge
One thing you'll notice on a recent Compose v2: the top-level version: "3.9" line produces a warning like the attribute `version` is obsolete, it will be ignored. It's harmless, and you can delete it from your own files. I've left it in the examples as written.
What each key does
| Key | Purpose | Example |
|---|---|---|
build |
Build image from local Dockerfile | build: . |
image |
Pull pre-built image from registry | image: postgres:16-alpine |
ports |
Map host port to container port | "8080:80" (host:container) |
volumes |
Persist data or mount host files | postgres_data:/var/lib/postgresql/data |
networks |
Connect containers on a shared network | app-network |
depends_on |
Control startup order between services | depends_on: db |
restart |
Auto-restart policy on failure | unless-stopped, always |
healthcheck |
Define a health probe for the container | pg_isready -U postgres |
By default, depends_on waits for the other container to start, not for it to be ready to accept connections. To actually wait, pair condition: service_healthy with a healthcheck on that service. Without it, your app can boot while Postgres is still initializing and fail with connection refused errors on the first deploy.
Writing an Optimized Dockerfile
When a service has a build key, Compose builds it from your Dockerfile, and how you write that file decides how fast builds are and how big the image gets. The main trick is a multi-stage build: compile in one stage, then copy only what you need to run into a small final image.
# ββ Stage 1: Build ββββββββββββββββββββββββββββββββββββ
FROM node:20-alpine AS builder
WORKDIR /app
# Copy dependency files first (layer caching)
COPY package*.json ./
RUN npm ci --only=production
# Copy source code
COPY . .
# Build if using TypeScript or a bundler
RUN npm run build
# ββ Stage 2: Production image ββββββββββββββββββββββββββ
FROM node:20-alpine AS runner
# Security: run as non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
# Copy only production artifacts from builder
COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
COPY --from=builder --chown=appuser:appgroup /app/package.json .
USER appuser
EXPOSE 3000
CMD ["node", "dist/server.js"]
node_modules
.git
.gitignore
*.md
.env
.env.*
dist
coverage
.nyc_output
*.log
docker-compose*.yml
Dockerfile*
.dockerignore keeps files out of the build context. That makes builds faster and stops things like .env from being copied into an image layer by COPY . ..
A gotcha in the Dockerfile above: npm ci --only=production skips devDependencies, so if your npm run build needs TypeScript or a bundler from devDependencies, the build step will fail. In that case install everything in the builder stage, build, then run npm prune --omit=dev before copying node_modules across.
Networking Between Services
Compose gives every service a DNS name on its network. Containers on the same Compose network reach each other by service name, so you never hardcode IP addresses.
# In your app, connect to the database using the service name "db"
# NOT localhost or 127.0.0.1, since those refer to the container itself
# Node.js (pg client)
const pool = new Pool({
host: 'db', // β the Compose service name
port: 5432,
database: 'myapp',
user: 'postgres',
password: 'secret',
});
# Redis (ioredis)
const redis = new Redis({
host: 'redis', // β the Compose service name
port: 6379,
});
# Or via connection string in environment variables
DATABASE_URL=postgresql://postgres:secret@db:5432/myapp
REDIS_URL=redis://redis:6379
Multiple Networks for Security Isolation
You can also split the stack into more than one network. The usual pattern is a frontend network (nginx β app) and a backend network (app β db). nginx can't reach the database at all, so a compromised proxy has one less thing to poke at.
services:
nginx:
networks:
- frontend # only on frontend network
app:
networks:
- frontend # speaks to nginx
- backend # speaks to db and redis
db:
networks:
- backend # only on backend, unreachable from nginx
redis:
networks:
- backend
networks:
frontend:
driver: bridge
backend:
driver: bridge
internal: true # no external internet access
Managing Environment Variables Securely
The first example hardcodes POSTGRES_PASSWORD: secret, and that file almost always ends up in Git. Move secrets into a .env file (or Docker secrets) and keep only references in the Compose file.
Create a .env file (never commit this)
Store all sensitive values in a .env file at the project root and add it to .gitignore.
POSTGRES_USER=myuser
POSTGRES_PASSWORD=sup3rS3cr3t!
POSTGRES_DB=myapp_prod
REDIS_PASSWORD=r3disPa$$
APP_SECRET_KEY=a1b2c3d4e5f6...
Reference variables in docker-compose.yml
Compose loads .env from the project directory and fills in ${VAR} placeholders. Watch out for $ inside values like r3disPa$$: Compose can treat it as the start of a variable. Wrapping the value in single quotes makes it literal. Run docker compose config to see the final values Compose will use.
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
app:
environment:
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
SECRET_KEY: ${APP_SECRET_KEY}
Provide a .env.example for teammates
Commit a .env.example file with placeholder values so developers know what variables are required without exposing real secrets.
POSTGRES_USER=your_db_user
POSTGRES_PASSWORD=your_db_password
POSTGRES_DB=your_db_name
REDIS_PASSWORD=your_redis_password
APP_SECRET_KEY=your_secret_key_here
Volumes: Persisting Data
Anything written inside a container's filesystem is gone once the container is removed. Volumes keep data outside the container so it survives rebuilds and docker compose down. These are the mount types you'll use in Compose:
| Type | Syntax | Best For | Managed By |
|---|---|---|---|
| Named Volume | postgres_data:/var/lib/postgresql/data |
Database files, persistent app data | Docker (stored in /var/lib/docker/volumes/) |
| Bind Mount | ./src:/app/src |
Source code, config files, development hot-reload | Host filesystem (your machine) |
| tmpfs Mount | tmpfs: /run/secrets |
Secrets, temporary in-memory data | Host RAM (not persisted) |
In development, use bind mounts (./src:/app/src) so code changes show up without a rebuild. In production, bake the code into the image and keep named volumes for data that has to persist, like database files and uploads.
Commands I Use Every Day
# Start all services (detached/background mode)
docker compose up -d
# Start and rebuild images before starting
docker compose up -d --build
# View running containers
docker compose ps
# View logs (all services)
docker compose logs -f
# View logs for a specific service
docker compose logs -f app
# Stop all services (keeps containers)
docker compose stop
# Stop and remove containers + networks
docker compose down
# Stop and remove containers + networks + volumes (β οΈ deletes data!)
docker compose down -v
# Execute a command in a running container
docker compose exec app sh
docker compose exec db psql -U postgres -d myapp
# Scale a service to N replicas
docker compose up -d --scale app=3
# Pull latest images for all services
docker compose pull
# Rebuild a specific service image
docker compose build app
Development vs Production Compose Files
Dev and prod want different things: hot reload and exposed ports locally, resource limits and no exposed database in production. I split it into a base docker-compose.yml, a docker-compose.dev.yml and a docker-compose.prod.yml, and let Compose merge them with repeated -f flags.
version: "3.9"
services:
app:
build: .
environment:
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
networks:
- app-network
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- app-network
volumes:
postgres_data:
networks:
app-network:
version: "3.9"
services:
app:
# Override: mount source for hot reload
volumes:
- .:/app
- /app/node_modules # anonymous volume protects node_modules
environment:
NODE_ENV: development
command: npm run dev # Use nodemon or tsx watch
ports:
- "3000:3000"
- "9229:9229" # Node.js debugger port
db:
# Expose DB port for local DB clients (TablePlus, DBeaver)
ports:
- "5432:5432"
version: "3.9"
services:
app:
restart: unless-stopped
environment:
NODE_ENV: production
# No ports here; nginx handles incoming traffic
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
nginx:
image: nginx:alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./certbot/conf:/etc/letsencrypt:ro
depends_on:
- app
# Development
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
# Production
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
# Alternatively, set COMPOSE_FILE environment variable
export COMPOSE_FILE=docker-compose.yml:docker-compose.prod.yml
docker compose up -d
Production Deployment Checklist
The things I check before a Compose stack goes on a real server:
Security Hardening
- Run containers as non-root users (use
USERin Dockerfile) - Never expose database ports (
5432,6379) to the host in production - Use read-only filesystem:
read_only: truein the service definition - Pin image tags: use
postgres:16.2-alpine, notpostgres:latest - Remember that ports published with
ports:bypass UFW on most Linux setups, because Docker writes its own iptables rules. Bind to127.0.0.1:5432:5432if you only need local access - Store secrets in Docker Secrets or a vault (Vault by HashiCorp, AWS Secrets Manager)
- Enable
no-new-privileges:security_opt: ["no-new-privileges:true"] - Use
internal: trueon backend networks to block external access
Performance & Reliability
- Add
healthcheckto all stateful services (db, redis, queues) - Set
restart: unless-stoppedon all services - Configure
mem_limitandcpusto prevent resource hogging - Use named volumes for all data that must survive container restarts
- Enable PostgreSQL connection pooling via PgBouncer to handle traffic spikes
- Configure nginx with gzip compression and proper proxy headers
services:
app:
image: myregistry.com/myapp:1.4.2 # pinned tag from CI/CD
restart: unless-stopped
read_only: true
tmpfs:
- /tmp # writable temp directory
security_opt:
- no-new-privileges:true
user: "1001:1001"
environment:
NODE_ENV: production
DATABASE_URL: ${DATABASE_URL}
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
reservations:
memory: 256M
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks:
- frontend
- backend
A typical CI/CD pipeline using Docker Compose for deployment
Zero-Downtime Deployments
Compose has no rolling updates (that's Swarm or Kubernetes territory). With one server you can still keep the gap short: pull images first, recreate only the app container, and check its health before calling the deploy done.
#!/bin/bash
set -e
echo "π Pulling latest images..."
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull
echo "π Starting new containers..."
# --no-deps: don't restart linked services
# --build: rebuild if using build context
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --no-deps app
echo "β³ Waiting for health check..."
sleep 10
# Check if container is healthy before finishing
HEALTH=$(docker inspect --format='{{.State.Health.Status}}' myapp)
if [ "$HEALTH" != "healthy" ]; then
echo "β Container is not healthy! Rolling back..."
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --no-deps --scale app=0 app
exit 1
fi
echo "π§Ή Removing old images..."
docker image prune -f
echo "β
Deployment complete!"
Be honest about what this script does. The sleep 10 is a guess, and with a start_period of 40s the container may still report starting at that point. Poll the health status in a loop instead. And the "rollback" branch just scales the app to zero, which is an outage, not a rollback. A real rollback means redeploying the previous image tag, so keep that tag around.
When I'd use Compose in production (and when I wouldn't)
Compose is a good fit when the whole app lives on one server: a Laravel or Node app with its database, Redis and a queue worker, an internal tool, a side project, a staging box. You get a config file in Git, a deploy that's a pull and an up -d, and very little to operate.
I wouldn't reach for it when:
- You need more than one host. Compose doesn't schedule containers across machines or move them when a server dies. That's what Swarm, Kubernetes, ECS or Nomad are for.
- You need true zero downtime. Recreating a container always leaves a short gap. You can work around it with two app services behind nginx, but at that point you're rebuilding an orchestrator by hand.
- The database is important and you're the only ops person. Running Postgres in a container is fine, but backups, upgrades and disk monitoring are now your job. A managed database is often the cheaper choice once you count your time.
A few mistakes I see often with the setup above:
container_nameand--scaledon't mix. Withcontainer_name: myappset,docker compose up --scale app=3fails because container names have to be unique. Dropcontainer_namefor any service you want to scale.- Running
docker compose down -vout of habit and wiping the database volume. - Using
localhostin a connection string inside a container, then wondering why it can't reach the database. - Shipping with
:latesttags, so two servers "on the same version" are running different images.