An app that only runs on your laptop helps nobody. Deployment is the process of getting your code onto servers where users can reach it, reliably, securely and repeatably. A good deployment setup means anyone on the team can release a change with confidence, a bad release can be rolled back in minutes, and the app keeps running when individual servers restart or fail.
Node.js apps can be deployed in many ways. Platforms as a service such as Render, Railway, Fly.io and Heroku take your code and run it with little setup. Containers built with Docker run the same way on any cloud, from a single virtual machine to Kubernetes. Serverless platforms such as AWS Lambda, Vercel and Netlify Functions run your code on demand without servers to manage.
Whichever you choose, the same practices apply. Configuration comes from environment variables, dependencies are installed exactly from the lock file, the app reports its health, it shuts down gracefully when asked to stop, logs go to standard output, and a CI/CD pipeline tests and deploys every change automatically. In this lesson you'll prepare an app for production, write a Dockerfile, set up health checks and graceful shutdown, and automate deployment with GitHub Actions.
Preparing the app
- Read
PORTand every other setting from environment variables, as covered in the process and env lesson. - Set
NODE_ENV=production. Express and many libraries then enable caching and hide detailed error pages. - Add a
startscript,"start": "node src/server.js", and specify the Node version with"engines": { "node": ">=22" }. - Install with
npm ci --omit=devso production gets exactly the locked versions without test tools. - Split
app.js(builds the Express app) fromserver.js(callslisten), so tests and tools can use the app without starting a server. - Log to stdout in JSON; the platform collects it.
Health checks and graceful shutdown
Hosting platforms and load balancers need to know whether an instance is working:
- A liveness endpoint such as
GET /healthanswers 200 if the process is running. - A readiness endpoint such as
GET /readyalso checks dependencies like the database, and answers 503 while the app can't serve traffic.
When a platform replaces or scales down an instance, it sends SIGTERM. The app should stop accepting new requests, finish the ones in progress, close database connections and then exit. Without this, users see failed requests during every deployment.
Docker and CI/CD
A Dockerfile describes an image containing Node, your dependencies and your code. Production images should use a slim, specific Node version, install with npm ci --omit=dev, run as the non-root node user and use a .dockerignore to leave out node_modules, .env and .git.
A CI/CD pipeline runs on every push: install, lint, test, build the image, and deploy if everything passes. Database migrations run as a step before the new version starts. Deploying the same image to staging and then production means what you tested is exactly what runs.
Node.js Advanced Syntax
Production essentials
const port = Number(process.env.PORT ?? 3000);
app.get('/health', (req, res) => res.json({ status: 'ok' }));
app.get('/ready', async (req, res) => (await db.ping()) ? res.sendStatus(200) : res.sendStatus(503));
const server = app.listen(port);
process.on('SIGTERM', () => server.close(() => db.end().then(() => process.exit(0))));NODE_ENV=production npm ci --omit=dev
docker build -t shop-api .
docker run -p 3000:3000 --env-file .env shop-apiNode.js Advanced Summary
- Read PORT and settings from the environment; set NODE_ENV=production; install with npm ci.
- Provide liveness and readiness endpoints so platforms know when to send traffic.
- Handle SIGTERM: stop accepting requests, finish current ones, close connections, exit.
- Build small, non-root Docker images with a .dockerignore and pinned Node version.
- Automate testing and deployment with CI/CD, run migrations, log to stdout and monitor.
Node.js Advanced Examples
Each example shows the code, the output it produces and what each part does.
Health checks and graceful shutdown
import express from 'express';
const app = express();
let ready = true;
let inFlight = 0;
app.use((req, res, next) => {
inFlight++;
res.on('finish', () => inFlight--);
next();
});
app.get('/health', (req, res) => res.json({ status: 'ok', uptime: process.uptime() > 0 }));
app.get('/ready', (req, res) => (ready ? res.json({ ready }) : res.status(503).json({ ready })));
app.get('/slow-report', async (req, res) => {
await new Promise((r) => setTimeout(r, 200));
res.json({ report: 'done' });
});
const server = app.listen(0, async () => {
const base = `http://localhost:${server.address().port}`;
console.log(await (await fetch(`${base}/health`)).text());
const pending = fetch(`${base}/slow-report`).then((r) => r.json());
await new Promise((r) => setTimeout(r, 50));
process.emit('SIGTERM');
console.log((await fetch(`${base}/ready`)).status);
console.log('In-flight request finished:', JSON.stringify(await pending));
});
process.on('SIGTERM', () => {
console.log(`SIGTERM: ${inFlight} request(s) in progress, draining`);
ready = false;
setTimeout(() => {
server.close(() => console.log('Server closed cleanly'));
server.closeIdleConnections();
}, 300);
});{"status":"ok","uptime":true}
SIGTERM: 1 request(s) in progress, draining
503
In-flight request finished: {"report":"done"}
Server closed cleanlyLine-by-line explanation
- Lines 7 to 11Middleware counts requests that are still being processed.
- Line 12The liveness check answers as long as the process is running.
- Line 13The readiness check reports 503 once shutdown starts, so the load balancer stops sending traffic.
- Line 25The demo sends itself SIGTERM while a slow request is running, as a platform would during a deployment.
- Line 28The slow request still completes, so no user sees an error during the release.
- Lines 31 to 38On SIGTERM the app marks itself not ready, waits briefly, then closes the server.
A production Dockerfile
# Dockerfile
FROM node:22-slim
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --chown=node:node src ./src
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", "src/server.js"]$ docker build -t shop-api .
$ docker run -d -p 3000:3000 -e DATABASE_URL=postgres://db/shop shop-api
$ curl localhost:3000/health
{"status":"ok","uptime":true}Line-by-line explanation
- Line 2A specific, slim Node image keeps builds repeatable and the image small.
- Lines 5 to 6Copying the package files first lets Docker cache the installed dependencies between builds.
- Line 7Only the source folder is copied, owned by the unprivileged node user.
- Line 8The app runs as a non-root user, limiting damage if it's ever compromised.
- Line 10Docker checks the health endpoint and marks the container unhealthy if it fails.
- Line 11The exec form of CMD lets Node receive SIGTERM directly for graceful shutdown.
A CI/CD pipeline with GitHub Actions
# .github/workflows/deploy.yml
name: test-and-deploy
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run lint
- run: npm test
- run: npm audit --audit-level=high
- run: curl -fsS -X POST "${{ secrets.DEPLOY_HOOK_URL }}"test-and-deploy: build
npm ci done
npm run lint done
npm test tests 48, pass 48, fail 0
npm audit found 0 high severity vulnerabilities
deploy hook deployment startedLine-by-line explanation
- Lines 3 to 5The workflow runs on every push to the main branch.
- Lines 11 to 14The exact Node version is installed, and npm's cache speeds up repeated runs.
- Lines 15 to 18Install, lint, test and audit; if any step fails, the pipeline stops and nothing is deployed.
- Line 19The deploy step calls the hosting platform with a secret stored in the repository settings, never in the code.
Common Node.js Advanced Mistakes
Hard-coding the port
app.listen(3000); // the platform assigns a different port, so the app is unreachableapp.listen(Number(process.env.PORT ?? 3000));Copying node_modules and .env into the image
COPY . . # includes local node_modules, .env secrets and .git# .dockerignore
node_modules
.env
.gitIgnoring SIGTERM
// no shutdown handling: requests in progress fail during every deploymentprocess.on('SIGTERM', () => server.close(() => process.exit(0)));Node.js Advanced in Real Projects
Deployment in practice
Small teams often deploy Node apps to Render, Railway or Fly.io straight from GitHub, with the platform building, health-checking and rolling out each release. Larger organisations build Docker images in CI, push them to a registry and deploy to Kubernetes, ECS or Cloud Run, with staging environments, feature flags and gradual rollouts. Monitoring with logs, metrics and alerts closes the loop, so problems are spotted and rolled back quickly.
Node.js Advanced FAQ
Should a Dockerfile use npm start or node to run the app?
Prefer CMD ["node", "src/server.js"]. Running through npm start adds an extra process between Docker and your app, and depending on the npm version and setup, the SIGTERM sent on shutdown may not reach Node, so graceful shutdown never happens. Calling node directly in the exec form means your app receives the signal itself.
Do I need PM2 if I deploy with Docker or a hosting platform?
Usually not. Docker, Kubernetes and platforms such as Render or Fly.io already restart crashed processes and run several instances for you. PM2 is most useful on a plain virtual server, where it keeps the app running, restarts it after crashes or reboots, and can run one process per CPU core.
Do I need Nginx in front of a Node.js app?
Not on most platforms, which provide their own load balancer and HTTPS. On your own server, a reverse proxy such as Nginx or Caddy is still common: it handles TLS certificates, serves static files efficiently, buffers slow clients and lets several apps share ports 80 and 443. Remember to set Express's trust proxy so req.ip and req.protocol are correct behind it.
Which Node.js version should I use in production?
Use the current Active LTS release, or a Maintenance LTS one that's still supported, never an odd-numbered Current release. Pin the major version everywhere it matters, such as FROM node:22-slim in the Dockerfile, "engines" in package.json and your CI setup, and plan an upgrade before your version reaches end of life.
How do I deploy a Node.js app without downtime?
Start new instances alongside the old ones, wait until their readiness checks pass, move traffic to them, then send SIGTERM to the old instances so they finish in-flight requests and exit. Most platforms do this rolling deployment automatically if your app has health checks and handles shutdown properly. Database changes should be backward compatible so old and new versions can run side by side for a few minutes.
Node.js Advanced Exercises
Answer each one and check it instantly. If you get stuck, open the hint before the answer.
Complete the line so the port comes from the environment.
app.listen(Number(process.env. ?? 3000));
Which command installs exactly the versions in the lock file without dev dependencies?
What should a readiness endpoint return while the app is shutting down?
Why should a container run as a non-root user?
Why copy package.json and the lock file before the rest of the source in a Dockerfile?
Node.js Advanced Quiz
5 questions to check what you learned.
Which signal do platforms send to stop a Node process gracefully?
- SIGKILL
- SIGHUP
- SIGTERM
- SIGSTOP
What does NODE_ENV=production change in Express?
- Nothing
- It disables routing
- It turns on debug logging
- It enables caching and less detailed error pages
Where should a production Node app write its logs?
- Standard output, collected by the platform
- A file inside the container
- The database
- Nowhere
What does a CI/CD pipeline usually do before deploying?
- Nothing
- Installs dependencies, lints, runs tests and checks for vulnerabilities
- Deletes the database
- Asks every developer for approval by email
Why split an Express app into app.js and server.js?
- Express requires two files
- It makes responses faster
- To use two ports
- Tests and tools can import the app without starting a real server