A production API needs automated tests, containerized deployment, and monitoring. In this lesson we'll cover all three.
Testing with Vitest and Supertest
Vitest is a fast test runner that works seamlessly with TypeScript. Supertest lets you make HTTP requests to your Express app without starting a server.
npm install -D vitest supertest @types/supertestAdd a test script to package.json:
// package.json (partial)
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
}
}Exporting the App
Make sure your Express app is exported so tests can import it without starting the server:
// src/index.ts
import express from "express";
import usersRouter from "./routes/users.js";
import { errorHandler } from "./middleware/error-handler.js";
export const app = express();
app.use(express.json());
app.use("/api/users", usersRouter);
app.use(errorHandler);
// Only start the server when running directly, not when imported in tests
if (process.env.NODE_ENV !== "test") {
app.listen(3000, () => console.log("Server running on port 3000"));
}Writing Tests
// src/routes/__tests__/users.test.ts
import { describe, it, expect } from "vitest";
import request from "supertest";
import { app } from "../../index.js";
describe("GET /api/users", () => {
it("returns a list of users", async () => {
const res = await request(app).get("/api/users");
expect(res.status).toBe(200);
expect(res.body.data).toBeInstanceOf(Array);
});
});
describe("POST /api/users", () => {
it("creates a new user with valid data", async () => {
const newUser = { name: "Charlie", email: "charlie@example.com" };
const res = await request(app)
.post("/api/users")
.send(newUser)
.set("Content-Type", "application/json");
expect(res.status).toBe(201);
expect(res.body.data).toMatchObject({
name: "Charlie",
email: "charlie@example.com",
});
expect(res.body.data.id).toBeDefined();
});
it("returns 400 when required fields are missing", async () => {
const res = await request(app)
.post("/api/users")
.send({ name: "Charlie" });
expect(res.status).toBe(400);
expect(res.body.error).toBeDefined();
});
});
describe("GET /api/users/:id", () => {
it("returns 404 for non-existent user", async () => {
const res = await request(app).get("/api/users/99999");
expect(res.status).toBe(404);
expect(res.body.error).toBeDefined();
});
});
describe("DELETE /api/users/:id", () => {
it("deletes an existing user", async () => {
// Create a user first
const createRes = await request(app)
.post("/api/users")
.send({ name: "ToDelete", email: "delete@example.com" });
const userId = createRes.body.data.id;
const res = await request(app).delete(`/api/users/${userId}`);
expect(res.status).toBe(204);
});
});Testing Authentication
// src/routes/__tests__/auth.test.ts
import { describe, it, expect } from "vitest";
import request from "supertest";
import { app } from "../../index.js";
describe("Protected routes", () => {
it("returns 401 without a token", async () => {
const res = await request(app).get("/api/users");
expect(res.status).toBe(401);
});
it("returns 401 with an invalid token", async () => {
const res = await request(app)
.get("/api/users")
.set("Authorization", "Bearer invalid-token");
expect(res.status).toBe(401);
});
it("succeeds with a valid token", async () => {
// Log in to get a token
const loginRes = await request(app)
.post("/api/auth/login")
.send({ email: "alice@example.com", password: "Password1" });
const { token } = loginRes.body.data;
const res = await request(app)
.get("/api/users")
.set("Authorization", `Bearer ${token}`);
expect(res.status).toBe(200);
});
});Docker Containerization
Package your API into a Docker container for consistent deployments.
Dockerfile
# Dockerfile
FROM node:22-slim AS base
WORKDIR /app
# Install dependencies
FROM base AS deps
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# Build TypeScript
FROM base AS build
COPY package.json package-lock.json tsconfig.json ./
RUN npm ci
COPY src ./src
RUN npm run build
# Production image
FROM base AS production
ENV NODE_ENV=production
# Copy only what's needed
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
# Run as non-root user
RUN addgroup --system app && adduser --system --ingroup app app
USER app
EXPOSE 3000
CMD ["node", "dist/index.js"]
Docker Compose
# docker-compose.yml
services:
api:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- JWT_SECRET=${JWT_SECRET}
- DATABASE_URL=${DATABASE_URL}
depends_on:
- db
restart: unless-stopped
db:
image: postgres:16
environment:
POSTGRES_DB: myapi
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
volumes:
pgdata:Build and run:
docker compose up --build -dCI/CD with GitHub Actions
Automate testing and deployment on every push:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
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 build
- run: npm test
deploy:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build and push Docker image
run: |
docker build -t myapi:${{ github.sha }} .
# Push to your container registryHealth Check Endpoint
Every production API needs a health check for load balancers and monitoring:
// src/routes/health.ts
import { Router } from "express";
const router = Router();
router.get("/health", (_req, res) => {
res.json({
status: "healthy",
uptime: process.uptime(),
timestamp: new Date().toISOString(),
version: process.env.npm_package_version || "unknown",
});
});
// Deep health check — verifies database connectivity
router.get("/health/ready", async (_req, res) => {
try {
// Check database connection
// await db.query("SELECT 1");
res.json({ status: "ready" });
} catch {
res.status(503).json({ status: "not ready" });
}
});
export default router;Graceful Shutdown
Handle termination signals properly so in-flight requests complete:
// src/index.ts
const server = app.listen(3000, () => {
console.log("Server running on port 3000");
});
function shutdown(signal: string) {
console.log(`${signal} received. Shutting down gracefully...`);
server.close(() => {
console.log("HTTP server closed");
// Close database connections, flush logs, etc.
process.exit(0);
});
// Force shutdown after 10 seconds
setTimeout(() => {
console.error("Forced shutdown");
process.exit(1);
}, 10_000);
}
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));Production Monitoring
Structured Logging with Pino
npm install pino pino-httpimport pino from "pino";
import pinoHttp from "pino-http";
const logger = pino({
level: process.env.LOG_LEVEL || "info",
transport:
process.env.NODE_ENV === "development"
? { target: "pino-pretty" }
: undefined,
});
app.use(
pinoHttp({
logger,
serializers: {
req: (req) => ({
method: req.method,
url: req.url,
id: req.id,
}),
res: (res) => ({
statusCode: res.statusCode,
}),
},
})
);Production Checklist
Before deploying, make sure you have:
- Validation on all inputs (body, query, params)
- Authentication on protected routes
- Rate limiting to prevent abuse
- CORS configured for your frontend domains
- Helmet for security headers
- Health checks for load balancers
- Graceful shutdown for zero-downtime deploys
- Structured logging for debugging in production
- Error handling that never leaks stack traces to clients
- Environment variables for all secrets and config