AdvancedTypeScript · Lesson 9 of 10

Production: Build, Configure, Observe & Deploy

Compile for production, log properly, shut down gracefully and ship with Docker.

For production, compile TypeScript to JavaScript with tsc and run the output with plain node — faster startup and no dev tooling on the server. Turn on strict compiler checks such as noUncheckedIndexedAccess to catch more bugs.

Configure the app through environment variables (validated at startup, as in the Zod lesson), never hard-coded secrets. Log structured JSON so log platforms can search and alert on it.

Handle SIGTERM: stop accepting new connections, let in-flight requests finish, close database pools, then exit. Hosting platforms and Kubernetes send SIGTERM on every deploy. Package the app in a small multi-stage Docker image that runs as a non-root user.

tsconfig.jsonJSON
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "sourceMap": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}
src/main.tsTypeScript
import { createServer } from "node:http";

const PORT = Number(process.env.PORT ?? 3000);

function log(level: "info" | "error", msg: string, extra: Record<string, unknown> = {}): void {
  console.log(JSON.stringify({ time: new Date().toISOString(), level, msg, ...extra }));
}

const server = createServer((req, res) => {
  const started = performance.now();
  res.on("finish", () => {
    log("info", "request", {
      method: req.method,
      url: req.url,
      status: res.statusCode,
      ms: Math.round(performance.now() - started),
    });
  });

  if (req.url === "/health") {
    res.writeHead(200, { "Content-Type": "application/json" });
    return res.end(JSON.stringify({ status: "ok" }));
  }
  res.writeHead(404).end();
});

server.listen(PORT, () => log("info", "server started", { port: PORT }));

function shutdown(signal: string): void {
  log("info", "shutting down", { signal });
  server.close((err) => {
    if (err) log("error", "close failed", { err: String(err) });
    process.exit(err ? 1 : 0);
  });
  setTimeout(() => process.exit(1), 10_000).unref(); // force-exit if stuck
}

process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));
DockerfileDockerfile
FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npx tsc && npm prune --omit=dev

FROM node:22-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/main.js"]

Key points

  • Compile with tsc, run plain node dist/main.js in production.
  • Validate config at startup; log structured JSON; expose a /health endpoint.
  • Handle SIGTERM for zero-downtime deploys; run containers as non-root.

Exercise

Take your task API from the earlier lessons, add a /health endpoint, structured logging and graceful shutdown, build it with the Dockerfile above, run it with docker run -p 3000:3000, and confirm docker stop triggers a clean shutdown in the logs.

Show solution

Try the exercise yourself first — then compare your approach with this one.

Combine the task routes with the production pieces from the lesson: a /health endpoint, one JSON log line per request, and a SIGTERM handler that stops accepting connections and exits once in-flight requests finish. Compile with tsc, run with plain node, and docker stop sends the SIGTERM you can watch in the logs.

src/main.tsTypeScript
import { createServer } from "node:http";

const PORT = Number(process.env.PORT ?? 3000);
const tasks = [{ id: 1, title: "Ship it", done: false }];

function log(level: "info" | "error", msg: string, extra: Record<string, unknown> = {}): void {
  console.log(JSON.stringify({ time: new Date().toISOString(), level, msg, ...extra }));
}

const server = createServer((req, res) => {
  const started = performance.now();
  res.on("finish", () =>
    log("info", "request", { method: req.method, url: req.url, status: res.statusCode, ms: Math.round(performance.now() - started) }),
  );

  res.setHeader("Content-Type", "application/json");
  if (req.url === "/health") return res.end(JSON.stringify({ status: "ok", uptime: Math.round(process.uptime()) }));
  if (req.method === "GET" && req.url === "/tasks") return res.end(JSON.stringify(tasks));
  res.writeHead(404).end(JSON.stringify({ error: "Not found" }));
});

server.listen(PORT, () => log("info", "server started", { port: PORT }));

function shutdown(signal: string): void {
  log("info", "shutting down", { signal });
  server.close(() => {
    log("info", "closed cleanly");
    process.exit(0);
  });
  setTimeout(() => process.exit(1), 10_000).unref();
}

process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));
TerminalShell
docker build -t task-api .
docker run --name task-api -p 3000:3000 task-api
curl localhost:3000/health          # {"status":"ok","uptime":3}
docker stop task-api                # logs: "shutting down" (SIGTERM) then "closed cleanly"

Check your understanding

  1. Why run compiled JavaScript (node dist/main.js) in production instead of tsx?

  2. What should a server do when it receives SIGTERM?

  3. Why log structured JSON instead of plain text?

  4. Which is a good container practice from this lesson?

Ask AI