Skip to content
Commitpedia
Log in Join & edit

Containerize a Node.js app and run it with PostgreSQL using Docker Compose

Package a small Node.js web app in a multi-stage Docker image, then run it next to PostgreSQL with Docker Compose, named volumes, environment settings, healthchecks, logs and rebuilds.

Drafted with AI from official documentation. Scripts were run in a test environment and repository links verified. Awaiting a human check. Did it work for you? Last edited Outdated?

Edit history of Containerize a Node.js app and run it with PostgreSQL using Docker Compose: 1 recorded edit
Edit history · 1 edit
Type
Article
Edits
1
Editors
1
First issued
15 Oct 2026
Last edit
15 Oct 2026
Reading
11 min

A container image bundles your app with exactly the runtime and libraries it needs, so it runs the same on your laptop and on a server. Docker Compose then starts several containers together, such as an app and its database, from one YAML file. This tutorial is for developers who know a little Docker vocabulary and want one complete, realistic example from source code to a running stack.

The app is a tiny visit counter written for Node.js: every request to / stores a row in PostgreSQL and returns the total. It is small enough to read in a minute but has the parts real apps have: dependencies, configuration through environment variables, a database and a health endpoint.

What you will build

  • A Node.js app with two endpoints: /healthz (is the process alive?) and / (count a visit in the database).
  • A small, multi-stage Docker image that runs as a non-root user.
  • A .dockerignore that keeps local junk and secrets out of the image.
  • A compose.yaml that runs the app next to PostgreSQL 18, with the password in a .env file, data in a named volume, and healthchecks that make the app wait for the database.
  • A repeatable loop for reading logs and rebuilding after a code change.

Before you start

  • Docker Engine with the Compose plugin on Linux, or Docker Desktop on macOS or Windows. docker compose version must work (note: a space, not the old docker-compose).
  • Node.js 20 or newer and npm on your machine, to run the app once outside Docker and to create package-lock.json. The app uses the built-in fetch, which needs Node.js 18 or newer.
  • curl for testing, and a free port 8080.
  • Time: about 1 hour, plus image downloads on the first run.
  • Knowledge: basic terminal use, what an image and a container are, and a little JavaScript and SQL. You do not need to know PostgreSQL administration.

Step 1: Write the app and run it without Docker

Why: always make sure the app itself works before you package it. If something breaks later, you then know the problem is in the container setup, not in the code.

mkdir visits && cd visits

Create package.json:

{
  "name": "visits",
  "version": "1.0.0",
  "private": true,
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "pg": "^8.13.0"
  }
}

Create server.js. It reads its settings from PORT and DATABASE_URL, creates the table on first use, and shuts down cleanly when Docker sends SIGTERM:

const http = require('node:http');
const { Pool } = require('pg');

const port = Number(process.env.PORT || 3000);
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
let tableReady = false;

async function countVisit() {
  if (!tableReady) {
    await pool.query('CREATE TABLE IF NOT EXISTS visits (id serial PRIMARY KEY, seen_at timestamptz NOT NULL DEFAULT now())');
    tableReady = true;
  }
  await pool.query('INSERT INTO visits DEFAULT VALUES');
  const { rows } = await pool.query('SELECT count(*)::int AS total FROM visits');
  return rows[0].total;
}

const server = http.createServer(async (req, res) => {
  if (req.url === '/healthz') {
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    return res.end('ok\n');
  }
  if (req.url === '/') {
    try {
      const visits = await countVisit();
      res.writeHead(200, { 'Content-Type': 'application/json' });
      return res.end(JSON.stringify({ message: 'Hello from Docker', visits }) + '\n');
    } catch (err) {
      console.error('database error:', err.message);
      res.writeHead(503, { 'Content-Type': 'application/json' });
      return res.end(JSON.stringify({ error: 'database unavailable' }) + '\n');
    }
  }
  res.writeHead(404, { 'Content-Type': 'text/plain' });
  res.end('not found\n');
});

server.listen(port, () => console.log(`listening on port ${port}`));

