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 like bcrypt and sharp.

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 use node:latest or node:20 without 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 like node: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| App
Always use condition: service_healthy in depends_on rather than a plain depends_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 — no sleep in 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 .dockerignore file keeps unnecessary files out of the build context. Without it, Docker would copy the host’s node_modules into 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 #

MethodPathPurposeIdempotent
GET/usersList all usersYes
GET/users/:idGet one userYes
POST/usersCreate a new userNo
PUT/users/:idUpdate a user (replace)Yes
PATCH/users/:idUpdate some fieldsNo
DELETE/users/:idDelete a userYes

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 .length to 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:

ServiceURLDescription
Apphttp://localhost:3000Main API
Health checkhttp://localhost:3000/healthServer status
Postgreslocalhost:5432User app, password dev
Redislocalhost:6379Cache
pgAdmin (optional)http://localhost:5050Database 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"}'
Use docker compose logs --tail=100 -f app to see the last 100 log lines while still following new output. Combine with | grep ERROR to 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:

MiddlewareFunctionImportant Configuration
helmetSet security headers (CSP, HSTS, X-Frame-Options)app.use(helmet())
corsSet cross-origin policyorigin: ['https://app.example.com']
express-rate-limitPrevent brute force and abusewindowMs, max
express.json({ limit })Limit body sizelimit: '1mb'
express-mongo-sanitizeAnti NoSQL injectionFor 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 #

StrategyProsConsBest For
Global limiterSimple, one configUnfair for heavy endpointsSmall APIs
Per-route limiterGranular controlRepetitiveLarge public APIs
User-based limiterFair per userNeeds auth up frontAPIs with login
IP-based limiterEasy to implementVPN/proxy can bypassAnti-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: the Accept: application/vnd.myapi.v2+json header, or a subdomain like v2.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:

  • nodemon is in devDependencies
  • The ./src:/app/src volume 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 deps stage for development (with devDependencies), a runner stage for production (slim image, non-root user).
  • The anonymous volume /app/node_modules is mandatory — it prevents the host from overwriting the container’s dependencies.
  • Healthchecks on Postgres and Redis + depends_on: condition: service_healthy so 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). Use next(err) in routes, catch in middleware.
  • API versioning via /api/v1, /api/v2 paths — 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).

← Previous: Rocket   Next: NestJS →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact