"Works on my machine" is a joke until it's your production bug at 2am. Docker Compose is how I've eliminated that class of problem entirely — and after using it on enough projects, I have a setup that starts a full Next.js + FastAPI + PostgreSQL stack in one command with no manual steps.
Here's the exact pattern I use.
Why Compose and Not Just Dockerfile Per Service?
A Dockerfile defines how to build one service. docker-compose.yml defines how multiple services talk to each other — networking, shared volumes, startup order, environment variables. It's the difference between having ingredients and having a recipe.
For a typical full-stack project you need at minimum:
- The frontend (Next.js dev server)
- The backend (FastAPI with hot reload)
- The database (PostgreSQL)
- Optionally: a cache (Redis), a reverse proxy (nginx), an email catcher (MailHog)
Without Compose, you're running these in separate terminals, manually coordinating startup order, and documenting it in a README nobody reads. With Compose, it's docker compose up.
The Base Structure
/
├── frontend/
│ ├── Dockerfile.dev
│ └── ...
├── backend/
│ ├── Dockerfile.dev
│ └── ...
├── docker-compose.yml
└── docker-compose.prod.yml
I keep separate Compose files for dev and prod. The dev file prioritises fast iteration (volume mounts, hot reload). The prod file prioritises correctness (multi-stage builds, no volume mounts, health checks on everything).
The docker-compose.yml I Actually Use
version: "3.9"
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: appdb
POSTGRES_USER: appuser
POSTGRES_PASSWORD: devpassword
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
interval: 5s
timeout: 5s
retries: 10
ports:
- "5432:5432" # expose for local psql / GUI clients
backend:
build:
context: ./backend
dockerfile: Dockerfile.dev
volumes:
- ./backend:/app # mount source — uvicorn reloads on change
environment:
DATABASE_URL: postgresql://appuser:devpassword@postgres:5432/appdb
ENVIRONMENT: development
depends_on:
postgres:
condition: service_healthy
ports:
- "8000:8000"
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.dev
volumes:
- ./frontend:/app
- /app/node_modules # keep node_modules inside container
- /app/.next # keep Next.js build cache inside container
environment:
NEXT_PUBLIC_API_URL: http://localhost:8000
WATCHPACK_POLLING: "true" # needed for file watching on some Linux setups
depends_on:
- backend
ports:
- "3000:3000"
volumes:
postgres_data:
A few things worth explaining:
The service_healthy condition on depends_on means the backend container won't start until PostgreSQL is actually ready to accept connections — not just running. Without this, FastAPI boots up, tries to connect to the database, fails, and crashes. The healthcheck on postgres uses pg_isready which is guaranteed to be accurate.
The named volume /app/node_modules prevents your host machine's node_modules (which might be missing, outdated, or built for a different OS) from being mounted over the container's. This is the source of 90% of "it works on Linux but not my Mac" Compose issues.
WATCHPACK_POLLING: "true" — on some Linux environments, inotify-based file watching doesn't work across volume mounts. Polling is slower but reliable.
The Dockerfiles
# backend/Dockerfile.dev
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
# frontend/Dockerfile.dev
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
CMD ["npm", "run", "dev"]
Notice what's not in these Dockerfiles: COPY . .. In dev, we mount the source directory as a volume instead. This means changes to your source files are instantly reflected without rebuilding the image.
Running Migrations Automatically
Database migrations are a common stumbling block in containerised setups. I handle this with a one-off migration step that runs before the backend starts:
migrate:
build:
context: ./backend
dockerfile: Dockerfile.dev
command: ["alembic", "upgrade", "head"]
environment:
DATABASE_URL: postgresql://appuser:devpassword@postgres:5432/appdb
depends_on:
postgres:
condition: service_healthy
restart: "no" # run once and exit
Or, for a Prisma project on the frontend:
db-push:
build:
context: ./frontend
dockerfile: Dockerfile.dev
command: ["npx", "prisma", "migrate", "deploy"]
environment:
DATABASE_URL: postgresql://appuser:devpassword@postgres:5432/appdb
depends_on:
postgres:
condition: service_healthy
restart: "no"
These services start, run the migration, and exit. Everything else starts after them.
The Commands I Use Daily
# Start everything (build if needed, in background)
docker compose up -d --build
# Follow logs for a specific service
docker compose logs -f backend
# Open a psql shell inside the postgres container
docker compose exec postgres psql -U appuser -d appdb
# Stop and remove containers (keep the volume)
docker compose down
# Stop and wipe the database volume (fresh start)
docker compose down -v
# Run a one-off command in the backend container
docker compose run --rm backend python manage.py seed_data
The Biggest Mistake I See
Mounting your entire project root as a volume: ./:/app. This is tempting because it seems comprehensive. In practice, it overwrites everything inside the container — including dependencies installed at build time. You end up with a container that has Python/Node packages installed but immediately loses them when the mount takes over.
Mount source directories specifically, and explicitly exclude build artifacts using named volumes or by keeping them in subdirectories not covered by the mount.
The Payoff
After the initial setup — which takes maybe two hours the first time — the workflow is frictionless. A new team member clones the repo, runs docker compose up -d --build, and has a running dev environment in minutes. No Python version conflicts, no Postgres installation, no mismatched Node version. It just works.
That's the point.