process.on('SIGTERM', () => {
  console.log('SIGTERM received, shutting down');
  server.close(() => pool.end().then(() => process.exit(0)));
});

Install the dependency and start the app. There is no database yet, so point DATABASE_URL at a port where nothing listens:

npm install
DATABASE_URL=postgres://app:[email protected]:5999/app PORT=3000 node server.js

In a second terminal:

curl -i http://localhost:3000/healthz
curl -i http://localhost:3000/

Check: /healthz answers 200 with ok. / answers 503 with {"error":"database unavailable"}, and the first terminal logs a connect ECONNREFUSED error. That is the correct behavior without a database. Stop the app with Ctrl+C. npm install also created package-lock.json, which the image build needs for repeatable installs.

Step 2: Keep unwanted files out with .dockerignore

Why: docker build sends the whole folder (the build context) to the builder. Your local node_modules may contain binaries built for another operating system, and .env will contain a password. Neither belongs in the image. Create .dockerignore next to package.json:

node_modules
npm-debug.log
.env
.git
Dockerfile
compose.yaml

Check: nothing to run yet; the effect shows in Step 4, where the build context stays small.

Step 3: Write a multi-stage Dockerfile

Why: the first stage installs dependencies; the second stage copies only the result into a clean image. Build tools and npm caches from the first stage never reach the final image. The official Node.js images include a non-root user called node, and running as that user limits the damage if the app is ever compromised. Create Dockerfile:

FROM node:24-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

FROM node:24-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY package.json server.js ./
USER node
EXPOSE 3000
CMD ["node", "server.js"]
  • npm ci --omit=dev installs exactly the versions in package-lock.json and skips development dependencies.
  • Copying package.json and the lock file before the source code lets Docker reuse the cached dependency layer when only server.js changes.
  • ENV NODE_ENV=production is the setting the Node.js image maintainers recommend for production.
  • CMD ["node", "server.js"] starts Node.js directly instead of through npm start, so the stop signal reaches your code.
  • EXPOSE 3000 documents the port; it does not publish it.

Step 4: Build the image and run one container

Why: building and running the image alone, before Compose, shows that the image is correct on its own.

docker build -t visits:1.0 .
docker image ls visits
docker run --rm -d --name visits-test -p 8080:3000 visits:1.0
curl http://localhost:8080/healthz
curl http://localhost:8080/
docker exec visits-test whoami
docker stop visits-test

Check: the build finishes with the image tagged visits:1.0. /healthz answers ok, / answers database unavailable because the container has no database, and whoami prints node, not root. -p 8080:3000 maps port 8080 on your machine to port 3000 in the container; --rm deletes the container when it stops.

Step 5: Describe the stack in compose.yaml

Why: the app needs a database, a private network between the two, a place to keep data and a shared password. Compose describes all of that in one file instead of a long list of docker run options. Create compose.yaml:

