Docker Compose gives a Node.js API and PostgreSQL a reproducible environment without requiring every developer to install the same database version manually. The goal is not merely starting two containers—it is defining networking, readiness, migrations, storage, and configuration so the stack behaves predictably.

This tutorial creates a production-aware baseline while keeping local development simple.

1. Project structure

node-postgres-app/
├── src/
│   └── server.js
├── prisma/
│   └── schema.prisma
├── package.json
├── package-lock.json
├── Dockerfile
├── .dockerignore
├── compose.yaml
└── .env

The examples use Prisma for migrations, but the container and Compose principles also work with Sequelize, Knex, Drizzle, TypeORM, or direct PostgreSQL clients.

2. Create a small Node.js server

// src/server.js
import express from "express";
import { PrismaClient } from "@prisma/client";

const app = express(); const prisma = new PrismaClient(); const port = Number(process.env.PORT || 3000);

app.use(express.json());

app.get("/health", async (req, res) => { await prisma.$executeRawUnsafe("SELECT 1"); res.json({ status: "ok" }); });

app.get("/users", async (req, res) => { const users = await prisma.user.findMany({ orderBy: { createdAt: "desc" } }); res.json(users); });

const server = app.listen(port, "0.0.0.0", () => { console.log("API listening on port " + port); });

async function shutdown(signal) { console.log("Received " + signal); server.close(async () => { await prisma.$disconnect(); process.exit(0); }); }

process.on("SIGTERM", () => shutdown("SIGTERM")); process.on("SIGINT", () => shutdown("SIGINT"));

Listen on 0.0.0.0, not only localhost, so the port is reachable through the container network. Graceful shutdown gives the server time to finish requests and close database connections.

3. Write a multi-stage Dockerfile

# syntax=docker/dockerfile:1

FROM node:22-bookworm-slim AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci

FROM node:22-bookworm-slim AS build WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY package.json package-lock.json ./ COPY prisma ./prisma COPY src ./src RUN npx prisma generate RUN npm prune --omit=dev

FROM node:22-bookworm-slim AS runtime ENV NODE_ENV=production WORKDIR /app

RUN groupadd --system nodeapp && useradd --system --gid nodeapp nodeapp COPY --from=build --chown=nodeapp:nodeapp /app/package.json ./ COPY --from=build --chown=nodeapp:nodeapp /app/node_modules ./node_modules COPY --from=build --chown=nodeapp:nodeapp /app/prisma ./prisma COPY --from=build --chown=nodeapp:nodeapp /app/src ./src

USER nodeapp EXPOSE 3000 CMD ["node", "src/server.js"]

The non-root runtime user reduces the impact of a compromised process. npm ci installs the exact dependency tree from the lockfile, while the final stage excludes build-only layers.

4. Keep the build context small

# .dockerignore
node_modules
npm-debug.log
.git
.gitignore
.env
.env.*
coverage
dist
uploads
README.md

Never copy local secrets into the image. A smaller context builds faster and reduces the chance of baking credentials or unrelated files into an image layer.

5. Define the Compose stack

# compose.yaml
services:
  db:
    image: postgres:17
    restart: unless-stopped
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 5s
      timeout: 5s
      retries: 10
      start_period: 10s
    networks:
      - backend

api: build: context: . target: runtime restart: unless-stopped environment: PORT: 3000 DATABASE_URL: postgresql://app:${POSTGRES_PASSWORD}@db:5432/app depends_on: db: condition: service_healthy ports: - "3000:3000" init: true stop_grace_period: 20s networks: - backend

volumes: postgres_data:

networks: backend:

Compose creates DNS names from service names. Inside the API container, the database host is db, not localhost. Localhost inside a container refers only to that same container.

6. Readiness is more than startup order

A running PostgreSQL process may still be initializing. The health check runs pg_isready, and long-form depends_on delays API startup until the database reports healthy.

This improves initial ordering but does not replace retry logic. Networks can fail after startup, so the API should handle transient connection errors, use backoff where appropriate, and expose a readiness endpoint for its deployment platform.

7. Manage environment variables safely

Create a local .env file excluded from version control:

POSTGRES_PASSWORD=replace-with-a-long-random-value

Compose interpolation inserts this value into both services. For production, use the platform's secret manager or Compose secrets instead of keeping live credentials in a plaintext project file.

Avoid logging DATABASE_URL because it contains the password. Use distinct credentials for development, staging, and production, then rotate them independently.

8. Run migrations as a controlled release step

Do not let every replicated API instance race to migrate the schema. Run migrations once before starting or updating application replicas.

docker compose run --rm api npx prisma migrate deploy
docker compose up -d api

For a first local run:

docker compose up -d db
docker compose run --rm api npx prisma migrate deploy
docker compose up -d --build api

Keep migration files in version control. Back up the database before risky changes and test both forward deployment and recovery.

9. Verify the stack

docker compose config
docker compose build
docker compose up -d
docker compose ps
docker compose logs -f api
curl http://localhost:3000/health

docker compose config renders the merged configuration and catches many interpolation or syntax mistakes. docker compose ps shows service state and health, while targeted logs keep diagnosis focused.

10. Separate development from production

For development, an override can bind-mount source and run a watcher. Keep that mount out of production because it can hide files copied into the image and makes the deployment depend on a host directory.

# compose.override.yaml
services:
  api:
    build:
      target: deps
    command: npm run dev
    environment:
      NODE_ENV: development
    volumes:
      - .:/app
      - /app/node_modules

For production, publish the API only through a TLS reverse proxy, pin image versions, centralize logs, monitor health, and configure resource limits supported by your platform.

11. Persist and back up PostgreSQL

The named volume survives container replacement. It is not a backup. A backup must exist outside the same failure boundary and must be restorable.

docker compose exec -T db pg_dump -U app -d app -Fc > app.dump

Store backups securely, encrypt them where required, define retention, and test restoration into a separate database. Before changing PostgreSQL major versions, follow the supported upgrade path; changing only the image tag is not a database migration plan.

Common problems

  • ECONNREFUSED 127.0.0.1:5432: set the database host to db.

  • API starts before PostgreSQL: add the health check, readiness condition, and application retry logic.

  • Data disappears: verify the named volume is mounted at PostgreSQL's data directory.

  • Permission denied: check bind-mount ownership and the runtime user; avoid running the application as root.

  • New code is ignored: rebuild the image and check whether a bind mount is hiding it.

  • Migration races: move schema migration to a one-off release step.

Production checklist

  1. The image builds from a lockfile and runs as a non-root user.

  2. Secrets are injected at runtime and excluded from the build context.

  3. The API connects to db:5432 on the private Compose network.

  4. PostgreSQL has a health check and persistent named volume.

  5. Migrations run once as a controlled release task.

  6. The API handles shutdown signals and transient database failures.

  7. Only the reverse proxy exposes the public application.

  8. Backups are stored externally and restoration is tested.

Containers make the environment reproducible. Health checks, migrations, secrets, and backups make the service operationally reliable.

References