"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_modulesso 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
| Need | Command |
|---|---|
| Start everything | docker compose up |
| Detach (background) | docker compose up -d |
| Rebuild after Dockerfile change | docker compose build then up |
| Shell into app container | docker compose exec app sh |
| psql into dev DB | docker compose exec postgres psql -U app |
| Reset everything (kill data!) | docker compose down -v |
| Tail logs | docker 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-alpinenotpostgres: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