services:
  web:
    build: .
    ports:
      - "8080:3000"
    environment:
      PORT: "3000"
      DATABASE_URL: postgres://app:${POSTGRES_PASSWORD}@db:5432/app
    depends_on:
      db:
        condition: service_healthy
        restart: true
    healthcheck:
      test: ["CMD", "node", "-e", "fetch('http://localhost:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 10s
    restart: unless-stopped

  db:
    image: postgres:18
    environment:
      POSTGRES_USER: app
      POSTGRES_DB: app
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
    volumes:
      - db-data:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 5s
      timeout: 5s
      retries: 10
    restart: unless-stopped

volumes:
  db-data:
  • Service names are host names. Compose puts both services on one network, so the app reaches the database at db:5432. Port 5432 is not published to your machine at all.
  • Environment: the official postgres image creates the user POSTGRES_USER, the database POSTGRES_DB and sets POSTGRES_PASSWORD on first start. The password itself comes from .env; ${POSTGRES_PASSWORD:?...} makes Compose stop with an error if it is missing.
  • Volume: db-data is a named volume, so the data survives when the container is removed. From PostgreSQL 18 on, the image expects the volume at /var/lib/postgresql; for PostgreSQL 17 and older, mount it at /var/lib/postgresql/data instead.
  • Healthchecks: pg_isready exits with 0 once the server accepts connections. depends_on with condition: service_healthy starts the app only after that, and restart: true restarts the app if the database is restarted by Compose. The app’s own healthcheck calls /healthz with Node.js, because the slim image has no curl.
  • restart: unless-stopped brings both containers back after a crash or a Docker restart, unless you stopped them yourself.

Now create the .env file with a password. Use letters and digits only, because the password is also placed inside the DATABASE_URL. The .dockerignore from Step 2 keeps this file out of the image; also add it to .gitignore if you use Git.

printf 'POSTGRES_PASSWORD=%s\n' "$(openssl rand -hex 16)" > .env
docker compose config --quiet && echo "compose file ok"

Check: docker compose config --quiet prints nothing and the success message appears. Without --quiet it shows the resolved file with the password filled in, which is a quick way to see what Compose will really do (do not paste that output anywhere public).

Step 6: Start the stack and watch the healthchecks

Why: --build builds the app image from your Dockerfile, and --wait returns only when the services are running and healthy, which makes problems visible immediately.

docker compose up --build --wait
docker compose ps

Check: docker compose ps lists db and web, both with a status that includes (healthy). Now use the app:

curl http://localhost:8080/
curl http://localhost:8080/
curl http://localhost:8080/

Each answer shows a higher number: {"message":"Hello from Docker","visits":3} after the third call. Look into the database directly to confirm the rows exist:

docker compose exec db psql -U app -d app -c 'SELECT count(*) FROM visits;'

Step 7: Prove that the data survives in the volume

Why: containers are disposable, data is not. Removing and recreating containers must not lose a single row, because the database files live in the named volume.

docker compose down
docker volume ls
docker compose up --wait
curl http://localhost:8080/

Check: docker compose down removes the containers and the network but keeps the volume, which appears in docker volume ls with the project name as prefix (for example visits_db-data). After starting again, the counter continues from where it was instead of starting at 1.

Step 8: Read the logs

Why: in containers, apps write logs to standard output, and Docker collects them. Compose shows the logs of all services together, each line prefixed with the service name.

docker compose logs --tail 20
docker compose logs -f web

--tail 20 limits the output to the last 20 lines per service; -f keeps following new lines until you press Ctrl+C. In a second terminal, run curl http://localhost:8080/nope and docker compose restart web, and watch the SIGTERM received, shutting down and listening on port 3000 lines appear.

Check: you see the app’s listening on port 3000 line and PostgreSQL’s startup messages, including that it is ready to accept connections.

Step 9: Change the code and rebuild

Why: the image is a snapshot of your code at build time. Editing server.js changes nothing in the running container until you rebuild the image and recreate the container.

sed -i.bak "s/Hello from Docker/Hello again from Docker/" server.js && rm server.js.bak
docker compose up --build --wait web
curl http://localhost:8080/

Check: the reply now says Hello again from Docker, and the visit count kept growing because only the web container was replaced. The build output shows the dependency layers as CACHED: because package.json and the lock file did not change, only the final COPY step ran again.

Check that it works

  1. docker compose ps shows both services as (healthy).
  2. Repeated curl http://localhost:8080/ calls return a growing visits number.
  3. docker compose down followed by docker compose up --wait keeps the count.
  4. docker compose exec web whoami prints node.
  5. docker compose stop db makes / return database unavailable while /healthz still answers ok; docker compose start db brings it back.

If something goes wrong

  • Compose stops with a message that POSTGRES_PASSWORD must be set: the .env file is missing or not in the same folder as compose.yaml. Create it as in Step 5.
  • The port is already allocated: another program uses port 8080. Change the left side of "8080:3000", for example to "8081:3000", and use that port in curl.
  • The app logs password authentication errors after you changed the password: PostgreSQL applies POSTGRES_PASSWORD only when it creates a new, empty data directory. Either change the password inside the database, or, on a test setup only, delete the volume (see Clean up) so it is created again.
  • The db container keeps restarting with PostgreSQL 18: check that the volume is mounted at /var/lib/postgresql, not /var/lib/postgresql/data. Read the reason with docker compose logs db.
  • Your code change does not show up: you restarted without rebuilding. Use docker compose up --build; docker compose restart reuses the old image.
  • npm ci fails during the build: package-lock.json is missing or out of date. Run npm install locally (Step 1) and build again.

Clean up

Stop and remove the containers and network, keeping the database volume:

docker compose down

Warning: the next command also deletes the db-data volume, which permanently erases all data in the database. Check with docker volume ls that the volume belongs to this tutorial project first.

docker volume ls
docker compose down -v --rmi local
docker image rm visits:1.0

--rmi local removes the image Compose built for the web service; the last command removes the image from Step 4. The node and postgres base images stay cached; remove them with docker image rm if you need the disk space.

Next steps

  • Add a compose.override.yaml for development that bind-mounts the source code, so changes appear without rebuilding.
  • Move the password from .env to Compose secrets, which mount it as a file instead of an environment variable.
  • Run database migrations with a dedicated tool instead of CREATE TABLE IF NOT EXISTS in the app.
  • Back up the database with docker compose exec db pg_dump -U app app > backup.sql and practice restoring it.
  • Push the image to a registry and deploy the same compose.yaml to a server.

Official documentation

Did this work for you?

No votes yet. Be the first.

If it did not work, the report form below tells the editors what went wrong.

See also

  • 0055 Docker basics: images, containers and the commands you need An image is a read-only package of an app and its dependencies, and a container is a running instance of it. Learn… Beginner · Explains an idea · Linux, macOS, Windows Article Docker basics 15 Oct 2026
  • 0056 Docker Compose: run a small web app with a database Docker Compose describes several containers, their ports, volumes and environment in one compose.yaml file, then starts them all with docker compose up. Intermediate · Step by step · Linux, macOS, Windows Article Docker basics · Docker Compose 15 Oct 2026
  • 0198 Install PostgreSQL and connect with psql on Debian, Ubuntu and RHEL Install the PostgreSQL server from your distribution or the official PGDG repositories, start it, and open your first psql session as the… Beginner · Step by step · Linux Article Install & connect 15 Oct 2026
  • 0199 psql meta-commands cheat sheet The backslash commands you use every day in psql: connecting, listing databases, tables and roles, describing objects, timing queries, expanded output and… Beginner · Cheat sheet · Any system Article Install & connect · Roles & administration 15 Oct 2026
  • 0200 PostgreSQL roles, privileges and default privileges Create login and group roles, grant access to databases, schemas and tables, and use ALTER DEFAULT PRIVILEGES so tables created later get… Intermediate · Step by step · Linux Article Roles & administration · Security & access 15 Oct 2026
  • 0201 pg_hba.conf and PostgreSQL authentication methods pg_hba.conf decides who may connect from where and how they must prove who they are. Rules are checked top to bottom and… Intermediate · Explains an idea · Linux Article Install & connect · Security & access 15 Oct 2026

Repos in the wild

  • postgres/postgres: PostgreSQL source code mirror postgres/postgres The official read-only GitHub mirror of the PostgreSQL source repository. Browse the code and history here; patches go through the project's mailing lists, not pull requests. GitHub
  • pgBackRest: backup and restore for PostgreSQL pgbackrest/pgbackrest pgBackRest is a widely used PostgreSQL backup tool with parallel full, differential and incremental backups, WAL archiving, encryption and S3, Azure or GCS repositories. GitHub
  • docker/compose docker/compose The source of Docker Compose, the tool that defines and runs multi-container applications from a compose.yaml file. Useful for release notes, the issue tracker and the examples directory. GitHub
  • Watchtower (maintained fork): automatic container image updates nicholas-fedor/watchtower Watchtower watches your running containers, pulls newer versions of their images and restarts them with the same settings. This fork continues the project after the original containrrr repository was archived. GitHub

History

Every saved edit is kept. The plot on the sleeve draws one line per edit. Open any edit to see exactly what changed.

  1. Dominik +15,889 chars

Report a problem

Something wrong, unsafe or copied? Tell the editors.
What is wrong?