How to Set Up Docker for Local Web Development: A Step-by-Step Guide

How to Set Up Docker for Local Web Development: A Step-by-Step Guide

by | Nov 5, 2026 | Uncategorized | 0 comments

Every team has that onboarding day where a new developer spends six hours installing the right runtime version, a database that refuses to start on port 5432, and a web server config nobody has touched in two years. A clean docker local development setup removes that pain for good: one repository, one command, and the exact same environment on every laptop.

This tutorial walks through containerizing a typical three-tier web stack (application server, database, reverse proxy) with a Dockerfile and Docker Compose, including bind mounts so hot reloading still works the way you expect. No theory dumps, just the files you can copy into your project today.

What You Will Build

The stack below is intentionally generic. The Node example can be swapped for PHP-FPM, Python, Ruby or .NET with very few changes.

Service Image / Build Role Exposed locally
app Built from local Dockerfile Application server with live reload Internal only (via proxy)
db postgres:17-alpine Persistent database with seed scripts localhost:5433
proxy nginx:1.27-alpine Reverse proxy, mirrors production routing localhost:8080

Prerequisites

  • Docker Desktop (macOS, Windows) or Docker Engine + the Compose plugin (Linux). Check with docker compose version.
  • On Windows, work inside WSL 2 and keep your code in the Linux filesystem, not in /mnt/c. File watching and I/O are dramatically faster there.
  • Roughly 4 GB of free RAM allocated to Docker.
  • Basic familiarity with your project start command (for example npm run dev).
docker containers laptop

Step 1: Lay Out the Project

Keep all container-related files in one folder so the repository stays readable.

my-project/
├── docker/
│   ├── nginx/
│   │   └── default.conf
│   └── postgres/
│       └── init/
│           └── 001-seed.sql
├── src/
├── .dockerignore
├── .env.example
├── docker-compose.yml
├── docker-compose.override.yml
├── Dockerfile
└── package.json

Write a .dockerignore first

This single file is the difference between a 40 MB build context and a 900 MB one.

.git
node_modules
npm-debug.log
dist
coverage
.env
*.md
.DS_Store

Step 2: The Dockerfile (Multi-Stage, Dev and Prod in One File)

A multi-stage build lets you keep a fat, tooling-rich development image and a slim production image in the same file. The target option in Compose decides which stage is used locally.

# syntax=docker/dockerfile:1

FROM node:22-alpine AS base
WORKDIR /app
RUN apk add --no-cache curl

# ---------- development ----------
FROM base AS dev
ENV NODE_ENV=development
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 3000
CMD npm run dev

# ---------- build ----------
FROM base AS build
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .

# ---------- production ----------
FROM node:22-alpine AS prod
WORKDIR /app
ENV NODE_ENV=production
USER node
COPY --from=build --chown=node:node /app ./
EXPOSE 3000
CMD node src/server.js

Why copy package files before the source code?

Docker caches each layer. Copying only the manifest first means dependencies are reinstalled only when package.json changes, not every time you edit a controller. The same trick applies to composer.json, requirements.txt, Gemfile or go.mod.

docker containers laptop

Step 3: The docker-compose.yml That Ties It Together

Modern Compose no longer needs the version key. Use name instead so container names stay predictable.

name: myapp

services:
  app:
    build:
      context: .
      target: dev
    container_name: myapp_app
    env_file: .env
    environment:
      DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
      CHOKIDAR_USEPOLLING: 'false'
    volumes:
      - ./:/app
      - /app/node_modules
    expose:
      - '3000'
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

  db:
    image: postgres:17-alpine
    container_name: myapp_db
    env_file: .env
    volumes:
      - db_data:/var/lib/postgresql/data
      - ./docker/postgres/init:/docker-entrypoint-initdb.d:ro
    ports:
      - '5433:5432'
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}']
      interval: 5s
      timeout: 3s
      retries: 10
    restart: unless-stopped

  proxy:
    image: nginx:1.27-alpine
    container_name: myapp_proxy
    volumes:
      - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    ports:
      - '8080:80'
    depends_on:
      - app
    restart: unless-stopped

volumes:
  db_data:

The three details that matter most

  1. Service names are hostnames. Inside the network, the app reaches the database at db:5432, never at localhost. Containers each have their own loopback interface.
  2. The anonymous volume /app/node_modules shields the dependencies installed inside the image from being overwritten by your host folder. Without it, a macOS or Windows node_modules directory leaks into a Linux container and native modules break.
  3. The healthcheck plus condition: service_healthy means the app waits until Postgres actually accepts connections, not merely until the container starts. This kills the classic race condition on first boot.

