Deploying Express.js to Production in 2027

Front
Back
Right
Left
Top
Bottom
DEPLOY

Deploying Express.js to Production in 2027

"You don't need to be perfect on day one. But your infrastructure does."  
a lesson I learned the hard way the first time I shipped an Express app with no health checks and got paged at 3 AM.

Deploying Express is easy. Deploying it *well* — with graceful shutdowns, observability, and zero-downtime releases — is a different game. Let me walk you through exactly how I’d deploy an Express API to production in 2027.
DOCKERIZE
Do It Right

Dockerizing Your Express App

The common mistake

Most tutorials give you a Dockerfile like this:
Copy to clipboard
FROM node:22
WORKDIR /app
COPY . .
RUN npm install
CMD node src/index.js
This ships dev dependencies to production, runs as root, and doesn’t handle signals properly. Multi-stage builds that cut image sizes by 70% and running as non-root are the standard practice now.

The production-grade Dockerfile

Copy to clipboard
# ---- 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:
If you use shell form (`CMD node src/index.js`), a shell process becomes PID 1 and Node.js becomes a child process. The shell doesn’t forward SIGTERM to its children — so your Node.js process never receives the signal and gets SIGKILL’d immediately. Always use exec form.
SHUTDOWN
Your App Deserves a Clean Exit

Graceful Shutdown

When Docker or Kubernetes stops a container, it sends SIGTERM to PID 1. Your application has a grace period (default 30 seconds) to finish in-flight requests and shut down cleanly. After that, SIGKILL is sent — the process is terminated immediately.

Without proper shutdown handling: active HTTP requests are severed, database connections remain open (ghost connections), and transactions may be left in limbo.
Copy to clipboard
// 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
You should initialize OpenTelemetry first (before anything else), stop accepting new connections, flush the OpenTelemetry SDK to send pending spans, then disconnect your database — in that order.
HEALTH CHECKS
Let Your Platform Know You're Alive

Health Checks

Kubernetes and load balancers need to know if your app is truly ready to serve traffic. The Express documentation on Health Checks and Graceful Shutdown describes two key endpoints:
Copy to clipboard
// 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.');
});
In your `docker-compose.yml` or Kubernetes deployment spec, wire these up as `livenessProbe` and `readinessProbe`.
STRATEGY
Fly.io vs Railway vs AWS ECS

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:
For a new SaaS or startup API in 2027, I start on Railway for speed, then migrate to Fly.io when global latency matters. AWS ECS is the right call when you’re already deep in the AWS ecosystem or have compliance requirements (SOC2, HIPAA).
Fly.io deploy (after installing `flyctl`)
Copy to clipboard
flyctl launch     # auto-detects Dockerfile, creates fly.toml
flyctl secrets set MONGO_URI=mongodb+srv://...
flyctl deploy
OBSERVE
You Can't Fix What You Can't See

Observability

The industry has moved to OpenTelemetry (OTel) as the standard for distributed tracing. It automatically instruments Express routes, MongoDB queries, Redis calls, and HTTP clients with a single setup. Initialize telemetry as the very first line of your entry point — before importing Express or anything else:
Copy to clipboard
// 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
Set `OTEL_SERVICE_NAME=my-express-api` and `OTEL_EXPORTER_OTLP_ENDPOINT` pointing to your Jaeger or Grafana Tempo collector, and you get full request traces with zero manual instrumentation.

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.

How to Configure SDK Shutdown Procedures in Node.js with SIGTERM, 2026

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!
Front
Back
Right
Left
Top
Bottom
FAQ's

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