Express.js #
Express.js is the most widely used Node.js web framework in the world. It’s the foundation for thousands of REST APIs, BFFs, and microservices. Its ecosystem is mature, its community is huge, and integrations with databases, caches, and message brokers almost always have official libraries. But behind its popularity, Express hides one weak point that often surfaces in local development: environment differences between developers.
You’ve surely experienced it: the app runs smoothly on your laptop, but npm install fails on a teammate’s. Or code you wrote on Node 20 doesn’t run because someone is still on Node 16. Problems like these aren’t Express’s fault — they’re environment setup issues. Docker is the most pragmatic solution — you standardize the runtime, dependencies, and supporting services in a single docker-compose.yml file that anyone can run.
This article covers a complete Docker Compose setup for Express.js local development: an optimal multi-stage Dockerfile, docker-compose with Postgres and Redis, nodemon hot reload, modular routing, middleware, error handling, validation, ORMs, and best practices that are often overlooked.
Prerequisites #
Make sure the host has installed:
- Docker and Docker Compose (latest versions, v2.20+)
- Node.js 20+ — optional, only for running tests or tooling on the host
- Your favorite editor (VS Code, WebStorm, Neovim)
- curl or Postman — for testing endpoints
The assumed Express project structure:
express-app/
├── src/
│ ├── index.js
│ ├── routes/
│ ├── controllers/
│ ├── middleware/
│ ├── services/
│ ├── validators/
│ └── db/
├── tests/
├── package.json
├── package-lock.json
├── Dockerfile
├── docker-compose.yml
├── .dockerignore
├── .env.example
└── .eslintrc.cjs
Note: The structure above is a lightweight MVC pattern. You can adapt it to a monorepo or layered architecture per your team’s needs.
Use Node.js 20 LTS as the minimum runtime. Node 18 is end-of-life, and many modern dependencies already require Node 20+. The Alpine image is chosen for its small size, and it includes glibc compatible with binary modules likebcryptandsharp.
Multi-Stage Dockerfile #
For Express, we build a multi-stage Dockerfile. The first stage (deps) holds Node with devDependencies; the second stage (runner) holds a slim Alpine Node image for production. For local development we run the deps stage — it already has all the tools needed, including nodemon.
# syntax=docker/dockerfile:1.6
FROM node:20-alpine AS deps
WORKDIR /app
# Copy the manifest first to optimize layer caching
COPY package.json package-lock.json ./
# Install all dependencies, including devDependencies (nodemon, eslint, jest)
RUN npm install --no-audit --no-fund
# Runner stage for production
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production \
PORT=3000
# Create a non-root user for security
RUN addgroup -S app && adduser -S app -G app
# Copy dependencies from the deps stage
COPY --from=deps --chown=app:app /app/node_modules ./node_modules
COPY --chown=app:app package.json ./
COPY --chown=app:app src ./src
USER app
EXPOSE 3000
CMD ["node", "src/index.js"]
Stage Explanations #
The deps stage — contains node_modules complete with devDependencies. This is the stage used for development. Layer caching here is crucial: copy package.json and package-lock.json first, run npm install, and Docker caches the dependency layer. When you edit code, the dependency layer isn’t rebuilt.
The runner stage — a slim production image. Only production dependencies (NODE_ENV=production) and source code. A non-root user (app) is important to prevent the container from running as root — if there’s a security hole in the app, attackers don’t immediately get root access on the host.
--no-audit --no-fund — prevents npm from showing irrelevant audit and funding messages in build logs.
Never usenode:latestornode:20without a tag. An untagged version pulls a random image at build time, and bugs can suddenly appear when the image is re-pulled. Always use a specific LTS tag likenode:20-alpine.
docker-compose.yml for Development #
# docker-compose.yml
services:
app:
build:
context: .
dockerfile: Dockerfile
target: deps # use the deps stage (has devDependencies)
image: express-app:dev
container_name: express-dev
command: npm run dev
working_dir: /app
ports:
- "3000:3000"
volumes:
- ./src:/app/src
- ./tests:/app/tests
- /app/node_modules # anonymous volume: protect node_modules
environment:
NODE_ENV: development
PORT: 3000
DATABASE_URL: postgres://app:pass@db:5432/express_dev
REDIS_URL: redis://cache:6379
JWT_SECRET: dev-secret-change-me
depends_on:
db:
condition: service_healthy
cache:
condition: service_healthy
db:
image: postgres:16-alpine
container_name: express-db
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: dev
POSTGRES_DB: express_dev
volumes:
- db-data:/var/lib/postgresql/data
- ./db/init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d express_dev"]
interval: 10s
timeout: 5s
retries: 5
ports:
- "5432:5432"
cache:
image: redis:7-alpine
container_name: express-cache
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 3
ports:
- "6379:6379"
volumes:
db-data:
Service Explanations #
app — the Express service. The target: deps line selects the deps stage from the Dockerfile, so node_modules complete with devDependencies gets copied. command: npm run dev runs the dev script from package.json (which we set to nodemon). The ./src:/app/src volume mirrors host source code into the container — nodemon detects changes and restarts the server.
The anonymous volume /app/node_modules — this small line is very important. Without it, Docker would overwrite the container’s node_modules with the host’s empty version (which usually doesn’t exist). The anonymous volume preserves the node_modules already installed in the image.
db — PostgreSQL 16 with the pg_isready healthcheck. The app won’t start until the database is truly ready to accept connections. The db-data volume stores data in a named volume so it survives container restarts. The db/init folder is mounted to /docker-entrypoint-initdb.d to automatically run initialization SQL.
cache — Redis 7 for sessions, caching, and rate limiting. The redis-cli ping healthcheck ensures Redis is alive before the app starts.
Architecture Diagram #
flowchart LR
Dev[Developer<br/>on Host] -->|edit src/ via bind mount| App[Container: app<br/>Express + nodemon]
App -->|query SELECT/INSERT| DB[(Postgres 16)]
App -->|GET/SET cache| Cache[(Redis 7)]
DB -.->|healthcheck<br/>pg_isready| HC1{Health Check}
Cache -.->|healthcheck<br/>redis-cli ping| HC2{Health Check}
HC1 -->|healthy| App
HC2 -->|healthy| AppAlways usecondition: service_healthyindepends_onrather than a plaindepends_on: - db. The plain version only waits for the container to start, not for the service to be ready. A healthcheck makes the app start exactly when Postgres accepts connections — nosleepin the entrypoint needed.
Hot Reload with Nodemon #
Nodemon is the standard hot-reload choice for Express. Add it to devDependencies and create a configuration that ignores folders which shouldn’t trigger restarts.
package.json:
{
"name": "express-app",
"version": "1.0.0",
"type": "commonjs",
"scripts": {
"dev": "nodemon --watch src --ext js,json",
"start": "node src/index.js",
"test": "jest --runInBand"
},
"dependencies": {
"express": "^4.19.2",
"pg": "^8.12.0",
"ioredis": "^5.4.1",
"zod": "^3.23.8",
"helmet": "^7.1.0",
"cors": "^2.8.5",
"express-rate-limit": "^7.4.0",
"pino": "^9.3.2",
"pino-http": "^10.2.0"
},
"devDependencies": {
"nodemon": "^3.1.4",
"jest": "^29.7.0",
"supertest": "^7.0.0"
}
}
nodemon.json (optional, for more control):
{
"watch": ["src"],
"ext": "js,json",
"ignore": ["src/**/*.test.js", "node_modules"],
"delay": 500
}
.dockerignore:
node_modules
npm-debug.log
.git
.gitignore
.env
.env.local
coverage
.nyc_output
tests
Dockerfile
docker-compose.yml
.dockerignore
README.md
The
.dockerignorefile keeps unnecessary files out of the build context. Without it, Docker would copy the host’snode_modulesinto the image — which is usually platform-specific and would conflict with the Alpine version.
Modular Routing #
Express follows a flexible routing pattern. For projects that start growing, split routing by resource to stay maintainable.
src/index.js — entry point:
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const pinoHttp = require('pino-http');
const rateLimit = require('express-rate-limit');
const logger = require('./services/logger');
const errorHandler = require('./middleware/errorHandler');
const notFound = require('./middleware/notFound');
const userRoutes = require('./routes/users');
const authRoutes = require('./routes/auth');
const app = express();
// ANTI-PATTERN: forgetting security middleware
// app.use(express.json());
// CORRECT: safe middleware order
app.use(helmet()); // security headers
app.use(cors({ origin: process.env.CORS_ORIGIN || '*' }));
app.use(express.json({ limit: '1mb' })); // body parser with a limit
app.use(pinoHttp({ logger })); // request logging
// Global rate limit
app.use(rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false
}));
// Routes
app.use('/api/v1/auth', authRoutes);
app.use('/api/v1/users', userRoutes);
// Health check
app.get('/health', (req, res) => res.json({ status: 'ok' }));
// 404 and error handlers MUST be at the very end
app.use(notFound);
app.use(errorHandler);
const PORT = process.env.PORT || 3000;
app.listen(PORT, '0.0.0.0', () => {
logger.info(`Server running on port ${PORT}`);
});
module.exports = app;
src/routes/users.js:
const express = require('express');
const { getUser, listUsers, createUser, updateUser, deleteUser } = require('../controllers/users');
const { validate } = require('../middleware/validate');
const { createUserSchema, updateUserSchema } = require('../validators/user');
const router = express.Router();
router.get('/', listUsers);
router.get('/:id', getUser);
router.post('/', validate(createUserSchema), createUser);
router.put('/:id', validate(updateUserSchema), updateUser);
router.delete('/:id', deleteUser);
module.exports = router;
Common HTTP Method Table #
| Method | Path | Purpose | Idempotent |
|---|---|---|---|
GET | /users | List all users | Yes |
GET | /users/:id | Get one user | Yes |
POST | /users | Create a new user | No |
PUT | /users/:id | Update a user (replace) | Yes |
PATCH | /users/:id | Update some fields | No |
DELETE | /users/:id | Delete a user | Yes |
Custom Middleware #
Middleware is the heart of Express. The (req, res, next) function that intercepts requests before they reach handlers. Three middleware pieces you almost always need: validator, logger, and error handler.
src/middleware/validate.js — Zod body validation:
const { ZodError } = require('zod');
function validate(schema) {
return (req, res, next) => {
try {
// ANTI-PATTERN: manual one-by-one validation
// if (!req.body.email) return res.status(400).json({...});
// if (!req.body.password || req.body.password.length < 8) return ...
// CORRECT: schema-based validation
req.body = schema.parse(req.body);
next();
} catch (err) {
if (err instanceof ZodError) {
return res.status(400).json({
error: 'ValidationError',
details: err.issues.map(i => ({
path: i.path.join('.'),
message: i.message
}))
});
}
next(err);
}
};
}
module.exports = { validate };
src/validators/user.js:
const { z } = require('zod');
const createUserSchema = z.object({
body: z.object({
email: z.string().email(),
name: z.string().min(2).max(100),
password: z.string().min(8).max(128),
role: z.enum(['user', 'admin']).default('user')
})
});
const updateUserSchema = z.object({
body: z.object({
name: z.string().min(2).max(100).optional(),
email: z.string().email().optional()
})
});
module.exports = { createUserSchema, updateUserSchema };
src/middleware/errorHandler.js — the central error handler:
const logger = require('../services/logger');
// ANTI-PATTERN: catching errors in each route one by one
// router.get('/users/:id', async (req, res) => {
// try { ... } catch (e) { res.status(500).send(e) }
// });
// CORRECT: a centralized error middleware
function errorHandler(err, req, res, next) {
// Default error
const status = err.statusCode || 500;
const code = err.code || 'InternalServerError';
// Log the full error on the server
logger.error({
err,
requestId: req.id,
path: req.path,
method: req.method
}, 'request failed');
// Don't leak internal details to the client
const response = {
error: code,
message: status < 500 ? err.message : 'Something went wrong'
};
if (err.details) response.details = err.details;
res.status(status).json(response);
}
module.exports = errorHandler;
src/middleware/notFound.js:
function notFound(req, res) {
res.status(404).json({
error: 'NotFound',
message: `Route ${req.method} ${req.path} not found`
});
}
module.exports = notFound;
Express recognizes error handlers by the(err, req, res, next)signature — four parameters. Make sure the parameter count is exactly four, because Express uses the function’s.lengthto distinguish error handlers from regular middleware. If you write(err, req, res), Express ignores it.
Database Access with pg (node-postgres) #
Express has no built-in ORM — that’s a design decision, not a flaw. You’re free to choose: Prisma, Knex, Sequelize, Drizzle, or raw pg for full control. For this example we use the pg Pool.
src/db/pool.js:
const { Pool } = require('pg');
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20, // max connections in the pool
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000
});
pool.on('error', (err) => {
// Log the error so it doesn't crash silently
console.error('Unexpected error on idle PG client', err);
});
module.exports = { pool };
src/controllers/users.js:
const { pool } = require('../db/pool');
const { NotFoundError, ValidationError } = require('../errors');
async function listUsers(req, res, next) {
try {
const limit = Math.min(parseInt(req.query.limit) || 20, 100);
const offset = parseInt(req.offset) || 0;
const result = await pool.query(
'SELECT id, email, name, role, created_at FROM users ORDER BY id LIMIT $1 OFFSET $2',
[limit, offset]
);
res.json({ data: result.rows, total: result.rowCount });
} catch (err) {
next(err);
}
}
async function getUser(req, res, next) {
try {
const id = parseInt(req.params.id, 10);
if (Number.isNaN(id)) throw new ValidationError('id must be a number');
const result = await pool.query(
'SELECT id, email, name, role, created_at FROM users WHERE id = $1',
[id]
);
if (result.rowCount === 0) throw new NotFoundError(`User ${id} not found`);
res.json({ data: result.rows[0] });
} catch (err) {
next(err);
}
}
module.exports = { listUsers, getUser };
src/errors/index.js — custom error classes:
class AppError extends Error {
constructor(message, statusCode = 500, code = 'AppError') {
super(message);
this.statusCode = statusCode;
this.code = code;
}
}
class NotFoundError extends AppError {
constructor(message = 'Resource not found') {
super(message, 404, 'NotFound');
}
}
class ValidationError extends AppError {
constructor(message, details) {
super(message, 400, 'ValidationError');
this.details = details;
}
}
class UnauthorizedError extends AppError {
constructor(message = 'Unauthenticated') {
super(message, 401, 'Unauthorized');
}
}
module.exports = { AppError, NotFoundError, ValidationError, UnauthorizedError };
Never return stack traces or internal error details (like SQL error messages) to the client in production. It’s fine in development, but in production log the full error on the server and return a generic response. The error handler above already filters this: for 5xx statuses, the response only shows “Something went wrong”.
Testing with Jest and Supertest #
Testing Express is often skipped. But with supertest, you can test HTTP endpoints without actually starting a server — Supertest binds to an ephemeral port.
tests/users.test.js:
const request = require('supertest');
const app = require('../src/index');
const { pool } = require('../src/db/pool');
describe('GET /api/v1/users', () => {
afterAll(async () => {
await pool.end();
});
it('returns a user list', async () => {
const res = await request(app).get('/api/v1/users');
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('data');
expect(Array.isArray(res.body.data)).toBe(true);
});
it('returns 404 for a user that does not exist', async () => {
const res = await request(app).get('/api/v1/users/99999');
expect(res.status).toBe(404);
expect(res.body.error).toBe('NotFound');
});
});
Can tests run without starting Postgres? With mocking, yes. But ideally you have an isolated test database. For development, add a db-test service in Compose with a separate database.
Build and Run #
Everyday commands:
# Build the image the first time (or after changing the Dockerfile/package.json)
docker compose build
# Run all services in the background
docker compose up -d
# View app logs in real-time
docker compose logs -f app
# Restart only the app service (e.g. after changing env vars)
docker compose restart app
# Stop all services
docker compose down
# Remove containers + volumes (Postgres data is lost!)
docker compose down -v
Access after up:
| Service | URL | Description |
|---|---|---|
| App | http://localhost:3000 | Main API |
| Health check | http://localhost:3000/health | Server status |
| Postgres | localhost:5432 | User app, password dev |
| Redis | localhost:6379 | Cache |
| pgAdmin (optional) | http://localhost:5050 | Database UI, add manually |
Test the endpoint with curl:
curl -X POST http://localhost:3000/api/v1/users \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","name":"Test User","password":"password123"}'
Usedocker compose logs --tail=100 -f appto see the last 100 log lines while still following new output. Combine with| grep ERRORto filter only errors — a fast way to debug when a request fails.
Security Checklist #
Express is very flexible, but also very “empty-handed” — no built-in protection. Add these middleware pieces early in the pipeline:
| Middleware | Function | Important Configuration |
|---|---|---|
helmet | Set security headers (CSP, HSTS, X-Frame-Options) | app.use(helmet()) |
cors | Set cross-origin policy | origin: ['https://app.example.com'] |
express-rate-limit | Prevent brute force and abuse | windowMs, max |
express.json({ limit }) | Limit body size | limit: '1mb' |
express-mongo-sanitize | Anti NoSQL injection | For MongoDB apps |
Middleware order is crucial. What must come first: helmet, then CORS, then the body parser, then the logger, then the rate limiter, then the router. Error handlers and 404 at the end.
// The correct order
app.use(helmet());
app.use(cors());
app.use(express.json({ limit: '1mb' }));
app.use(pinoHttp({ logger }));
app.use(rateLimit({ windowMs: 15 * 60 * 1000, max: 100 }));
// ... routers
app.use(notFound);
app.use(errorHandler);
Rate Limiting Strategy Comparison #
| Strategy | Pros | Cons | Best For |
|---|---|---|---|
| Global limiter | Simple, one config | Unfair for heavy endpoints | Small APIs |
| Per-route limiter | Granular control | Repetitive | Large public APIs |
| User-based limiter | Fair per user | Needs auth up front | APIs with login |
| IP-based limiter | Easy to implement | VPN/proxy can bypass | Anti-scraper |
API Versioning #
Someday your API will change. The cleanest way to handle change is path-based versioning: /api/v1/..., /api/v2/.... No need to delete old endpoints — let them coexist until clients migrate.
// src/routes/v1/index.js
const router = require('express').Router();
router.use('/users', require('./users'));
router.use('/auth', require('./auth'));
module.exports = router;
// src/routes/v2/index.js
const router = require('express').Router();
router.use('/users', require('./users')); // new structure
module.exports = router;
// src/index.js
app.use('/api/v1', require('./routes/v1'));
app.use('/api/v2', require('./routes/v2'));
Other versioning alternatives: theAccept: application/vnd.myapi.v2+jsonheader, or a subdomain likev2.api.example.com. Path-based is the simplest and clearest in logs, browser URLs, and Postman. Pick one and stay consistent across your whole API.
Best Practices #
1. Use an Anonymous Volume for node_modules #
volumes:
- /app/node_modules # DON'T remove this line
Without this volume, Docker overwrites the container’s node_modules with the host’s empty version (which usually doesn’t exist). Express won’t start.
2. Use Healthchecks #
Postgres and Redis must have healthchecks. The app starts with condition: service_healthy in depends_on. Don’t use sleep in the entrypoint — that’s a code smell.
3. Separate Dev and Prod Dockerfiles #
The files above focus on development. For production, add a runner stage that only copies production dependencies (see the multi-stage Dockerfile above). Or create separate Dockerfile.dev and Dockerfile.prod files.
4. Log to stdout/stderr #
Use pino or winston with the stdout transport. Docker captures stdout/stderr via docker compose logs. Don’t log to files — they’ll fill the container disk.
5. Validate with Schemas, Not If-Else #
Zod, Joi, or class-validator. Reusable schemas, automatic documentation via TypeScript inference, and no duplicated validation in controllers.
6. Centralized Error Handlers #
Every next(err) in a route reaches the error handler. This reduces try-catch boilerplate and ensures consistent error responses.
7. Use a Non-Root User #
The runner stage in the Dockerfile creates an app user and runs the container as that user. Prevents privilege escalation if the app is exploited.
Troubleshooting #
Container Keeps Restarting (CrashLoopBackOff) #
Check the logs:
docker compose logs --tail=50 app
Common causes: a wrong DATABASE_URL, missing node_modules, a port conflict. Verify the environment variables and startup order with docker compose ps.
Hot Reload Not Working #
Make sure:
nodemonis indevDependencies- The
./src:/app/srcvolume is mounted correctly - The edited file is outside ignored folders (
ignore: ['src/**/*.test.js']can skip test files)
Test: edit a file on the host, run docker compose logs -f app — you should see a restart message from nodemon.
pg_isready Failing #
The healthcheck is misconfigured. Check that the user, database name, and password in pg_isready match the environment on the db service.
docker compose exec db pg_isready -U app -d express_dev
Redis Connection Refused #
The redis-cli ping healthcheck should return PONG. Check the REDIS_URL=redis://cache:6379 env var (the hostname is the service name, not localhost).
Summary #
- Express is ideal for consistent local development — Postgres and Redis run as containers, not manual installs.
- Multi-stage Dockerfiles: a
depsstage for development (with devDependencies), arunnerstage for production (slim image, non-root user).- The anonymous volume
/app/node_modulesis mandatory — it prevents the host from overwriting the container’s dependencies.- Healthchecks on Postgres and Redis +
depends_on: condition: service_healthyso the app starts after dependencies are ready.- Hot reload with
nodemon+ source bind mounts. Edit on the host, automatic restart in the container.- Modular routing — split per resource, mount at
/api/v1/<resource>to keep the structure clear as the project grows.- Middleware:
helmet(security),cors(origin policy),express.json({ limit })(body parser with a limit),pino-http(logger),express-rate-limit(anti-abuse).- Schema-based validation with Zod/Joi — safer, reusable, and more consistent than manual if-else.
- Centralized error handlers with custom error classes (
NotFoundError,ValidationError,UnauthorizedError). Usenext(err)in routes, catch in middleware.- API versioning via
/api/v1,/api/v2paths — the clearest way to evolve an API without breaking clients.- Log to stdout, not files. Docker handles log rotation and aggregation.
- Security headers + rate limiting are the minimum, not optional. Express protects nothing by default.
- Separate dev and prod Dockerfiles. Development focuses on DX (hot reload, devDeps), production on size and security.
- Troubleshooting is most often: container crash loops (check logs), dead hot reload (check volumes), pg/redis refused (check healthchecks).