Port 5433 on the host, on purpose

Mapping 5433:5432 avoids a clash with any Postgres already installed on the machine. Your GUI client connects to localhost:5433, your app still talks to port 5432 inside the network.

Step 4: The Reverse Proxy Configuration

Running Nginx locally is not decoration. It lets you reproduce production routing, test subpaths, serve static assets, and later add a second service (API, admin panel) behind the same origin without CORS headaches.

upstream app_upstream {
    server app:3000;
}

server {
    listen 80;
    server_name localhost;
    client_max_body_size 25m;

    location / {
        proxy_pass http://app_upstream;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_cache_bypass $http_upgrade;
    }
}

The Upgrade and Connection headers are mandatory if your dev server uses WebSockets for hot module replacement (Vite, Next.js, Webpack dev server, Laravel Vite). Forget them and the page will load but never refresh by itself. Docker Hub quickstart covers this in more depth.

Step 5: Environment Variables

Commit a .env.example, never the real .env.

# .env.example
POSTGRES_USER=app
POSTGRES_PASSWORD=secret_local_only
POSTGRES_DB=app_dev
APP_PORT=3000
NODE_ENV=development

Onboarding then becomes a two-liner:

cp .env.example .env
docker compose up --build

Open http://localhost:8080 and the full stack is live.

docker containers laptop

Step 6: Hot Reloading That Actually Reloads

The bind mount ./:/app makes your editor and the container share the same files, so the dev server inside the container sees your saves. Two things can still go wrong.

File events do not cross the boundary

On some macOS and Windows configurations, inotify events are not propagated. Fixes, in order of preference:

  • Enable VirtioFS (macOS) or run from the WSL 2 filesystem (Windows). This solves the issue in most cases.
  • Enable polling as a fallback: CHOKIDAR_USEPOLLING=true, WATCHPACK_POLLING=true, or --poll depending on your tooling. It works everywhere but burns CPU.
  • Use Compose Watch, described below.

Compose Watch: the modern alternative to broad bind mounts

Instead of mounting the whole project, you can let Compose sync only what matters and rebuild automatically when dependencies change. For the wider picture, see Local Development Environment Usage.

  app:
    build:
      context: .
      target: dev
    develop:
      watch:
        - action: sync
          path: ./src
          target: /app/src
          ignore:
            - node_modules/
        - action: sync+restart
          path: ./config
          target: /app/config
        - action: rebuild
          path: ./package.json

Then start with:

docker compose up --watch

You get faster file events, no permission mess, and an automatic rebuild when a teammate adds a dependency. Many teams keep the bind mount for day-to-day work and Compose Watch for frontend-heavy services. A longer analysis is available for anyone who wants it.

Step 7: Database Persistence and Seeding

Named volumes survive docker compose down; they are only deleted when you add -v. Any .sql or .sh file placed in /docker-entrypoint-initdb.d runs only on an empty data directory, meaning on the very first start.

-- docker/postgres/init/001-seed.sql
CREATE TABLE IF NOT EXISTS projects (
  id SERIAL PRIMARY KEY,
  name TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now()
);

INSERT INTO projects (name) VALUES ('Demo project'), ('Sandbox');

To replay the seed after changing it:

docker compose down -v
docker compose up --build

For schema changes, keep using your migration tool and run it inside the container:

docker compose exec app npm run migrate
docker containers laptop

Step 8: Make It a True One-Command Setup

A short Makefile (or a ./dev script) hides the flags nobody remembers and makes the README a five-line document.

.PHONY: up down logs sh db reset

up:
	docker compose up --build -d

down:
	docker compose down

logs:
	docker compose logs -f app

sh:
	docker compose exec app sh

db:
	docker compose exec db psql -U $$POSTGRES_USER -d $$POSTGRES_DB

reset:
	docker compose down -v && docker compose up --build -d

New teammate instructions become: cp .env.example .env && make up. That is the whole point of a reproducible docker local development setup.

Daily Commands Cheat Sheet

Goal Command
Start everything in the background docker compose up -d
Rebuild after Dockerfile changes docker compose up --build
Follow logs of one service docker compose logs -f app
Open a shell in the app container docker compose exec app sh
Run a one-off task docker compose run --rm app npm test
Restart a single service docker compose restart proxy
Stop and keep data docker compose down
Stop and wipe volumes docker compose down -v
Reclaim disk space docker system prune -af --volumes

