Learn to package an Express 5 API in a small, secure Docker image with a multi-stage build, a non-root user and a health check, then run it with a database using Docker Compose.

Bishal Biswas
Self Learner
What you will learn
- What containers, images and Dockerfiles are, in plain words
- How to write a production-ready multi-stage Dockerfile for an Express 5 API
- Why the image should run as a non-root user and include a health check
- How to run your API next to MongoDB with Docker Compose
- The classic Docker mistakes and how to avoid them
The problem Docker solves
“It works on my machine” is one of the oldest jokes in software. Your laptop has Node 22, the server has Node 18. Your laptop is Windows, the server is Linux. A native package compiled on one doesn’t run on the other.
Docker solves this by packing your app together with everything it needs to run: the right Node.js version, the operating system files, your node_modules and your code. That bundle runs the same way on your laptop, in CI and on any server.
Think of a shipping container. The ship doesn’t care whether it carries bananas or bicycles; every container has the same shape and the same hooks. Docker gives your app the same standard shape.
Three words to know:
| Term | Meaning | Analogy |
|---|---|---|
| Dockerfile | A text file with step-by-step instructions to build the package | The recipe |
| Image | The finished, read-only package built from the Dockerfile | The cake in the box |
| Container | A running instance of an image | A slice being eaten. You can run many containers from one image. |
You’ll need Docker Desktop (Windows and macOS) or Docker Engine (Linux) installed.
Get the app ready first
A container-friendly Express app follows a few rules you’ve already learned:
- Reads its port from the environment. The platform decides the port, not you.
- Has a health endpoint, so Docker can check if it’s alive.
- Shuts down gracefully on
SIGTERM, the signal Docker sends when stopping a container. - Has a
package-lock.jsoncommitted, so installs are repeatable.
// server.js
import { app } from './app.js';
const port = Number(process.env.PORT) || 3000;
const server = app.listen(port, (error) => {
if (error) throw error;
console.log(`Listening on port ${port}`);
});
process.on('SIGTERM', () => {
console.log('SIGTERM received, closing server');
server.close(() => process.exit(0));
});
// app.js
import express from 'express';
export const app = express();
app.use(express.json());
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
app.get('/api/hello', (req, res) => {
res.json({ message: 'Hello from a container' });
});
Note:
app.listen(port)with no host listens on all network interfaces, which is what you want inside a container. If you ever pass'127.0.0.1'as the host, the app is only reachable from inside the container itself.
Step 1: The .dockerignore file
When you build, Docker sends your project folder to the builder. This is called the build context. A .dockerignore file lists what to leave out, just like .gitignore.
# .dockerignore
node_modules
npm-debug.log
.git
.env
.env.*
coverage
Dockerfile
.dockerignore
The two most important lines:
node_modules: your host’s packages may be built for the wrong operating system. The image installs its own..env: secrets must never be baked into an image. Anyone who can pull the image could read them.
Step 2: A first, simple Dockerfile
Let’s start simple to see the basic instructions:
# Dockerfile (simple version)
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
Line by line:
| Instruction | What it does |
|---|---|
FROM node:22-alpine |
Start from an official image with Node 22 on Alpine Linux, a very small Linux distribution |
WORKDIR /app |
Create /app and work inside it |
COPY package*.json ./ |
Copy package.json and package-lock.json only |
RUN npm ci --omit=dev |
Install exact versions from the lock file, without dev dependencies |
COPY . . |
Copy the rest of your code |
EXPOSE 3000 |
Document which port the app uses |
CMD ["node", "server.js"] |
The command that runs when a container starts |
Why copy package files first?
Docker builds images in layers, one per instruction, and caches each layer. If nothing a layer depends on has changed, Docker reuses it instead of running the step again.
FROM node:22-alpine cached
COPY package*.json ./ cached (you didn't change dependencies)
RUN npm ci --omit=dev cached (the slow step is skipped!)
COPY . . rebuilt (you edited app.js)
By copying the package files before the code, editing app.js doesn’t trigger a full reinstall. Builds go from minutes to seconds.
Tip:
npm ciis made for automated builds. It installs exactly what’s inpackage-lock.jsonand fails if the lock file doesn’t matchpackage.json.npm installmight quietly change versions.
Step 3: The production Dockerfile
The simple version works, but it has three weaknesses: it runs as root (the all-powerful admin user), it doesn’t set production mode, and Docker can’t tell whether the app is healthy. A multi-stage build also lets us keep build tools out of the final image.
A multi-stage build uses several FROM lines. Earlier stages do the messy work; the final stage copies only the results. It’s like cooking in a busy kitchen but serving only the clean plate.
# Dockerfile
# ---- Stage 1: install production dependencies ----
FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
# ---- Stage 2: the small runtime image ----
FROM node:22-alpine AS runtime
ENV NODE_ENV=production
WORKDIR /app
# Copy dependencies and code, owned by the unprivileged "node" user
COPY /app/node_modules ./node_modules
COPY . .
# Never run as root
USER node
EXPOSE 3000
# Ask the app itself if it's healthy (Alpine has no curl, but Node 22 has fetch)
HEALTHCHECK \
CMD node -e "fetch('http://localhost:3000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
CMD ["node", "server.js"]
What each improvement gives you:
ENV NODE_ENV=production: Express and many libraries switch to production behaviour, such as caching views and hiding stack traces in default error pages.USER node: the official Node images include a user callednode. If an attacker ever breaks into your app, they don’t get root inside the container.--chown=node:node: makes the copied files belong to that user.HEALTHCHECK: Docker runs the command regularly. Exit code0means healthy,1means unhealthy. Orchestrators use this to restart broken containers.
Build and run it
docker build -t notes-api .
docker run --rm -p 3000:3000 --name notes-api notes-api
-p 3000:3000 connects port 3000 on your machine to port 3000 in the container. In another terminal:
curl http://localhost:3000/api/hello
docker ps
{"message":"Hello from a container"}
CONTAINER ID IMAGE STATUS PORTS NAMES
3f2c1a9b7d10 notes-api Up 40 seconds (healthy) 0.0.0.0:3000->3000/tcp notes-api
See the (healthy) status? That’s your HEALTHCHECK at work.
Passing configuration
Configuration comes in at run time, not build time. Use -e for single values or --env-file for a file that stays on the server:
docker run --rm -p 3000:3000 --env-file .env.production notes-api
A TypeScript version
If your app is written in TypeScript (as in TypeScript with Express 5), add a build stage that has dev dependencies and compiles the code. Only the compiled JavaScript reaches the final image.
# Dockerfile (TypeScript)
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
FROM node:22-alpine AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY /app/node_modules ./node_modules
COPY /app/dist ./dist
COPY package.json ./
USER node
EXPOSE 3000
HEALTHCHECK \
CMD node -e "fetch('http://localhost:3000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
CMD ["node", "dist/server.js"]
package.json is copied so Node can see "type": "module". TypeScript, tsx and type packages never reach production.
Step 4: Signals and PID 1
When you run docker stop, Docker sends SIGTERM to the main process in the container, waits (10 seconds by default), then forces it to stop with SIGKILL.
The main process in a container gets process ID 1 (PID 1). PID 1 is special in Linux: it doesn’t get default signal handling. Two rules keep shutdown working:
- Use the exec form of
CMD:CMD ["node", "server.js"]with square brackets. The shell form,CMD node server.js, starts a shell that may not passSIGTERMon to Node. - Don’t start through
npm startin production images. npm adds another process in between that can swallow signals.
For extra safety, run with Docker’s tiny built-in init process, which forwards signals and cleans up zombie processes:
docker run --init --rm -p 3000:3000 notes-api
Your SIGTERM handler from Graceful Shutdown and Health Checks then gets a chance to finish in-flight requests.
Step 5: Docker Compose for the API and its database
Real APIs need a database. Docker Compose runs several containers together from one YAML file, with a private network between them.
# compose.yaml
services:
api:
build: .
init: true
ports:
- '3000:3000'
environment:
PORT: 3000
MONGO_URL: mongodb://mongo:27017/notes
depends_on:
mongo:
condition: service_healthy
mongo:
image: mongo:8
volumes:
- mongo-data:/data/db
healthcheck:
test: ['CMD', 'mongosh', '--quiet', '--eval', "db.adminCommand('ping')"]
interval: 10s
timeout: 5s
retries: 5
volumes:
mongo-data:
Notice the database address: mongodb://mongo:27017. Inside Compose, each service is reachable by its service name. localhost inside the API container means the API container itself, not your laptop and not the database.
depends_onwithservice_healthywaits until MongoDB passes its health check before starting the API.- The volume
mongo-datakeeps your data when containers are recreated. init: trueis the Compose version of--init.
docker compose up --build
docker compose logs -f api
docker compose down
Warning: This Compose file is for local development. A production database needs authentication, backups and usually a managed service. Never expose MongoDB’s port to the internet without a password.
Keeping images small and safe
| Habit | Why |
|---|---|
Use node:22-alpine or node:22-slim |
Smaller images download and start faster, and contain fewer things that could have security holes |
Pin the major version (node:22-alpine, not node:latest) |
Builds don’t change under you when a new Node version comes out |
npm ci --omit=dev |
No test tools or compilers in production |
USER node |
Limits damage if the app is compromised |
| No secrets in the Dockerfile or image | Images are often shared and stored in registries |
Scan images (for example docker scout) |
Finds known vulnerabilities in OS packages and dependencies |
Check your image size with:
docker images notes-api
Common mistakes
Common mistake: Connecting to
localhost:27017from the API container. Use the Compose service name (mongo) instead.
Common mistake: Copying
node_modulesfrom your machine into the image. Packages with native code (likebcrypt) compiled on Windows or macOS won’t run on Alpine Linux. Always addnode_modulesto.dockerignore.
Common mistake: Copying
.envinto the image. Pass configuration with-e,--env-fileor your platform’s secret settings.
Common mistake:
COPY . .beforenpm ci. Every code change then reinstalls all dependencies and builds become slow.
Common mistake: Using
CMD npm start. Signals may not reach Node, so the container is killed after the timeout instead of shutting down gracefully.
Key takeaways
- A Dockerfile is the recipe, an image is the package, a container is a running copy.
- Copy
package*.jsonand runnpm ci --omit=devbefore copying code, so Docker’s layer cache keeps builds fast. - A multi-stage build keeps build tools out of the final image; use a build stage for TypeScript.
- Run as
USER node, setNODE_ENV=production, and add aHEALTHCHECK. - Use exec-form
CMD ["node", "server.js"]plus--initsoSIGTERMreaches your graceful shutdown code. - In Docker Compose, services talk to each other by service name, not
localhost.
Practice
- Write a
.dockerignoreand the production Dockerfile for your Notes API. Build it and confirmdocker psshows(healthy). - Change one line in
app.js, rebuild, and watch which steps sayCACHED. - Create a
compose.yamlthat runs your API with MongoDB, and check that notes survive adocker compose downandup(thanks to the volume). - Make the health check fail on purpose (return a
500from/health) and watch the status change to(unhealthy).
What’s next
In production your container usually sits behind a reverse proxy or load balancer, which changes what Express sees about each request. Continue with Reverse Proxies and Trusting Proxy Headers.
Previous
Observability: Metrics and OpenTelemetry Tracing
Next
Reverse Proxies and Trusting Proxy Headers
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready