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.
Discussion 0
Join the discussion
Sign in with GitHub to comment and vote.
No comments yet
Be the first to weigh in.