Troubleshooting the Classics

Symptom Cause Fix
ECONNREFUSED on 127.0.0.1:5432 App tries to reach the DB on its own loopback Use the service name: db:5432
Port is already allocated A native service holds the port Change the host side only, e.g. 8081:80
Changes not reloading File events not propagated Enable polling or switch to Compose Watch
Files owned by root on the host Container runs as root (Linux) Add user: "${UID}:${GID}" or create a matching user in the image
Module not found after install Dependency installed on host only Install inside: docker compose exec app npm i pkg, then rebuild
Seed SQL ignored Volume already initialized docker compose down -v then start again
Build is slow every time Bad layer order or no .dockerignore Copy manifests first, ignore node_modules and .git
docker containers laptop

Performance Tips Worth the Five Minutes

  • Pin your tags. postgres:17-alpine beats postgres:latest, which silently changes under your feet and breaks a colleague next Monday.
  • Use BuildKit cache mounts for package managers: RUN --mount=type=cache,target=/root/.npm npm ci.
  • Keep heavy directories out of bind mounts (node_modules, vendor, .venv, target) with anonymous or named volumes.
  • Give Docker Desktop enough CPU and RAM, but not all of it. Four CPUs and 4 to 6 GB is a sane baseline for this stack.
  • Split configuration: keep shared services in docker-compose.yml and local-only tweaks in docker-compose.override.yml, which Compose loads automatically.
  • Add a debug port (for example 9229:9229 for Node, or Xdebug for PHP) so breakpoints work from your IDE.

Where Dev Containers Fit In

If you want your editor itself to run inside the container (extensions, linters, language server all preinstalled), add a .devcontainer/devcontainer.json that points to your existing Compose file and attach with VS Code. The Compose setup described here stays the single source of truth; the dev container is just another client of it. This is the cleanest way to guarantee that the toolchain is versioned with the code, not with the laptop.

FAQ

How do I run Docker locally?

Install Docker Desktop (macOS, Windows) or Docker Engine with the Compose plugin (Linux), verify with docker compose version, then from your project folder run docker compose up --build. Compose reads docker-compose.yml, builds images, creates a private network and starts every service.

Is Docker a good idea for local development, or is it overkill?

It pays off as soon as you have more than one service, more than one developer, or more than one project with conflicting runtime versions. For a single static site it is probably overkill. For an app plus database plus proxy, it removes an entire category of environment bugs.

Why can my app not connect to the database on localhost?

Each container has its own network namespace, so localhost inside the app container means the app container itself. Use the Compose service name (db) as the hostname. localhost:5433 is only for tools running on your host machine.

Will hot reloading work inside Docker?

Yes, as long as the source folder is bind mounted (or synced with Compose Watch) and the dev server can see file events. On macOS and Windows, enable VirtioFS or WSL 2, and fall back to polling flags if events still do not arrive.

How do I install a new dependency?

Run the install inside the container so the lockfile and the image stay in sync: docker compose exec app npm install some-package, then docker compose up --build so the dependency is baked into the image for your teammates.

Should I use the same Dockerfile in production?

Use the same file with a different stage. The dev stage carries dev dependencies and a watcher, the prod stage is slim, runs as a non-root user and starts the compiled app. One file, two targets, zero drift between environments.

What is Docker used for in DevOps?

Containers are the common packaging format between development, CI and production. The same image that passed your local tests is the one your pipeline scans, pushes to a registry and deploys, which is exactly why starting at the local level matters so much.

How do I reset everything when the environment misbehaves?

Run docker compose down -v to drop containers and volumes, then docker compose build --no-cache and docker compose up. If disk space is the issue, docker system prune -af --volumes clears unused images, networks and volumes (be aware it removes data from projects you are not currently running).

Wrapping Up

A solid docker local development setup is not about Docker expertise, it is about deleting the gap between laptops. With a multi-stage Dockerfile, a Compose file with healthchecks, a reverse proxy mirroring production routing, and bind mounts or Compose Watch for live reloading, your onboarding instructions shrink to two commands and your bug reports stop starting with the words it works on my machine.

Copy the files above into your repository, adjust the runtime image and the start command, and commit. The next developer who joins will thank you on day one.