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

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.

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
- Service names are hostnames. Inside the network, the app reaches the database at
db:5432, never atlocalhost. Containers each have their own loopback interface. - The anonymous volume
/app/node_modulesshields the dependencies installed inside the image from being overwritten by your host folder. Without it, a macOS or Windowsnode_modulesdirectory leaks into a Linux container and native modules break. - The healthcheck plus
condition: service_healthymeans 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.

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--polldepending 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

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 |

Performance Tips Worth the Five Minutes
- Pin your tags.
postgres:17-alpinebeatspostgres: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.ymland local-only tweaks indocker-compose.override.yml, which Compose loads automatically. - Add a debug port (for example
9229:9229for 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.
