Running a database, a backend API, and a frontend as separate manually managed processes works fine until you need to onboard a new dev, redeploy to a new serv…
Running a database, a backend API, and a frontend as separate manually-managed processes works fine until you need to onboard a new dev, redeploy to a new server, or just restart everything after a reboot without forgetting a step. Docker Compose solves this by turning your entire stack — every service, its config, and how they talk to each other — into a single declarative file you can spin up with one command. This guide walks through containerizing a typical web app (Node.js API + PostgreSQL + Nginx) from scratch.
Prerequisites / What You'll Need
- Docker Engine and Docker Compose installed (official install guide)
- A basic Node.js app (or any backend — the pattern applies broadly)
- Familiarity with the command line
- 30–40 minutes
Verify your install:
docker --version
docker compose version
Step 1: Understand the Shape of the Stack
Before writing any config, it helps to be clear on what you're building:
api — your Node.js application
db — a PostgreSQL database with a persistent volume
nginx — a reverse proxy in front of the API, handling the public-facing port
Each becomes its own container, and Compose handles the networking between them automatically.
Step 2: Write a Dockerfile for Your App
In your project root, create a Dockerfile:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
A couple of choices worth explaining:
node:20-alpine — Alpine-based images are significantly smaller than the default Debian-based ones, which speeds up builds and pulls
npm ci instead of npm install — ci installs exactly what's in package-lock.json, which is what you want for reproducible builds; npm install can silently drift versions
- Copying
package*.json before the rest of the app lets Docker cache the dependency layer, so a code-only change doesn't force a full npm install on every rebuild
Step 3: Write the Compose File
Create docker-compose.yml in the project root:
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: appuser
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: appdb
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U appuser"]
interval: 5s
timeout: 5s
retries: 5
api:
build: .
restart: unless-stopped
environment:
DATABASE_URL: postgres://appuser:${DB_PASSWORD}@db:5432/appdb
depends_on:
db:
condition: service_healthy
expose:
- "3000"
nginx:
image: nginx:alpine
restart: unless-stopped
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- api
volumes:
db_data:
Key details:
depends_on with condition: service_healthy — a plain depends_on only waits for the container to start, not for the database to actually be ready to accept connections. The healthcheck fixes a very common race condition where the API crashes on boot because Postgres isn't ready yet.
expose vs ports — the API uses expose, which makes it reachable to other containers on the same network but not to the outside world. Only Nginx uses ports to publish port 80 externally. This means your database and API are never directly reachable from the internet — only through the reverse proxy.
- Named volume (
db_data) — this persists your database outside the container's filesystem, so data survives container restarts, rebuilds, and docker compose down.
Step 4: Configure Nginx as the Reverse Proxy
Create nginx.conf:
server {
listen 80;
location / {
proxy_pass http://api:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
Notice proxy_pass http://api:3000 uses the service name (api), not localhost or an IP. Compose automatically creates an internal DNS network where each service can reach others by name — this is one of the most useful things Compose handles for you invisibly.
Step 5: Manage Secrets with a .env File
Never hardcode credentials directly in docker-compose.yml. Create a .env file in the same directory:
DB_PASSWORD=change_this_to_something_strong
Compose automatically loads .env and substitutes ${DB_PASSWORD} wherever it appears in the compose file. Add .env to .gitignore immediately:
echo ".env" >> .gitignore
Step 6: Build and Launch the Stack
docker compose up -d --build
-d runs it detached (in the background)
--build forces a rebuild of the api image — needed the first time, and any time your Dockerfile or code changes
Check that everything's healthy:
docker compose ps
Tail logs from a specific service if something looks off:
docker compose logs -f api
Step 7: Everyday Workflow Commands
docker compose down # stop and remove containers (volumes persist)
docker compose down -v # stop and remove containers AND volumes (destroys DB data — careful)
docker compose restart api # restart just one service
docker compose exec db psql -U appuser -d appdb # open a psql shell inside the running db container
That last command is genuinely useful day-to-day — no need to install a Postgres client locally just to poke at the database.
Common Pitfalls / Troubleshooting
depends_on without a healthcheck. This is the single most common source of "works sometimes, fails on cold start" bugs in Compose stacks. If a service needs another to be ready, not just started, add a healthcheck.
- Using
localhost inside a container. Inside the api container, localhost refers to the container itself, not the db container. Always use the service name (db, api, etc.) for inter-service communication.
- Committing
.env to version control. This is how database credentials end up in a public GitHub repo. Double-check .gitignore before your first commit.
- Volumes not persisting as expected.
docker compose down (without -v) keeps named volumes intact — but docker compose down -v deletes them. Know which one you're running, especially in scripts.
- Rebuilding not picking up code changes. If you edit code and
docker compose up doesn't reflect it, you likely need the --build flag — Compose won't rebuild an image automatically just because a source file changed.
- Port conflicts on the host. If
80:80 fails to bind, something else on the host (another Nginx instance, Apache) is already using that port. Check with sudo ss -tulpn | grep :80.
Wrapping Up
Once your stack is defined this way, spinning up an identical environment on a new machine — a teammate's laptop, a staging server, a fresh VPS — is a single docker compose up -d --build instead of a checklist of manual installs and configuration steps. That reproducibility is the real value here, well beyond convenience.
From here, natural next steps include adding a docker-compose.override.yml for local development settings that differ from production, wiring in Let's Encrypt via a tool like nginx-proxy + acme-companion for automatic HTTPS, and looking at Docker Compose's profiles feature once your stack grows enough that you don't want every service starting every time.
Further Reading