Docker clicked for me when I stopped thinking of it as a deployment technology and started thinking of it as a way to check in the development environment alongside the code. A new engineer clones the repo, runs one command, and has the same database version, the same runtime, and the same environment variables as everyone else. No more “works on my machine” investigations that eat an afternoon.
For a small team – say two to eight engineers – you don’t need Kubernetes or a sophisticated orchestration layer. A well-structured Docker Compose setup covers most of what you need for local development and can scale to simple staging environments. Here’s the setup I’ve converged on after using Docker seriously for a few years.
Project structure
Keep Docker configuration at the root of the repo, not buried in a subdirectory. The main files you’ll have:
your-project/
docker-compose.yml # shared base
docker-compose.override.yml # local dev overrides (gitignored)
docker-compose.staging.yml # staging-specific overrides
Dockerfile # production image
Dockerfile.dev # dev image with hot reload, dev tools
.env.example # committed, documents required vars
.env # actual values, gitignored
The split between docker-compose.yml and docker-compose.override.yml is useful: Docker Compose automatically merges them when you run docker compose up without specifying a file. The base file has the service definitions; the override adds volume mounts for hot reload, opens debugging ports, and sets development-only environment variables. New team members copy .env.example to .env, fill in any secrets, and go.
A practical compose file
# docker-compose.yml
services:
api:
build:
context: .
dockerfile: Dockerfile
ports:
- "3001:3001"
environment:
- NODE_ENV=production
- DATABASE_URL=${DATABASE_URL}
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${DB_USER:-app}
POSTGRES_PASSWORD: ${DB_PASSWORD:-secret}
POSTGRES_DB: ${DB_NAME:-appdb}
volumes:
- db_data:/var/lib/postgresql/data
- ./db/init:/docker-entrypoint-initdb.d
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-app}"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
db_data:
# docker-compose.override.yml (gitignored)
services:
api:
build:
dockerfile: Dockerfile.dev
volumes:
- .:/app
- /app/node_modules
environment:
- NODE_ENV=development
command: npm run dev
The healthcheck on the database is important. Without it, your API container starts before Postgres is ready to accept connections and you get a confusing startup error. The depends_on: condition: service_healthy syntax waits for the healthcheck to pass before starting the dependent service.
The Dockerfile
Use multi-stage builds for production images. The build stage installs all dependencies and compiles; the production stage copies only what’s needed to run. This keeps images small and avoids shipping dev tools to production.
# Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY . .
EXPOSE 3001
CMD ["node", "dist/index.js"]
# Dockerfile.dev - simpler, prioritizes fast rebuilds
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
CMD ["npm", "run", "dev"]
The COPY package*.json ./ before RUN npm install is the standard layer caching trick: Docker only re-runs the install step if package.json or package-lock.json changes. Your day-to-day rebuilds skip the install entirely.
Database migrations
Don’t run migrations inside the application startup. If two instances start simultaneously, you get a race condition. Instead, run migrations as a separate step in your deployment pipeline before the new instances come up:
# in your CI/CD pipeline
docker compose run --rm api npx prisma migrate deploy
docker compose up -d api
For local dev, you can run them on first start via the docker-entrypoint-initdb.d directory mounted into Postgres, but keep them separate from application boot in every other environment.
Useful day-to-day commands
# start everything in the background
docker compose up -d
# follow logs for a specific service
docker compose logs -f api
# run a one-off command in the api container
docker compose exec api npm run db:seed
# rebuild only the api image (after Dockerfile changes)
docker compose build api && docker compose up -d api
# wipe everything including volumes (full reset)
docker compose down -v
One workflow I’ve found useful: add a Makefile at the project root with short targets for common operations. make dev, make reset, make logs. It removes the cognitive load of remembering the right docker compose incantation when you haven’t touched a project in a week.
Avoiding the common pitfalls
Don’t mount the entire project directory in production-like environments – mount only what needs to be editable. Volume mounts bypass the layer cache and make builds unpredictable.
Use named volumes for database data, not bind mounts. Bind mounts to host paths have permission issues on Linux and behave differently on macOS.
Pin image versions in production (postgres:16.2-alpine, not postgres:latest). The official images are updated frequently and latest will eventually pull a version that changes behavior.
The Docker Compose documentation covers the full override merge logic and all service configuration options: docs.docker.com/compose/compose-file. The section on healthchecks is worth reading in full if you’re setting up a multi-service app.