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?
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
.dockerignorethat keeps local junk and secrets out of the image. - A
compose.yamlthat runs the app next to PostgreSQL 18, with the password in a.envfile, 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 versionmust work (note: a space, not the olddocker-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-infetch, which needs Node.js 18 or newer. curlfor 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=devinstalls exactly the versions inpackage-lock.jsonand skips development dependencies.- Copying
package.jsonand the lock file before the source code lets Docker reuse the cached dependency layer when onlyserver.jschanges. ENV NODE_ENV=productionis the setting the Node.js image maintainers recommend for production.CMD ["node", "server.js"]starts Node.js directly instead of throughnpm start, so the stop signal reaches your code.EXPOSE 3000documents 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
postgresimage creates the userPOSTGRES_USER, the databasePOSTGRES_DBand setsPOSTGRES_PASSWORDon first start. The password itself comes from.env;${POSTGRES_PASSWORD:?...}makes Compose stop with an error if it is missing. - Volume:
db-datais 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/datainstead. - Healthchecks:
pg_isreadyexits with 0 once the server accepts connections.depends_onwithcondition: service_healthystarts the app only after that, andrestart: truerestarts the app if the database is restarted by Compose. The app’s own healthcheck calls/healthzwith Node.js, because the slim image has nocurl. - 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
docker compose psshows both services as(healthy).- Repeated
curl http://localhost:8080/calls return a growingvisitsnumber. docker compose downfollowed bydocker compose up --waitkeeps the count.docker compose exec web whoamiprintsnode.docker compose stop dbmakes/returndatabase unavailablewhile/healthzstill answersok;docker compose start dbbrings it back.
If something goes wrong
- Compose stops with a message that
POSTGRES_PASSWORDmust be set: the.envfile is missing or not in the same folder ascompose.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 incurl. - The app logs password authentication errors after you changed the password: PostgreSQL applies
POSTGRES_PASSWORDonly 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 withdocker compose logs db. - Your code change does not show up: you restarted without rebuilding. Use
docker compose up --build;docker compose restartreuses the old image. npm cifails during the build:package-lock.jsonis missing or out of date. Runnpm installlocally (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.yamlfor development that bind-mounts the source code, so changes appear without rebuilding. - Move the password from
.envto Composesecrets, which mount it as a file instead of an environment variable. - Run database migrations with a dedicated tool instead of
CREATE TABLE IF NOT EXISTSin the app. - Back up the database with
docker compose exec db pg_dump -U app app > backup.sqland practice restoring it. - Push the image to a registry and deploy the same
compose.yamlto 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.
- Dominik +15,889 chars