DEV Community

devtocash
devtocash

Posted on Originally published at devtocash.com

Docker Compose for Development Environments: Best Practices 2026

💡 Originally published on devtocash.com — where this guide stays updated. I write hands-on DevOps/SRE deep-dives there weekly.

Introduction

Every developer has experienced the "it works on my machine" problem. Docker Compose solves this by giving every team member an identical, reproducible environment — no more missing dependencies, version conflicts, or OS-specific quirks.

In 2026, Docker Compose has matured significantly. With Compose v2.30+, GPU support, Watch mode, and include for multi-file projects, it's the backbone of modern local development workflows.

Here's what we'll cover:

  • Structuring your Compose files for real projects
  • Hot reload with bind mounts and Watch mode
  • Multi-service orchestration (app + database + cache + queue)
  • Debugging inside containers with VS Code
  • Production parity and environment management
  • Performance tuning for fast builds

Let's build a development environment you'll actually enjoy using.

Project Structure: Where Do Files Go?

A clean project structure is half the battle. Here's what I recommend:

project/
├── docker-compose.yml          # Main compose file
├── docker-compose.override.yml # Dev overrides (auto-loaded)
├── .env                         # Environment variables
├── backend/
│   ├── Dockerfile
│   ├── Dockerfile.dev
│   └── src/
├── frontend/
│   ├── Dockerfile
│   └── src/
└── docker/
    ├── nginx/
    │   └── default.conf
    └── postgres/
        └── init.sql
Enter fullscreen mode Exit fullscreen mode

The key insight: docker-compose.override.yml is automatically merged with docker-compose.yml when both exist. This means your base file defines production-like configuration, and the override adds development-specific tweaks like bind mounts, exposed ports, and debug flags.

docker-compose.yml (Base Configuration)

version: "3.9"

services:
  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile
    image: myapp-backend:latest
    environment:
      - NODE_ENV=production
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_started
    networks:
      - app-network

  postgres:
    image: postgres:17-alpine
    environment:
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: ${DB_NAME}
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
      interval: 5s
      timeout: 5s
      retries: 5
    networks:
      - app-network

  redis:
    image: redis:7-alpine
    networks:
      - app-network

networks:
  app-network:
    driver: bridge

volumes:
  pgdata:
Enter fullscreen mode Exit fullscreen mode

This base configuration is clean and close to what you'd run in production. Now let's add the development override.

docker-compose.override.yml (Development Magic)

services:
  backend:
    build:
      dockerfile: Dockerfile.dev
    ports:
      - "3000:3000"
      - "9229:9229"  # Node.js debugger
    environment:
      - NODE_ENV=development
      - DEBUG=myapp:*
    volumes:
      - ./backend/src:/app/src          # Hot reload source
      - ./backend/package.json:/app/package.json
      - /app/node_modules               # Anonymous volume
    command: npm run dev
    develop:
      watch:
        - action: sync
          path: ./backend/src
          target: /app/src
        - action: rebuild
          path: ./backend/package.json

  postgres:
    ports:
      - "5432:5432"

  redis:
    ports:
      - "6379:6379"
Enter fullscreen mode Exit fullscreen mode

When you run docker compose up, both files merge automatically. No need for -f flags.

Hot Reload: Bind Mounts vs Watch Mode

Method 1: Bind Mounts (Tried and True)

volumes:
  - ./backend/src:/app/src
command: npm run dev  # Uses nodemon/ts-node-dev
Enter fullscreen mode Exit fullscreen mode

Works with any language that has a file watcher. The host and container share the same filesystem — when you save a file on your host, it instantly appears inside the container.

Downsides: Slow on macOS (osxfs overhead). Large node_modules can cause issues.

Method 2: Compose Watch (New in 2026)

develop:
  watch:
    - action: sync
      path: ./backend/src
      target: /app/src
    - action: rebuild
      path: ./backend/package.json
    - action: sync+restart
      path: ./backend/tsconfig.json
      target: /app/tsconfig.json
Enter fullscreen mode Exit fullscreen mode

Available actions:

  • sync: Copy changed files into the running container (fast, no restart)
  • rebuild: Rebuild the image and restart the container
  • sync+restart: Copy files then restart the container

This is significantly faster than bind mounts on macOS because it copies only changed files instead of sharing the entire filesystem.

Multi-Service Orchestration: A Real Example

Here's a typical web application stack:

