“Works on my machine” is a classic developer problem. One developer runs Python 3.11, another runs 3.13. One uses PostgreSQL 15, another uses 16. The app behaves differently on every machine.

Docker solves this. With Docker, your entire development environment — app, database, cache, message queue — is defined in a YAML file. Every developer on your team runs identical environments with one command.

Why Use Docker for Development?

  • Consistent environments — everyone runs the same versions of everything
  • No installation required — no need to install PostgreSQL, Redis, or Node locally
  • Easy onboarding — new team members run docker compose up and have everything
  • Clean isolation — each project has its own versions, no conflicts between projects
  • Production parity — dev environment matches production as closely as possible

The Development Compose Pattern

A common pattern is to have two Compose files:

  • docker-compose.yml — base configuration (works for both dev and prod)
  • docker-compose.override.yml — development overrides (automatically applied when you run docker compose up)

Docker Compose automatically merges docker-compose.override.yml on top of docker-compose.yml.

docker-compose.yml (base):

services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "8080:8080"
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:17
    environment:
      POSTGRES_USER: alex
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: myapp
    volumes:
      - db_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U alex -d myapp"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  db_data:

docker-compose.override.yml (dev overrides):

services:
  api:
    build:
      dockerfile: Dockerfile.dev  # use the dev Dockerfile
    environment:
      APP_ENV: development
      LOG_LEVEL: debug
    develop:
      watch:
        - action: sync
          path: ./src
          target: /app/src
        - action: rebuild
          path: requirements.txt

  db:
    ports:
      - "5432:5432"  # expose db port locally (for GUI tools)

In production, you deploy only docker-compose.yml and do not apply the override.

Compose Watch Mode: The Best Dev Experience

Compose Watch Mode (now stable, requires Docker Compose 2.22+) is the modern way to do hot reload with Docker. It watches your files and syncs changes into running containers automatically.

Start with watch mode:

docker compose watch

Or:

docker compose up --watch

Three Watch Actions

sync — copy changed files into the running container without restarting. Best for interpreted languages:

develop:
  watch:
    - action: sync
      path: ./src/python
      target: /app/src

Save a Python file → it is instantly available inside the container. The running server (Flask/FastAPI with --reload) picks it up automatically.

rebuild — rebuild the image and restart the container when a file changes. Best for compiled languages:

develop:
  watch:
    - action: rebuild
      path: ./src/go

Save a Go file → Docker rebuilds the image and restarts the container. Slower than sync, but necessary for compiled code.

sync+restart — sync the file then restart the container process. Best for config files:

develop:
  watch:
    - action: sync+restart
      path: ./config/nginx.conf
      target: /etc/nginx/conf.d/default.conf

Hot Reload for Python/FastAPI

# docker-compose.override.yml
services:
  api:
    build:
      context: .
      dockerfile: Dockerfile.dev
    command: uvicorn main:app --reload --host 0.0.0.0 --port 8080
    develop:
      watch:
        - action: sync
          path: ./app
          target: /app/app
        - action: rebuild
          path: requirements.txt

Dockerfile.dev (for Python development):

FROM python:3.13-slim

WORKDIR /app

# Install dev dependencies too
COPY requirements.txt requirements-dev.txt ./
RUN pip install --no-cache-dir -r requirements.txt -r requirements-dev.txt

COPY . .

CMD ["uvicorn", "main:app", "--reload", "--host", "0.0.0.0", "--port", "8080"]

The --reload flag makes uvicorn watch for Python file changes and reload automatically. Combined with Compose sync, changes appear instantly in the running container.

Hot Reload for Node.js

services:
  api:
    build: .
    command: node --watch src/index.js
    develop:
      watch:
        - action: sync
          path: ./src
          target: /app/src
        - action: rebuild
          path: package.json

Node.js 18+ has built-in --watch mode. No need for nodemon.

Rebuild Workflow for Go

Go must be compiled. Use rebuild action:

