Dockerize a Web App: From Dockerfile to docker compose
Build a production-ready multi-stage Dockerfile for a Node.js API, wire it to a PostgreSQL database with docker compose, and run the whole stack locally with volumes and env files.
What you'll build
You'll containerize a small Node.js (Express) web API using a multi-stage Dockerfile that produces a lean, non-root production image, then orchestrate it alongside a PostgreSQL database using docker compose. By the end you'll have a reproducible local stack with a persistent data volume and environment configuration loaded from an .env file.
Prerequisites
- Docker Engine 24+ with the Compose V2 plugin (invoked as
docker compose, not the legacydocker-compose). Verify withdocker --versionanddocker compose version.- macOS (Apple Silicon or Intel): install Docker Desktop. Compose V2 is bundled.
- Linux: install
docker-ceand thedocker-compose-pluginpackage.
- Node.js 20 LTS locally only if you want to run the app outside Docker for comparison. Not strictly required.
- Basic familiarity with the terminal and a code editor.
All commands assume a POSIX shell (bash/zsh). On Windows, use WSL2.
Step 1: Create the sample app
Create a project folder and a minimal Express server that talks to PostgreSQL.
mkdir docker-web-demo && cd docker-web-demo
npm init -y
npm install express pg
Create server.js:
const express = require('express');
const { Pool } = require('pg');
const app = express();
const port = process.env.PORT || 3000;
const pool = new Pool({
host: process.env.DB_HOST,
port: process.env.DB_PORT || 5432,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
});
app.get('/health', (_req, res) => res.json({ status: 'ok' }));
app.get('/time', async (_req, res) => {
try {
const { rows } = await pool.query('SELECT NOW() AS now');
res.json({ dbTime: rows[0].now });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.listen(port, () => console.log(`Listening on ${port}`));
Add a start script to package.json so the container has a stable entrypoint:
{
"scripts": {
"start": "node server.js"
}
}
Step 2: Write a multi-stage Dockerfile
A multi-stage build keeps build-time tooling out of the final image. Create Dockerfile:
# syntax=docker/dockerfile:1
# ---- Stage 1: install production dependencies ----
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# ---- Stage 2: runtime ----
FROM node:20-alpine AS runner
ENV NODE_ENV=production
WORKDIR /app
# Copy installed node_modules from the deps stage
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Run as the built-in non-root 'node' user for safety
USER node
EXPOSE 3000
CMD ["npm", "start"]
Key points:
npm cirequires apackage-lock.json(created bynpm install) and gives reproducible installs.--omit=devskips devDependencies in the final image.- The official
nodeimages ship with an unprivilegednodeuser; switching to it avoids running as root. node:20-alpineis small; if you hit native-module build issues, switch tonode:20-slim(Debian-based).
Add a .dockerignore so local junk and secrets never enter the build context:
node_modules
npm-debug.log
.env
.git
Dockerfile
docker-compose.yml
Step 3: Add environment configuration
Never bake credentials into the image. Create a .env file (and keep it out of version control via .gitignore):
PORT=3000
DB_HOST=db
DB_PORT=5432
DB_USER=appuser
DB_PASSWORD=supersecret
DB_NAME=appdb
Note DB_HOST=db — that's the Compose service name, which Docker's internal DNS resolves to the database container.
Step 4: Write docker-compose.yml
Create docker-compose.yml:
services:
app:
build:
context: .
target: runner
ports:
- "3000:3000"
env_file:
- .env
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
pgdata:
What's happening:
build.target: runnerbuilds the app from your Dockerfile, stopping at therunnerstage.env_fileinjects.envinto theappcontainer. Compose also reads.envautomatically for${...}interpolation in the YAML itself, which is why${DB_USER}etc. work in thedbservice.depends_onwithcondition: service_healthymakes the app wait until Postgres actually accepts connections — not just until its container starts.pgdatanamed volume persists database files across container restarts and rebuilds.- A modern Compose file does not need a top-level
version:key; it's obsolete in Compose V2 and triggers a warning.
Step 5: Build and run the stack
docker compose up --build
This builds the app image, pulls postgres:16-alpine, waits for the DB healthcheck to pass, then starts the app. To run detached:
docker compose up --build -d
Verify it works
Check container status — db should report (healthy):
docker compose ps
Hit the health endpoint:
curl http://localhost:3000/health
# {"status":"ok"}
Confirm the app can reach Postgres:
curl http://localhost:3000/time
# {"dbTime":"2024-05-20T12:34:56.789Z"}
Verify the volume persists data. Restart everything and confirm the volume survives:
docker compose down
docker volume ls | grep pgdata
The pgdata volume should still be listed. Running docker compose up -d again reuses it. To wipe data deliberately, use docker compose down -v.
Inspect the image size to confirm the multi-stage build paid off:
docker images | grep docker-web-demo
Troubleshooting
app exits immediately or ECONNREFUSED to the database
The app started before Postgres was ready. Confirm the db healthcheck is defined and that depends_on uses condition: service_healthy. Check logs with docker compose logs db. Also confirm DB_HOST=db matches the service name exactly.
npm ci fails with a lockfile error during build
npm ci needs a package-lock.json that matches package.json. Run npm install locally first to generate/refresh it, and make sure it is not listed in .dockerignore. (The .dockerignore above only excludes node_modules, not the lockfile.)
Port 3000 already allocated
Another process owns the host port. Either stop it, or remap by changing the host side of the mapping, e.g. "3001:3000", then browse to localhost:3001.
Environment variables show up empty in the DB service
Compose interpolates ${DB_USER} from a .env file in the same directory as docker-compose.yml. Ensure the file is named exactly .env and lives at the project root. Run docker compose config to print the fully resolved configuration and confirm values are substituted.
Next steps
- Add a healthcheck to the app image using Docker's
HEALTHCHECKinstruction so orchestrators can detect failures. - Use BuildKit cache mounts (
RUN --mount=type=cache,target=/root/.npm npm ci) to speed up dependency installs. - Split configs with a
docker-compose.override.ymlfor local-only settings (bind mounts, hot reload) versus a lean base file for CI. - Move secrets out of
.envfor real production using Docker secrets or your cloud provider's secret manager. - Scan your image with
docker scout cvesto catch known vulnerabilities before shipping.
Rachel has been embedded in the developer tooling ecosystem for nearly eight years, covering everything from IDE wars and package-manager drama to the quiet rise of AI-assisted coding. She has a soft spot for open-source maintainers and an unhealthy number of terminal emulators installed on a single laptop.
Discussion 0
No comments yet
Be the first to weigh in.