How to Set Up Docker Compose and Dockerfile for Dev vs Production
Setting up Docker Compose for dev vs production starts with a single multi-stage Dockerfile with named build targets, combined with Docker Compose's built-in file override system (docker-compose.yml + docker-compose.override.yml) to layer environment-specific configuration. This approach keeps one source of truth for your image while letting each environment diverge where it needs to. You get volume mounts and hot-reload in dev, optimized images and restart policies in production. Avoid maintaining two separate Dockerfiles; they always drift apart.
Multi-Stage Dockerfile with Named Targets
The foundation is a single Dockerfile that defines a base stage for shared dependencies, a development stage that installs dev tools and sets a dev entrypoint, and a production stage that copies only the built artifact into a minimal image. You select which stage to build with --target or the target key in Compose.
# Shared dependencies for all environments.
FROM node:20-alpine AS base
WORKDIR /app
COPY package.json package-lock.json ./
# Dev stage: install everything, enable hot-reload.
FROM base AS development
RUN npm ci
COPY . .
CMD ["npm", "run", "dev"]
# Build stage: compile production assets.
FROM base AS build
RUN npm ci --ignore-scripts
COPY . .
RUN npm run build
# Production stage: minimal image with only compiled output.
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
CMD ["node", "dist/server.js"]
Base Docker Compose File for Shared Configuration
Your docker-compose.yml defines everything that is identical across environments: service names, network topology, and the base image build context. Keep this file environment-agnostic. Don't put volume mounts, port bindings to the host, or restart policies here unless they're truly shared.
# docker-compose.yml
services:
api:
build:
context: .
dockerfile: Dockerfile
env_file:
- .env
networks:
- backend
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
networks:
- backend
networks:
backend:
Development Override File for Hot-Reload
Docker Compose automatically merges docker-compose.override.yml on top of docker-compose.yml when you run docker compose up with no -f flags. This is your dev configuration. Bind-mount your source code for hot-reload, target the development build stage, and expose debug ports. The merge is deep: it adds keys rather than replacing the entire service block.
# docker-compose.override.yml (auto-loaded in development)
services:
api:
build:
target: development
volumes:
# Mount source code for hot-reload.
- .:/app
# Prevent host node_modules from overwriting container's.
- /app/node_modules
ports:
- "3000:3000"
- "9229:9229"
postgres:
ports:
- "5432:5432"
volumes:
- pgdata_dev:/var/lib/postgresql/data
volumes:
pgdata_dev:
Production Docker Compose File
For production, create docker-compose.prod.yml and invoke it explicitly with docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d. This skips the override file entirely. Target the production build stage, add restart policies, set resource limits, and avoid bind-mounting source code. Never expose database ports to the host in production.
# docker-compose.prod.yml
services:
api:
build:
target: production
restart: unless-stopped
ports:
- "80:3000"
deploy:
resources:
limits:
memory: 512M
cpus: "1.0"
postgres:
restart: unless-stopped
volumes:
- pgdata_prod:/var/lib/postgresql/data
volumes:
pgdata_prod:
Running Each Environment
The commands are straightforward. In development, Compose auto-loads the override file, so a plain up is all you need. In production, you explicitly specify the files to merge. Wrapping these in a Makefile or shell aliases prevents mistakes.
# Development: auto-merges docker-compose.yml + docker-compose.override.yml.
docker compose up --build
# Production: explicitly choose the prod overlay, skip override.
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
# Validate the merged result without starting anything.
docker compose -f docker-compose.yml -f docker-compose.prod.yml config
Managing Environment Variables in Docker Compose
Use separate .env files per environment rather than hardcoding values. Compose supports an --env-file flag, and individual services support env_file lists. Keep secrets out of images. Pass them at runtime through environment variables or Docker secrets, and never bake them into build args that persist in image layers.
# .env.production
DB_NAME=myapp
DB_USER=myapp_user
DB_PASSWORD=strong-random-password
NODE_ENV=production
# Run production with a specific env file.
docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file .env.production up -d
Common Gotchas and Pitfalls
The anonymous volume trick is essential. When you bind-mount .:/app in development, the host's empty node_modules directory shadows the container's installed packages. The anonymous volume mount /app/node_modules in the override file prevents this by telling Docker to preserve the container's own copy. Without it, your app crashes with missing module errors.
docker-compose.override.yml loads automatically unless you use -f. This is the most common source of accidental dev-config-in-production bugs. The moment you pass any -f flag, Compose stops auto-loading the override. That's why the production command explicitly lists only the base and prod files. Verify your merged output with docker compose config before deploying.
Build cache invalidation order matters. In your Dockerfile, copy package.json and run npm ci before copying the rest of your source code. Docker caches each layer, and if you copy all source files first, every code change invalidates the expensive dependency-install layer. This single ordering choice can cut build times from minutes to seconds.
Don't use latest as a base image tag. Pin to specific versions like node:20-alpine so your builds are reproducible. A silently updated base image breaks production deploys more often than most people care to admit.