How to Use Docker Compose for Local Web App Development

Team 6 min read

#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.