Posted Sep 29, 2026 · 5 min read · 0 views
Docker for Beginners: Containerize a Node.js App with PostgreSQL
"It works on my machine" is one of the oldest jokes in software, and one of the most expensive problems. Different Node versions, missing system packages, and mismatched database setups waste hours every week.
Docker solves this by packaging your app and everything it needs into a container that runs the same way everywhere. In this tutorial you will containerize a Node.js API, add a PostgreSQL database with Docker Compose, and learn the habits that keep your images small and secure.
What you need
- Docker Desktop (Windows or macOS) or Docker Engine (Linux)
- Node.js installed locally, only to create the starter project
- A code editor and a terminal
Verify the installation:
docker --version
docker compose version
Step 1: Create a small Node.js app
Create a folder and initialize the project:
mkdir docker-demo && cd docker-demo
npm init -y
npm install express pg
Create index.js:
const express = require('express');
const { Pool } = require('pg');
const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
app.get('/', (req, res) => {
res.json({ message: 'Hello from Docker' });
});
app.get('/time', async (req, res) => {
try {
const result = await pool.query('SELECT NOW() AS now');
res.json(result.rows[0]);
} catch (err) {
res.status(500).json({ error: 'Database unavailable' });
}
});
const port = process.env.PORT || 3000;
app.listen(port, () => console.log(`Listening on port ${port}`));
The /time route proves that the app can talk to the database, which we will add in a moment.
Step 2: Write the Dockerfile
A Dockerfile is a recipe for building your image. Create a file named Dockerfile with no extension:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
USER node
CMD ["node", "index.js"]
Here is why each part matters:
node:20-alpineis a small base image, so downloads and builds are faster.- Copying
package*.jsonfirst lets Docker cache the dependency layer. If you only change your code,npm cidoes not run again. npm ci --omit=devinstalls exact locked versions and skips dev dependencies.USER noderuns the app as a non-root user, which limits damage if the app is compromised.
Step 3: Add a .dockerignore file
Without this file, Docker copies everything into your image, including node_modules and secrets. Create .dockerignore:
node_modules
npm-debug.log
.git
.env
Dockerfile
docker-compose.yml
This keeps images small and stops your .env file from leaking into a published image.
Step 4: Build and run the container
docker build -t docker-demo .
docker run -p 3000:3000 docker-demo
Open http://localhost:3000 and you should see the JSON greeting. The -p 3000:3000 flag maps port 3000 on your machine to port 3000 in the container.
Press Ctrl + C to stop it. The /time route will fail for now, since no database exists yet.
Step 5: Add PostgreSQL with Docker Compose
Running several containers by hand gets messy. Docker Compose lets you describe your whole stack in one file. Create docker-compose.yml:
services:
app:
build: .
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://appuser:secret@db:5432/appdb
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: appuser
POSTGRES_PASSWORD: secret
POSTGRES_DB: appdb
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
interval: 5s
timeout: 5s
retries: 5
volumes:
pgdata:
A few important details:
- The host is
db, notlocalhost. Inside Compose, services reach each other by service name. - The named volume
pgdatakeeps your database files, so data survives when containers are removed. - The healthcheck makes the app wait until PostgreSQL is truly ready, not just started.
Start everything:
docker compose up --build
Visit http://localhost:3000/time and you will see the current database time. Your app and database are now running together with a single command.
Useful commands you will use daily
docker compose up -d # start in the background
docker compose logs -f app # follow logs for one service
docker compose ps # see running services
docker compose down # stop and remove containers
docker compose down -v # also delete volumes (wipes the database)
docker exec -it <container> sh # open a shell inside a container
Be careful with down -v. It deletes your data.
Development tip: live reload with bind mounts
Rebuilding the image after every code change is slow. For local development, add a bind mount and a watcher in a separate override file, docker-compose.override.yml:
services:
app:
command: npx nodemon index.js
volumes:
- .:/app
- /app/node_modules
Install nodemon as a dev dependency first (npm install --save-dev nodemon), and note that the Dockerfile above omits dev dependencies, so for development build with npm ci instead. Many teams keep a separate Dockerfile.dev for this reason.
Best practices checklist
- Pin image versions (
node:20-alpine), never uselatestin production - Keep secrets out of images and out of Git, and use environment variables or a secrets manager
- Use a
.dockerignorefile in every project - Run as a non-root user
- Use multi-stage builds when your app needs a compile step, such as TypeScript
- Add healthchecks so orchestration tools know when a service is really ready
Common mistakes
- Using
localhostfor the database host. Inside a container,localhostmeans the container itself. - Forgetting to expose or map ports. The app runs but you cannot reach it.
- Storing data without a volume. The database resets every time the container is recreated.
- Copying
node_modulesfrom your machine. Native modules built on Windows or macOS may not work on Linux.
Final thoughts
Docker feels like extra work at first, but it pays off quickly. New teammates run one command and get a working environment, your staging server matches production, and deployments become predictable. Start with a Dockerfile and a Compose file for your next project, and add more only when you need it.
Are you already using Docker in your projects? Tell us how you set it up in the comments.
Discussion (0)