services:
  api:
    build:
      context: .
      dockerfile: Dockerfile.dev
    develop:
      watch:
        - action: rebuild
          path: .
          ignore:
            - "*.md"
            - ".git"

Dockerfile.dev (Go):

FROM golang:1.24-alpine

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN go build -o myapp .

CMD ["./myapp"]

Database Setup for Development

Here is a development setup with PostgreSQL and Redis, including GUI tools:

services:
  api:
    build: .
    environment:
      DATABASE_URL: postgresql://alex:secret@db:5432/myapp
      REDIS_URL: redis://cache:6379

  db:
    image: postgres:17
    environment:
      POSTGRES_USER: alex
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: myapp
    ports:
      - "5432:5432"  # Access from host with psql or GUI
    volumes:
      - db_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U alex -d myapp"]
      interval: 10s
      retries: 5

  pgadmin:
    image: dpage/pgadmin4
    profiles:
      - debug
    environment:
      PGADMIN_DEFAULT_EMAIL: alex@example.com
      PGADMIN_DEFAULT_PASSWORD: admin
    ports:
      - "5050:80"
    depends_on:
      - db

  cache:
    image: redis:7-alpine
    ports:
      - "6379:6379"

  redis-insight:
    image: redis/redisinsight:latest
    profiles:
      - debug
    ports:
      - "5540:5540"

volumes:
  db_data:

Start with debug tools:

docker compose --profile debug up

Start without debug tools:

docker compose up

Devcontainers: Full Dev Environment in a Container

Devcontainers go one step further. Instead of just running your services in Docker, you run your entire development environment — including your code editor’s tools, extensions, and terminal — inside a container.

VS Code has first-class support via the Dev Containers extension.

Create .devcontainer/devcontainer.json:

{
  "name": "My App Dev",
  "dockerComposeFile": ["../docker-compose.yml", "docker-compose.dev.yml"],
  "service": "api",
  "workspaceFolder": "/workspace",
  "extensions": [
    "ms-python.python",
    "ms-python.pylance",
    "ms-azuretools.vscode-docker"
  ],
  "settings": {
    "python.defaultInterpreterPath": "/usr/local/bin/python"
  },
  "postCreateCommand": "pip install -r requirements-dev.txt"
}

When you open the project in VS Code, it asks if you want to “Reopen in Container”. Say yes, and VS Code runs inside your Docker container. Your terminal, Python interpreter, linter — everything runs inside Docker.

Benefits:

  • Zero local installation needed (just VS Code + Docker)
  • Everyone on the team has identical development tools
  • CI and local environments match exactly

Environment Variable Management

Use a layered approach:

  1. .env — default values committed to git (no secrets)
  2. .env.local — local overrides, not committed to git

.env (committed, no secrets):

APP_ENV=development
LOG_LEVEL=debug
DB_NAME=myapp
DB_USER=alex
API_PORT=8080

.env.local (not committed, has secrets):

DB_PASSWORD=my_local_password
API_SECRET_KEY=dev_secret_key_123

In docker-compose.yml, use env_file::

services:
  api:
    env_file:
      - .env
      - .env.local  # override with local values

Add to .gitignore:

.env.local
.env.*.local

Common Mistakes

Using sync watch mode for compiled languages

If you use action: sync for a Go or Rust project, Docker copies your source files into the container — but the binary does not get rebuilt. The container keeps running the old binary. Use action: rebuild for compiled languages.

Mounting the entire project as a bind mount in production

In development, bind mounting the code is fine. In production, copy the code into the image with COPY and use named volumes only for data. Bind mounting in production creates a dependency on the host filesystem.

Committing .env files with secrets to git

Keep secrets out of git. Use separate .env.local files for local secrets and a secrets manager (AWS Secrets Manager, HashiCorp Vault) for production. The .env file committed to git should only contain non-sensitive defaults.

What’s Next?

Next: Docker Tutorial #9: Docker Security Best Practices