DevOps

Deploying Applications with Docker Compose

Mayur Dabhi
Mayur Dabhi
April 13, 2026
14 min read

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.

Prerequisites

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:

Docker Host app-network (bridge) nginx :80 β†’ :80 Reverse Proxy app :3000 (internal) Node.js / PHP db :5432 (internal) PostgreSQL redis Cache / Sessions User :80 πŸ“¦ postgres_data Docker Compose Multi-Container Stack

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.

docker-compose.yml
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
depends_on only controls start order

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.

Dockerfile (Node.js, multi-stage)
# ── 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"]
.dockerignore
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.

Service-to-service communication
# 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.

Network segmentation pattern
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.

1

Create a .env file (never commit this)

Store all sensitive values in a .env file at the project root and add it to .gitignore.

.env
POSTGRES_USER=myuser
POSTGRES_PASSWORD=sup3rS3cr3t!
POSTGRES_DB=myapp_prod
REDIS_PASSWORD=r3disPa$$
APP_SECRET_KEY=a1b2c3d4e5f6...
2

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.

docker-compose.yml: environment variable interpolation
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}
3

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.

.env.example (safe to commit)
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)
Development vs Production Volumes

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

Daily Compose Commands
# 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.

docker-compose.yml (base, shared)
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:
docker-compose.dev.yml (development overrides)
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"
docker-compose.prod.yml (production overrides)
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
Running with merged files
# 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 USER in Dockerfile)
  • Never expose database ports (5432, 6379) to the host in production
  • Use read-only filesystem: read_only: true in the service definition
  • Pin image tags: use postgres:16.2-alpine, not postgres:latest
  • Remember that ports published with ports: bypass UFW on most Linux setups, because Docker writes its own iptables rules. Bind to 127.0.0.1:5432:5432 if 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: true on backend networks to block external access

Performance & Reliability

  • Add healthcheck to all stateful services (db, redis, queues)
  • Set restart: unless-stopped on all services
  • Configure mem_limit and cpus to 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
Complete production-hardened service example
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
git push Developer CI/CD Build & push image Registry Docker Hub / ECR Server docker compose pull & up πŸš€ Live CI/CD β†’ Docker Registry β†’ Production Deployment Flow

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.

deploy.sh: production deployment script
#!/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:

A few mistakes I see often with the setup above:

Docker Docker Compose Deployment DevOps Containers Production
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Node.js.