Every MEAN project we take over has a deployment story, and it is usually one of three: a hand-built VM someone SSHs into, a pm2 start server.js on a box nobody remembers provisioning, or a 1.4 GB Docker image built from FROM node that takes six minutes to push. This tutorial replaces all three with a setup we are happy to hand to a client: a multi-stage Dockerfile for the Express 5 API, a second one for the Angular 22 browser bundle, a Compose file for local parity, proper health and readiness endpoints, graceful shutdown that does not drop in-flight requests, and a GitHub Actions pipeline that builds, tests and publishes both images.
Versions used here: Node.js 24 LTS, Express 5, Mongoose 8, MongoDB 8, Angular 22. The layout assumes the two-package structure from Building a Modern MEAN App in 2026:
mean-app/
api/ # Express 5 + Mongoose 8
client/ # Angular 22
compose.yaml
1. The API image: multi-stage, non-root, small
The three mistakes we see most often are building on the full node image, running as root, and copying node_modules from the host. Fix all three at once:
# api/Dockerfile
# syntax=docker/dockerfile:1
FROM node:24-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
FROM node:24-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run build --if-present && npm test --if-present
FROM node:24-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --chown=node:node . .
USER node
EXPOSE 3000
CMD ["node", "src/server.js"]
Points worth understanding rather than copying:
node:24-bookworm-slim, notnode:24. The default image carries a full build toolchain you do not need at runtime. Slim is roughly a fifth of the size. Alpine is smaller still, but it is musl-based; if you use native modules (argon2,sharp,bcrypt) you will be rebuilding them from source and occasionally chasing glibc-vs-musl bugs. On client projects we default to slim and only reach for Alpine when image size is a hard constraint.- Separate
depsstage. Dependencies are installed before the source is copied, so a code change does not invalidate thenpm cilayer. The--mount=type=cacheline keeps the npm cache between builds without baking it into the image (requires BuildKit, which is the default in Docker 23+). npm ci --omit=devfor the runtime layer. Dev dependencies belong in the build stage only.USER node. The official images ship anodeuser with UID 1000. Running as root inside a container is a real privilege-escalation step in a container-breakout chain, and most Kubernetes admission policies will reject it anyway.CMD ["node", ...], notnpm start. npm forks a shell, which swallowsSIGTERMand gives you a container that always takes the full 10-second kill timeout to stop. Runningnodeas PID 1 means your process receives signals directly, which is what the graceful shutdown below depends on.
Add an api/.dockerignore or the COPY . . will drag your local node_modules and .env into the image:
node_modules
npm-debug.log
.env
.env.*
.git
coverage
Dockerfile
.dockerignore
2. Health and readiness, and why they are two endpoints
Every orchestrator (Kubernetes, ECS, Fly, Render, Coolify) wants to ask your app two different questions: are you alive? and should I send you traffic? Answering both with one route causes an ugly failure mode: MongoDB has a transient blip, your single health check fails, and the platform restarts perfectly healthy containers instead of just routing around them.
// api/src/health.js
import { Router } from 'express';
import mongoose from 'mongoose';
export const health = Router();
// Liveness: is the event loop up? No dependencies checked.
health.get('/healthz', (req, res) => res.status(200).json({ status: 'ok' }));
// Readiness: can we actually serve requests?
health.get('/readyz', async (req, res) => {
if (req.app.locals.shuttingDown) {
return res.status(503).json({ status: 'draining' });
}
try {
await mongoose.connection.db.admin().command({ ping: 1 });
res.status(200).json({ status: 'ready' });
} catch (err) {
res.status(503).json({ status: 'db-unavailable' });
}
});
Mount it before authentication middleware (app.use(health)), and keep the payload trivial: health endpoints get hit every few seconds forever, so no database aggregations, no version lookups over the network.
3. Graceful shutdown in Express 5
When the platform deploys a new version it sends SIGTERM and then waits. If your process exits immediately, every in-flight request becomes a 502 for a real user. If it ignores the signal, you wait for SIGKILL and get the same result plus a slow deploy. The correct sequence is: flip readiness to failing, keep serving for a few seconds while the load balancer notices, stop accepting new connections, drain, close MongoDB, exit.
// api/src/server.js
import { connectDb } from './db.js';
import { app } from './app.js';
import mongoose from 'mongoose';
const PORT = Number(process.env.PORT ?? 3000);
const DRAIN_MS = Number(process.env.DRAIN_MS ?? 5000);
await connectDb();
const server = app.listen(PORT, () => console.log(`API listening on ${PORT}`));
// Node 18+: stop keep-alive sockets from holding the close open forever.
server.keepAliveTimeout = 65_000;
server.headersTimeout = 66_000;
let shuttingDown = false;
async function shutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
app.locals.shuttingDown = true; // /readyz starts returning 503
console.log(`${signal} received, draining for ${DRAIN_MS}ms`);
setTimeout(() => {
server.close(async (err) => {
if (err) console.error('server close error', err);
try {
await mongoose.connection.close(false);
} catch (e) {
console.error('mongo close error', e);
}
process.exit(err ? 1 : 0);
});
// Hard stop: Node 18.2+ closes idle sockets on close(), but a slow
// client should not hold the deploy hostage.
setTimeout(() => {
console.error('forced exit after drain timeout');
process.exit(1);
}, 10_000).unref();
server.closeIdleConnections?.();
}, DRAIN_MS).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
DRAIN_MS should be at least two failed readiness probe intervals. On Kubernetes with periodSeconds: 5 and failureThreshold: 2, five seconds is about right; set terminationGracePeriodSeconds to comfortably more than DRAIN_MS plus your longest request.
Verify it locally instead of trusting it: start the container, run a slow request (curl localhost:3000/api/slow &), docker stop the container, and confirm the request completes and the container exits well before the 10-second kill.
4. The Angular image: build once, serve static, inject config at runtime
An Angular browser build is static files. Do not ship Node to serve them.
# client/Dockerfile
# syntax=docker/dockerfile:1
FROM node:24-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run build -- --configuration production
FROM nginx:1.27-alpine AS runtime
COPY --from=build /app/dist/client/browser /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 8080
# client/nginx.conf
server {
listen 8080;
root /usr/share/nginx/html;
# Hashed assets: cache hard.
location ~* \.(js|css|woff2|png|svg|webp)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# index.html: never cache, or users get a stale shell after deploys.
location = /index.html {
add_header Cache-Control "no-cache";
}
# Angular router: fall back to the shell.
location / {
try_files $uri $uri/ /index.html;
}
}
The environment-variable trap: environment.prod.ts is baked in at build time, so a single image cannot be promoted from staging to production if the API URL differs. Two ways out. The clean one is a relative API path (/api) with the reverse proxy routing /api to the API container, which is what we recommend. If you genuinely need per-environment values, emit them at container start:
# client/docker-entrypoint.sh
cat > /usr/share/nginx/html/config.json <<EOF
{ "apiBase": "${API_BASE:-/api}" }
EOF
exec nginx -g 'daemon off;'
and load config.json in an APP_INITIALIZER-style provider before bootstrap. One image, many environments — a requirement for any promotion-based pipeline.
SSR note: if the client is server-rendered, skip nginx and containerize the SSR server like the API (node dist/client/server/server.mjs), with the same signal handling. See Server-Side Rendering a MEAN App for the app-side setup.
5. Compose for local parity
# compose.yaml
services:
mongo:
image: mongo:8.0
command: ["--replSet", "rs0", "--bind_ip_all"]
volumes: [mongo-data:/data/db]
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 5s
retries: 12
mongo-init:
image: mongo:8.0
depends_on:
mongo: { condition: service_healthy }
entrypoint: ["mongosh", "--host", "mongo", "--eval", "try { rs.status() } catch (e) { rs.initiate({_id:'rs0',members:[{_id:0,host:'mongo:27017'}]}) }"]
api:
build: ./api
environment:
MONGODB_URI: mongodb://mongo:27017/mean?replicaSet=rs0&directConnection=true
JWT_SECRET: dev-only-change-me
NODE_ENV: production
depends_on:
mongo-init: { condition: service_completed_successfully }
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/readyz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 10s
timeout: 3s
retries: 3
ports: ["3000:3000"]
client:
build: ./client
depends_on: [api]
ports: ["8080:8080"]
volumes:
mongo-data:
The single-node replica set is not ceremony: transactions and change streams only work against a replica set, so a standalone mongo container means local behaviour diverges from Atlas exactly where it hurts. The health check uses Node's built-in fetch — no need to add curl to a slim image just to probe it.
6. CI: build, test, publish
# .github/workflows/deploy.yml
name: build-and-publish
on:
push:
branches: [main]
permissions:
contents: read
packages: write
jobs:
api:
runs-on: ubuntu-latest
services:
mongo:
image: mongo:8.0
ports: ['27017:27017']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 24, cache: npm, cache-dependency-path: api/package-lock.json }
- run: npm ci
working-directory: api
- run: npm test
working-directory: api
env:
MONGODB_URI: mongodb://localhost:27017/test
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: ./api
push: true
tags: |
ghcr.io/${{ github.repository }}/api:${{ github.sha }}
ghcr.io/${{ github.repository }}/api:latest
cache-from: type=gha
cache-to: type=gha,mode=max
Duplicate the final three steps for ./client. Two habits that pay off: tag with the commit SHA, never only latest, so a rollback is a one-line image swap rather than a rebuild; and cache-from/cache-to: type=gha, which pushes BuildKit layer caching into GitHub's cache and typically cuts a cold MEAN build from four minutes to under one.
Deployment itself stays deliberately out of this file. Whether the last step is kubectl set image, an ECS task-definition update, or a webhook to Fly or Render, it should reference the SHA tag CI just published.
7. Pre-flight checklist
Before this goes anywhere near production traffic:
docker image ls— the API image should be roughly 150–250 MB. If it is over 500 MB, something in.dockerignoreis missing.docker scout cves(ortrivy image) in CI, failing on high-severity fixable findings. Rebuild weekly: the base image gets patches even when your code does not change.- No secrets in the image.
docker history --no-truncshould show noENV JWT_SECRET=. Secrets are injected at runtime, always. - Structured JSON logs to stdout — no log files inside the container, no log rotation to maintain.
- Set
--max-old-space-sizeor a container memory limit consciously. Node 24 sizes its heap from cgroup limits better than older versions, but an unbounded container plus a memory leak is still an OOM-killed pod at 3 a.m. - Readiness verified under a real deploy: roll the service while running a load generator and confirm zero 5xx responses.
Where this usually goes wrong
The pattern above is not complicated, but the failure modes are quiet. Containers restarting every few minutes because liveness probes MongoDB. Deploys that drop a handful of requests because npm start eats SIGTERM. Images that cannot be promoted between environments because the API URL was baked in at build time. Each is a half-day fix once you know it, and a week of confusing incident reports if you do not.
If you would rather have this done and verified than debugged in production, our MEAN Stack performance tuning and MEAN Stack Consulting teams containerize and pipeline existing MEAN applications as a fixed-scope engagement. Get in touch with your current deployment setup and we will tell you what we would change first.