Engineering

Docker for Development: Consistent Environments, No 'Works on My Machine'

May 2026 · 11 min read

"Works on my machine" is a solvable problem. A 50-line docker-compose.yml gives every developer on the team the same Postgres, Redis, and app runtime. New joiners are productive in 10 minutes instead of half a day. Here's the pattern we deploy on every new project.

Project structure

my-app/
├── docker-compose.yml          # services for dev
├── docker-compose.prod.yml     # overrides for prod-like local
├── Dockerfile                   # multi-stage build
├── .env.example                 # template for .env
├── .env                         # gitignored, per-developer secrets
├── src/                         # app source
└── scripts/
    └── wait-for-postgres.sh

docker-compose.yml

version: "3.9"

services:
  app:
    build:
      context: .
      target: dev
    ports:
      - "8000:8000"
    volumes:
      - ./src:/app/src      # hot reload
      - /app/node_modules   # don't shadow with host
    env_file: .env
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    command: npm run dev

  postgres:
    image: postgres:16-alpine
    ports:
      - "5432:5432"
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: dev
      POSTGRES_DB: app_dev
    volumes:
      - pg_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 5s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  pg_data:

Multi-stage Dockerfile

FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci

FROM node:22-alpine AS dev
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
EXPOSE 8000
CMD ["npm", "run", "dev"]

FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:22-alpine AS prod
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/package*.json ./
RUN npm ci --omit=dev
EXPOSE 8000
CMD ["node", "dist/index.js"]

Dev stage has full dependencies and source for hot reload. Prod stage has only built output and prod deps. The same Dockerfile builds both.

.env pattern

.env.example (committed to git):

DATABASE_URL=postgres://app:dev@postgres:5432/app_dev
REDIS_URL=redis://redis:6379
OPENAI_API_KEY=your_key_here
SENTRY_DSN=
NODE_ENV=development

Each developer copies to .env and fills in their own secrets. .env is in .gitignore.

Hot reload pattern

The trick for hot reload in Docker:

  • Mount the source dir as a volume so file changes propagate into the container
  • Create a named anonymous volume for node_modules so the host's missing/different node_modules don't shadow the container's
  • Run the dev command (npm run dev, tsx watch, nodemon) inside the container — it sees file changes via the volume

For Python: replace npm with poetry / uv, use uvicorn --reload as the command.

Healthchecks matter

depends_on with condition: service_healthy waits for Postgres to actually accept connections before starting the app. Without this, the app starts, tries to connect, fails, exits. Docker restarts it. Race condition for 30 seconds. Looks like a flaky environment.

Common workflows

NeedCommand
Start everythingdocker compose up
Detach (background)docker compose up -d
Rebuild after Dockerfile changedocker compose build then up
Shell into app containerdocker compose exec app sh
psql into dev DBdocker compose exec postgres psql -U app
Reset everything (kill data!)docker compose down -v
Tail logsdocker compose logs -f app

Common mistakes

  • Forgetting the node_modules anonymous volume. Host's empty or incompatible node_modules shadows the container's. App fails to start.
  • Committing .env. Use .env.example as a template and gitignore .env.
  • Using latest tag for images. Pin versions (postgres:16-alpine not postgres:latest) for reproducibility.
  • Mounting the whole repo. Mount only src/. Mounting the entire repo causes the container's .git, node_modules, build artifacts to clash with host's.
  • Skipping healthchecks. App-DB race conditions are the #1 source of "first run doesn't work."

Optional: docker-compose.override.yml

Per-developer customizations (different ports, debug ports for IDE attachment) go in docker-compose.override.yml which is gitignored. Docker Compose merges automatically.

Comparison

Without Docker With Docker
New joiner setup time: 4 hours 10 minutes
'Works on my machine' bugs: monthly Rare
Postgres version drift: yes Pinned to image tag
OS-specific gotchas (Windows line endings, etc.) All Linux containers
Spinning up parallel branches needs cleanup docker compose -p branch up
CI matches local: no guarantee Same Dockerfile in CI

FAQ

Should I use Docker in production?
Yes if you're deploying to Kubernetes, Cloud Run, ECS. Same Dockerfile, prod target. If your prod is a static binary or serverless, Docker may add no value.

What about M1/M2 Macs (ARM)?
Most Alpine images are multi-arch. For images that aren't, set platform: linux/amd64 to force Rosetta emulation. Slower but works.

Docker is slow on Mac, alternatives?
OrbStack (faster file IO than Docker Desktop, free for personal). Colima (FOSS, lighter). For pure speed, Linux dev machines beat both.

Should each service have its own repo and Dockerfile?
For microservices, yes. For a monolith, one Dockerfile is fine. Don't over-engineer the structure early.

Need Docker set up for your codebase?

1-day fixed-price: docker-compose, multi-stage Dockerfile, healthchecks, CI parity. Done.

Book a discovery call

Related Posts

GitHub Actions iOS AWS Lambda Python
← All blog posts

10-minute setup for new joiners

No more 'works on my machine'. Docker-compose, multi-stage, healthchecks.

Book a discovery call