Intro
A production-like local environment pays for itself quickly. When your app, database, cache, and proxy run together under Docker Compose with realistic settings, you reveal integration issues early, debug faster, and reduce surprises later. This guide shows how to build that setup with services, networks, volumes, environment files, health checks, and a practical debugging workflow.
Workflow Overview
Use a simple loop that mirrors how software is delivered:
- Plan
- List services and how they talk (ports, protocols, env vars).
- Decide networks, volumes, and health criteria for each service.
- Build
- Create production-like Dockerfiles (small images, non-root users, pinned versions).
- Use docker build or let Compose build from your Dockerfile.
- Run
- Start the stack with docker compose up -d.
- Wait for health checks to go healthy.
- Test
- Send traffic through the public entry point (for example, Nginx on port 80).
- Validate readiness, liveness, and core use cases.
- Iterate
- Change configs or code, rebuild, and rerun.
This clear separation of steps helps keep changes understandable and reduces rework.
Compose Design
Below is a practical docker-compose.yml for a small web stack: a Node.js app, Postgres, Redis, and Nginx as the edge proxy. It uses two networks (frontend and backend), named volumes for data, env files for settings, and health checks with startup ordering.
version: '3.9'
services:
web:
build:
context: ./web
image: local/web: dev
ports:
- '8080:3000'
env_file:
- ./env/web.env
environment:
- NODE_ENV=development
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ['CMD-SHELL', 'wget -qO- http://localhost:3000/health || exit 1']
interval: 10s
timeout: 3s
retries: 5
start_period: 15s
volumes:
- ./web:/app: cached
- web-node-modules:/app/node_modules
networks:
- frontend
- backend
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: appdb
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U app -d appdb']
interval: 10s
timeout: 5s
retries: 5
networks:
- backend
redis:
image: redis:7-alpine
command: ['redis-server', '--appendonly', 'yes']
volumes:
- redis-data:/data
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 10s
timeout: 3s
retries: 5
networks:
- backend
nginx:
image: nginx:1.25-alpine
depends_on:
web:
condition: service_healthy
ports:
- '80:80'
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d: ro
networks:
- frontend
volumes:
db-data:
redis-data:
web-node-modules:
networks:
frontend:
backend:
Nginx config example
Route traffic on port 80 to the web service.
# ./nginx/conf.d/app.conf
server {
listen 80;
server_name _;
location / {
proxy_pass http://web:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /healthz {
return 200 'ok';
add_header Content-Type text/plain;
}
}
Node.js Dockerfile example
Keep it close to production: minimal base, non-root, pinned version, and a simple health endpoint in your app at /health.
# ./web/Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
RUN addgroup -S app && adduser -S app -G app && chown -R app: app /app
USER app
EXPOSE 3000
CMD node server.js
Minimal Node app example
// ./web/server.js
const express = require('express');
const app = express();
const port = 3000;
app.get('/health', (req, res) => res.send('ok'));
app.get('/', (req, res) => res.send('Hello from web via Nginx'));
app.listen(port, () => console.log(`web listening on ${port}`));
Env and Secrets
Use a project-level .env file for Compose variables, and per-service env files for app settings.
- Project .env controls Compose behavior and shared values.
- Service env files control application settings.
- Do not commit real secrets. Provide sample files and load real values from a secure location.
Example project .env:
# ./.env
COMPOSE_PROJECT_NAME=prodlike
WEB_PORT=8080
Example service env file:
# ./env/web.env
PORT=3000
DATABASE_URL=postgresql://app: app@db:5432/appdb
REDIS_URL=redis://redis:6379/0
You can reference ${WEB_PORT} in your compose file if you prefer to avoid hardcoding ports.
Health Checks and Order
Health checks make dependency readiness explicit. Use healthcheck in each service and depends_on with condition: service_healthy in dependents. This prevents the web from failing because the database is not ready yet.
- Web health: GET /health returns 200.
- Postgres health: pg_isready passes.
- Redis health: redis-cli ping returns PONG.
Compose will show health in docker compose ps so you can see when the stack is ready.
Bring it up, test it, and iterate
Start the stack:
docker compose up -d --build
Check status and health:
docker compose ps
docker compose logs -f --tail=100 web db redis nginx
Test through the proxy like a user would:
curl -sS http://localhost/
Hot-reload options:
- Bind-mount ./web into the container for rapid local edits.
- If you need nodemon, add it to devDependencies and run it with an override compose file only for local use.
Rebuild when Dockerfile changes:
docker compose build web && docker compose up -d web
Debugging and Troubleshooting
Common checks and fixes:
- Port conflicts
- Symptom: docker compose up fails to expose a port.
- Fix: change host ports (e.g., WEB_PORT), or stop the conflicting process.
- Health check keeps failing
- Check container logs: docker compose logs web.
- Exec into the container and curl the health endpoint locally: docker compose exec web sh -lc 'wget -qO- http://localhost:3000/health'.
- Verify env variables are set: docker compose exec web env | sort.
- App cannot reach db or redis
- Verify DNS within the network: docker compose exec web getent hosts db redis.
- Test TCP reachability: docker compose exec web sh -lc 'nc -zv db 5432' and 'nc -zv redis 6379'.
- Volume permissions on Linux
- Symptom: EACCES on write.
- Fix: set the same UID/GID inside the container as your host user, or chown the workdir during build as shown in the Dockerfile.
- Slow file sync on macOS/Windows
- Symptom: slow hot reload.
- Fix: use :cached option, move heavy dependencies to a named volume (web-node-modules), or build images more often instead of live-mounting everything.
- Data resets unexpectedly
- Ensure you use named volumes (db-data, redis-data) rather than anonymous volumes.
- Removing with docker compose down -v will delete named volumes; use with care.
- Environment not applied
- Check resolution order: .env, shell, and env_file.
- Print env: docker compose config to see the final merged config.
Useful commands:
docker compose config
docker compose ps --format json | jq '.'
docker compose logs -f web
docker compose exec -it web sh
docker compose inspect web
# Networks and volumes
docker network ls
docker network inspect prodlike_backend
docker volume ls
Local Pilot Plan
Start small, measure, then expand.
Scope
- Services: web + db only.
- Success criteria:
- Time from docker compose up -d to both services healthy is under 15 seconds on your machine.
- curl http://localhost/ returns 200 and expected content.
- docker compose restart web recovers to healthy within 5 seconds.
Steps
- Implement health endpoints and checks for web and db.
- Add a named volume for Postgres data.
- Wire env via ./env/web.env and ./.env.
- Measure baseline timings with the commands in this guide.
- Add Redis and Nginx when the pilot meets the success criteria.
Why this works
- Narrow scope is easier to debug and reason about.
- Clear, measurable outcomes tell you when to grow the stack.
Conclusion
A production-like Docker Compose setup gives you fast, realistic feedback without leaving your laptop. Model your stack with services, networks, and volumes; wire configuration through env files; and use health checks and a repeatable workflow to reduce rework. Start with a narrow pilot, measure results, and expand toward full parity.