Deploying Express.js to Production in 2027
"You don't need to be perfect on day one. But your infrastructure does." Â
Dockerizing Your Express App
The common mistake
FROM node:22
WORKDIR /app
COPY . .
RUN npm install
CMD node src/index.js
The production-grade Dockerfile
# ---- Stage 1: Build ----
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
# ---- Stage 2: Production ----
FROM node:22-alpine AS runner
WORKDIR /app
# Don't run as root
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
COPY --from=builder /app/node_modules ./node_modules
COPY . .
EXPOSE 3000
# Use exec form — Node.js receives signals directly
CMD ["node", "src/index.js"]
Why exec form (`CMD ["node", ...]`) matters:
Graceful Shutdown
// server.js
import express from 'express';
import mongoose from 'mongoose';
const app = express();
app.use(express.json());
// ... your routes
const server = app.listen(3000, () => {
console.log('Server running on port 3000');
});
// Graceful shutdown
async function shutdown(signal) {
console.log(`${signal} received. Starting graceful shutdown...`);
server.close(async () => {
console.log('HTTP server closed. All connections drained.');
await mongoose.connection.close();
console.log('Database connection closed.');
process.exit(0);
});
// Force exit if cleanup takes too long
setTimeout(() => {
console.error('Shutdown timed out. Forcing exit.');
process.exit(1);
}, 10_000);
}
process.on('SIGTERM', () => shutdown('SIGTERM')); // Docker/Kubernetes
process.on('SIGINT', () => shutdown('SIGINT')); // Ctrl+C
Health Checks
// Liveness: "Is the process alive?"
app.get('/health/live', (req, res) => {
res.status(200).json({ status: 'ok', uptime: process.uptime() });
});
// Readiness: "Is the app ready to serve traffic?"
let isReady = false;
app.get('/health/ready', (req, res) => {
if (!isReady) {
return res.status(503).json({ status: 'not ready' });
}
res.status(200).json({ status: 'ready' });
});
// Mark as ready after DB connects
mongoose.connect(process.env.MONGO_URI).then(() => {
isReady = true;
console.log('Database connected. App is ready.');
});
Deploying
| Fly.io | Railway | AWS ECS | |
|---|---|---|---|
| Best for | Global edge, latency-sensitive APIs | Fast side projects, MVPs | Enterprise, existing AWS infra |
| Setup complexity | Low | Very low | High |
| Cost (small app) | ~$3–5/mo | ~$5/mo | ~$20+/mo |
| Scaling | Regions + autoscale | Autoscale | Full control (ECS + ALB) |
| Secrets | fly secrets set |
Dashboard UI | AWS Secrets Manager |
My take:
Fly.io deploy (after installing `flyctl`)
flyctl launch # auto-detects Dockerfile, creates fly.toml
flyctl secrets set MONGO_URI=mongodb+srv://...
flyctl deploy
Observability
// tracing.js — must be required FIRST
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc';
const sdk = new NodeSDK({
traceExporter: new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
}),
instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();
export { sdk };
```
```js
// index.js
import { sdk } from './tracing.js'; // FIRST
import express from 'express';
// ... rest of your app
Explore project snapshots or discuss custom web solutions.
Proper signal handling in Node.js is essential for Kubernetes deployments. Without it, the last few seconds of telemetry data from each pod restart is lost, creating blind spots in exactly the moments you need visibility the most.
Thank You for Spending Your Valuable Time
I truly appreciate you taking the time to read blog. Your valuable time means a lot to me, and I hope you found the content insightful and engaging!
Frequently Asked Questions
Docker Compose is great for local development. In production, use Kubernetes (or the managed equivalents on Fly.io/Railway). Compose lacks health-check-driven rolling updates and auto-restarts under load.
Never bake secrets into your Docker image. Use environment variables injected at runtime: `fly secrets set`, Railway's dashboard, or AWS Secrets Manager. Anything committed to the image is a security incident waiting to happen.
Liveness = is the process still running? Readiness = is it ready to *accept traffic*? Your readiness probe should return `503` until your database connection is established. This prevents Kubernetes from routing requests to an uninitialized app.
You might not need Jaeger. But at minimum, structured logging (with `pino` or `winston`) and a `/health` endpoint are non-negotiable in production. Tracing becomes essential once you have 3+ services talking to each other.
On Fly.io and Railway, rolling deploys are built-in. On ECS, use a blue-green or rolling update strategy. The critical prerequisites are: graceful shutdown (as above), readiness probes that accurately reflect app readiness, and a load balancer that respects the probe status.
Generally yes for production (smaller attack surface, smaller image). Watch out for native Node addons that depend on glibc — Alpine uses musl libc. If you hit issues, switch to `node:22-slim` (Debian-slim) as a balanced alternative.
Comments are closed