How to Use Docker Compose for Local Web App Development
#docker
#docker-compose
#local-dev
#webdev
#tutorial
Docker Compose is one of the fastest ways to spin up a full local development environment that mirrors production. With a single file, you can define your app, database, cache, and supporting services; then bring everything up with one command.
This guide walks you through a pragmatic setup for local web development using Docker Compose. We’ll cover the essentials, add hot reloading, wire up Postgres and Redis, and share tips for smooth day-to-day workflows.
Why use Docker Compose for local dev?
- One command bootstraps your whole stack
- Consistent environments across teammates and CI
- Easy to add dependencies like databases, queues, and mail catchers
- Reproducible, clean teardown without polluting your host machine
Prerequisites
- Docker Desktop (macOS/Windows) or Docker Engine (Linux)
- Docker Compose v2 (use docker compose, not docker-compose)
Verify:
docker --version
docker compose version
Example app stack
We’ll use a Node.js API with hot reload, Postgres, and Redis. You can adapt the same patterns to other runtimes (Python, Go, Ruby, etc.).
Directory layout:
your-app/
Dockerfile
docker-compose.yml
.dockerignore
.env
package.json
src/
Step 1: Dockerfile for the app (development-friendly)
Create a minimal dev Dockerfile that installs dependencies and runs a dev server (nodemon, ts-node-dev, vite, etc.).
Dockerfile:
# Lightweight Node image
FROM node:20-alpine
# Create app dir
WORKDIR /app
# Install deps first for better build caching
COPY package*.json ./
RUN npm ci
# Copy source
COPY . .
# Expose dev port (adjust to your app, e.g., 3000, 5173, etc.)
EXPOSE 3000
# Use a dev command; Compose will override if needed
CMD ["npm", "run", "dev"]
.dockerignore:
node_modules
.git
Dockerfile
docker-compose.yml
npm-debug.log*
Example package.json scripts:
{
"scripts": {
"dev": "nodemon --watch src --ext ts,js,json --exec node src/index.js",
"start": "node src/index.js",
"test": "node -e \"console.log('tests go here')\""
}
}
Step 2: docker-compose.yml
This file defines your services and how they work together.
docker-compose.yml:
# Compose v2 doesn't require a version key
name: local-webapp
services:
web:
build:
context: .
command: npm run dev
ports:
- "3000:3000" # host:container
environment:
# Your app can read these; adjust names as needed
NODE_ENV: development
DATABASE_URL: postgres://postgres:postgres@db:5432/app
REDIS_URL: redis://cache:6379
volumes:
- .:/app
- web_node_modules:/app/node_modules
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: app
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432" # optional: expose for external tools (psql, GUI)
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d app"]
interval: 5s
timeout: 3s
retries: 20
cache:
image: redis:7-alpine
ports:
- "6379:6379"
# Optional: a local mail catcher you can open at http://localhost:8025
mail:
image: mailhog/mailhog:v1.0.1
ports:
- "8025:8025"
profiles: ["devtools"] # only starts if profile is enabled
volumes:
web_node_modules:
db_data:
Notes:
- The bind mount .:/app gives you hot reload as you edit files.
- A separate named volume for node_modules avoids conflicting host dependencies.
- db_data persists your Postgres data between restarts.
- The mail service uses a profile so it doesn’t run unless requested.
Step 3: Environment variables
Put local-only environment config in a .env file. Compose automatically loads it.
.env:
# Sets project name (used to namespace containers/volumes)
COMPOSE_PROJECT_NAME=localwebapp
# You can also override ports or credentials here if you prefer
# POSTGRES_PASSWORD=postgres
Inside docker-compose.yml you can also reference ${VAR} to pull from .env.
Step 4: Start the stack
Common commands:
- First run (build and start in background):
docker compose up -d --build - Follow logs:
docker compose logs -f web - Stop:
docker compose down - Stop and remove volumes (resets database):
docker compose down -v - Start optional services using profiles (e.g., MailHog):
docker compose --profile devtools up -d
Step 5: Connect your app to services
In your app code, use service hostnames as defined in Compose:
- Postgres host: db, port 5432
- Redis host: cache, port 6379
Example Node.js connection (pg):
// src/db.js
const { Pool } = require('pg');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
module.exports = pool;
Example Redis (ioredis):
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);
module.exports = redis;
Step 6: Develop with hot reload
- Use nodemon (Node), uvicorn —reload (Python), or dev servers like Vite.
- The bind mount mirrors your code into the container so changes apply immediately.
- Keep node_modules inside the container via the web_node_modules volume to avoid host conflicts.
Step 7: Running one-off commands
Run scripts or open a shell inside the app container:
docker compose exec web npm test
docker compose exec web sh
Database migrations:
docker compose exec web npm run migrate
Import a database dump:
cat dump.sql | docker compose exec -T db psql -U postgres -d app
Step 8: Health checks and dependencies
- The db service defines a healthcheck so web waits for the database to be ready.
- depends_on with condition: service_healthy ensures fewer race conditions on startup.
Step 9: Dev vs prod overrides
You can keep a base docker-compose.yml and a docker-compose.override.yml that’s only for local dev settings like bind mounts and extra ports. Docker automatically loads the override file.
docker-compose.override.yml example:
services:
web:
environment:
DEBUG: "true"
volumes:
- .:/app
- web_node_modules:/app/node_modules
For production, skip the override and use a separate compose file or image with NODE_ENV=production and a non-dev command.
Step 10: Troubleshooting tips
- Ports already in use: change host ports (e.g., “3001:3000”).
- File permission issues on Linux: use a non-root user in the image or set user: “node” for the web service.
- Changes not reflecting: confirm volumes are correct and your dev command supports hot reload.
- Slow installs: cache dependencies by splitting Dockerfile steps; keep package*.json stable to leverage layer cache.
- Reset everything: docker compose down -v; docker builder prune; docker volume prune (careful).
Optional: Python example (quick swap)
If you prefer Python + Flask:
Dockerfile:
FROM python:3.12-slim
WORKDIR /app
COPY pyproject.toml poetry.lock* ./
RUN pip install --no-cache-dir poetry && poetry config virtualenvs.create false && poetry install
COPY . .
EXPOSE 8000
CMD ["flask", "run", "--host=0.0.0.0", "--port=8000", "--reload"]
docker-compose.yml service adjustment:
services:
web:
build: .
command: flask run --host=0.0.0.0 --port=8000 --reload
ports:
- "8000:8000"
environment:
FLASK_APP: app.py
DATABASE_URL: postgresql+psycopg://postgres:postgres@db:5432/app
volumes:
- .:/app
Cleanup
When you’re done:
docker compose down
# plus volumes if you want a clean slate
docker compose down -v
Summary
- Use Docker Compose to define your app and its dependencies in one place.
- Bind mount your code for hot reloading, keep dependencies inside the container.
- Add databases, caches, mail catchers, and dev tools as services.
- Use health checks, profiles, and overrides to optimize your local workflow.
With these patterns, you’ll have a fast, reliable local environment that’s easy to share across your team.