
Dockerize Node.js and PostgreSQL with Docker Compose
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
└── .envThe 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.mdNever 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-valueCompose 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 apiFor a first local run:
docker compose up -d db
docker compose run --rm api npx prisma migrate deploy
docker compose up -d --build apiKeep 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/healthdocker 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_modulesFor 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.dumpStore 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
The image builds from a lockfile and runs as a non-root user.
Secrets are injected at runtime and excluded from the build context.
The API connects to
db:5432on the private Compose network.PostgreSQL has a health check and persistent named volume.
Migrations run once as a controlled release task.
The API handles shutdown signals and transient database failures.
Only the reverse proxy exposes the public application.
Backups are stored externally and restoration is tested.
Containers make the environment reproducible. Health checks, migrations, secrets, and backups make the service operationally reliable.