services:
  nginx:
    image: nginx:1.27-alpine
    ports:
      - "80:80"
    volumes:
      - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
    depends_on:
      - backend
      - frontend

  backend:
    build: ./backend
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_started
    environment:
      DATABASE_URL: postgresql://${DB_USER}:${DB_PASSWORD}@postgres:5432/${DB_NAME}
      REDIS_URL: redis://redis:6379

  frontend:
    build: ./frontend
    environment:
      API_URL: http://backend:3000

  worker:
    build: ./backend
    command: npm run worker
    depends_on:
      - redis
      - postgres

  postgres: ...
  redis: ...
Enter fullscreen mode Exit fullscreen mode

Pro tip: Use condition: service_healthy instead of bare depends_on. This waits for the database to actually accept connections, not just for the container to start.

Debugging Inside Containers with VS Code

Add this to your backend's Dockerfile.dev:

FROM node:22-alpine
WORKDIR /app
RUN npm install -g nodemon
EXPOSE 3000 9229
CMD ["nodemon", "--inspect=0.0.0.0:9229", "src/index.js"]
Enter fullscreen mode Exit fullscreen mode

Then in VS Code, create .vscode/launch.json:

{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "attach",
      "name": "Docker: Attach to Backend",
      "port": 9229,
      "address": "localhost",
      "localRoot": "${workspaceFolder}/backend/src",
      "remoteRoot": "/app/src",
      "restart": true
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Set breakpoints, hit F5, and you're debugging code running inside Docker. The localRoot and remoteRoot mapping ensures VS Code can match source files.

Environment Management

.env file

DB_USER=myapp
DB_PASSWORD=dev-secret-123
DB_NAME=myapp_dev
JWT_SECRET=local-dev-only
Enter fullscreen mode Exit fullscreen mode

Per-environment overrides

# Start with dev configuration
docker compose up

# Override with staging
docker compose -f docker-compose.yml -f docker-compose.staging.yml up

# Production (no override)
docker compose -f docker-compose.yml up
Enter fullscreen mode Exit fullscreen mode

Security note: Never commit .env with real secrets. Use .env.example as a template:

DB_USER=myapp
DB_PASSWORD=change_me
JWT_SECRET=change_me
Enter fullscreen mode Exit fullscreen mode

Performance Tuning

1. Use BuildKit

DOCKER_BUILDKIT=1 docker compose build
Enter fullscreen mode Exit fullscreen mode

Or set permanently:

echo '{"features":{"buildkit":true}}' | sudo tee /etc/docker/daemon.json
Enter fullscreen mode Exit fullscreen mode

2. Layer Caching Strategy

# Bad: invalidates cache on every source change
COPY . .
RUN npm install

# Good: install deps first, copy source later
COPY package.json package-lock.json ./
RUN npm ci
COPY src/ ./src/
Enter fullscreen mode Exit fullscreen mode

3. .dockerignore

node_modules
.git
.env
*.log
dist
coverage
Enter fullscreen mode Exit fullscreen mode

This dramatically speeds up the build context transfer.

4. Use Alpine Images

Image Size
node:22 1.1 GB
node:22-alpine 130 MB

The smaller image means faster pulls, less disk usage, and quicker CI builds.

Common Pitfalls & Solutions

"Port already in use"

# Find what's using port 5432
sudo lsof -i :5432
# Stop local postgres
sudo systemctl stop postgresql
Enter fullscreen mode Exit fullscreen mode

"Changes not reflecting"

# Force rebuild without cache
docker compose build --no-cache backend
docker compose up -d
Enter fullscreen mode Exit fullscreen mode

"node_modules disappears after npm install"

volumes:
  - ./backend:/app
  - /app/node_modules  # Anonymous volume prevents overwrite
Enter fullscreen mode Exit fullscreen mode

The anonymous volume at /app/node_modules takes priority over the bind mount for that specific path.

Conclusion

Docker Compose in 2026 is more than just a "dev tool" — it's the foundation of a professional development workflow. The combination of base + override files, Watch mode, and proper debugging setup means you can onboard new developers in minutes, not days.

Key takeaways:

  1. Split base and override files — docker-compose.yml + docker-compose.override.yml keeps dev and prod concerns separate
  2. Use Compose Watch on macOS for faster hot reload than bind mounts
  3. Always add healthchecks — depends_on without them is a race condition
  4. Set up VS Code remote debugging — you'll thank yourself later
  5. Layer your Dockerfiles — copy package.json first, then source

Related Reading

The next step: take this Compose setup into CI with docker compose -f docker-compose.yml -f docker-compose.ci.yml up --abort-on-container-exit for integration tests. But that's a topic for another article.


📌 Read the latest version of this guide — plus the full library of DevOps, SRE, Kubernetes, observability & cloud-cost guides — on devtocash.com.

Top comments (0)