Skip to content
Dev Tools Intermediate Tutorial

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.

AI
DevClubHouse Curation
Jun 8, 2026 · 9 min read · 0 comments

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 legacy docker-compose). Verify with docker --version and docker compose version.
    • macOS (Apple Silicon or Intel): install Docker Desktop. Compose V2 is bundled.
    • Linux: install docker-ce and the docker-compose-plugin package.
  • 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 ci requires a package-lock.json (created by npm install) and gives reproducible installs.
  • --omit=dev skips devDependencies in the final image.
  • The official node images ship with an unprivileged node user; switching to it avoids running as root.
  • node:20-alpine is small; if you hit native-module build issues, switch to node: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: runner builds the app from your Dockerfile, stopping at the runner stage.
  • env_file injects .env into the app container. Compose also reads .env automatically for ${...} interpolation in the YAML itself, which is why ${DB_USER} etc. work in the db service.
  • depends_on with condition: service_healthy makes the app wait until Postgres actually accepts connections — not just until its container starts.
  • pgdata named 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 HEALTHCHECK instruction 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.yml for local-only settings (bind mounts, hot reload) versus a lean base file for CI.
  • Move secrets out of .env for real production using Docker secrets or your cloud provider's secret manager.
  • Scan your image with docker scout cves to catch known vulnerabilities before shipping.

Discussion 0

Join the discussion

Sign in with GitHub to comment and vote.

Sign in with GitHub

No comments yet

Be the first to weigh in.

Related Reading