Skip to content
AI Full-Stack Wiki

Tutorial: Ship a Next.js API with Docker, CI, and Health Checks

Take the secure streaming API to production: env validation at startup, a real health check, unit tests, a small non-root Docker image, and a CI smoke test.

Difficulty: Intermediate22 min readUpdated
On this page· What you will build

Why this matters

An app that only runs with npm run dev on your laptop is a demo. This tutorial turns the API from Tutorial 5 into something you can deploy and keep deploying, following DevOps basics. Along the way you will hit three problems that catch real teams: a server that starts with missing configuration, a health check that a build quietly turns into a constant, and code that cannot be tested outside its framework. Each one gets fixed and proven, and a CI pipeline runs the final container end to end on every pull request.

What you will build#

  • Startup validation: the server refuses to start, with a clear message, if a secret is missing or too weak.
  • A health check that reports the running version and uptime on every request.
  • Unit tests for the security-critical code, runnable with plain node --test.
  • A multi-stage Docker image that runs as a non-root user.
  • A CI pipeline: typecheck, tests, build, then build the image, start it, and smoke-test it.
Delivery pipeline for the secure APIA push or pull request runs typecheck, tests, and a production build. The same commit is then built into a Docker image, started with a throwaway secret, and smoke-tested end to end. Only an image that passed is deployed, behind a health check that also gates rollouts.pushsame imageDeveloperCheckstypes, tests, buildDocker imagetagged by commitSmoke testreal containerPlatformsecrets, health check

Hover or tap a component to see what it does.

Delivery pipeline: every push runs typecheck, tests, and a build; the commit is built into a Docker image, smoke-tested as a running container, and only that tested image is deployed behind a health check.
Show text description

Each stage answers one question, and the artifact that passed the last stage is the one that ships.

  • Developer: Pushes a branch and opens a pull request.
  • Checks: Fast feedback on every push: next typegen and tsc, node --test, and next build.
  • Docker image: Multi-stage build of the standalone server, running as a non-root user, about 33 MB of app files.
  • Smoke test: Starts the image, waits for /api/health, then signs in, creates a key, and streams a reply with the client script.
  • Platform: Runs the tested image with per-environment secrets; routes traffic only to instances whose health check passes, and can roll back.

You need the finished Tutorial 5 project, Docker (opens in a new tab), and a GitHub repository to push to. create-next-app already ran git init and its .gitignore excludes .env*; check with git status that .env.local is not listed before your first commit.

1. Validate configuration at startup#

A missing secret should stop the deploy, not the first user request. Parse the environment with a schema:

lib/env.ts
import { z } from "zod";
 
const Env = z.object({
  NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
  SESSION_SECRET: z.string().min(32, "must be at least 32 random characters"),
  GIT_SHA: z.string().default("dev"),
});
 
export type Env = z.infer<typeof Env>;
 
/** Validate the environment, listing every problem at once. */
export function parseEnv(source: Record<string, string | undefined>): Env {
  const parsed = Env.safeParse(source);
  if (!parsed.success) {
    const problems = parsed.error.issues.map((i) => `  ${i.path.map(String).join(".")}: ${i.message}`);
    throw new Error(`Invalid environment variables:\n${problems.join("\n")}`);
  }
  return parsed.data;
}

Where to call it matters. Not at the top of a module that routes import: next build loads those modules, and the build (in CI, in Docker) deliberately has no production secrets. The right place is instrumentation.ts, whose register function Next.js runs once when a server starts, before it accepts requests:

instrumentation.ts
// Runs once when a server instance starts, before it accepts any request.
export async function register() {
  // Next.js calls register in every runtime; the check below uses Node.js-only APIs.
  if (process.env.NEXT_RUNTIME === "nodejs") await import("./instrumentation-node");
}
instrumentation-node.ts
import { parseEnv } from "./lib/env";
 
try {
  parseEnv(process.env);
} catch (error) {
  // A half-configured server is worse than none: print every problem, then stop.
  console.error((error as Error).message);
  process.exit(1);
}

Two details come from testing this for real:

  • Throwing is not enough. If register throws, Next.js logs the error but keeps the server running, answering every request with a 500. process.exit(1) makes the process actually stop, so a container platform sees the failure and does not route traffic to it.
  • Keep Node.js-only code in a separate file. register is compiled for every runtime, including Edge, where process.exit does not exist. Importing instrumentation-node.ts only when NEXT_RUNTIME === "nodejs" keeps the build free of warnings.

Checkpoint. Build, then start the production server without its secret:

npm run build
mv .env.local .env.local.bak
npm start
Invalid environment variables:
  SESSION_SECRET: Invalid input: expected string, received undefined

The process exits with code 1. Try SESSION_SECRET=short npm start and you get SESSION_SECRET: must be at least 32 random characters. Restore the file with mv .env.local.bak .env.local before continuing.

2. A health check that tells the truth#

Platforms poll a health endpoint to decide whether a new version can receive traffic. Write the obvious version first:

app/api/health/route.ts (first try)
export async function GET() {
  return Response.json({ status: "ok", version: process.env.GIT_SHA ?? "dev", uptime: Math.round(process.uptime()) });
}

Run npm run build and read the route list:

├ ○ /api/health
○  (Static)   prerendered as static content

○ means static. With Cache Components, a GET handler that reads no request data is prerendered at build time, so this endpoint would forever return the build machine's uptime and version. It would also answer "ok" from a cache even when the server is broken. Tell Next.js the response depends on the request with connection():

app/api/health/route.ts
import { connection } from "next/server";
 
// Liveness check for the platform's health probe. Keep it cheap: no model or database calls here.
export async function GET() {
  await connection(); // answer per request; without this, the build would prerender a frozen response
  return Response.json({ status: "ok", version: process.env.GIT_SHA ?? "dev", uptime: Math.round(process.uptime()) });
}

Build again and the line reads ƒ /api/health: rendered on demand. Every API route should be ƒ; the build output is the place to check.

3. Make the security code testable#

Unit tests run in plain Node.js with node --test, without Next.js. Try importing lib/session.ts from a test and it fails:

Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../node_modules/next/headers' imported from .../lib/session.ts

The token logic is pure cryptography, but it lives in the same file as cookie handling that needs Next.js. Split them. Move the token functions into their own module:

lib/session-token.ts
import { createHmac, timingSafeEqual } from "node:crypto";
 
export const MAX_AGE = 60 * 60 * 24 * 7; // 7 days
 
function secret(): string {
  const value = process.env.SESSION_SECRET;
  if (!value || value.length < 32) throw new Error("SESSION_SECRET must be set to at least 32 random characters");
  return value;
}
 
const sign = (payload: string) => createHmac("sha256", secret()).update(payload).digest("base64url");
 
export function createSessionToken(userId: string, now = Date.now()): string {
  const payload = Buffer.from(JSON.stringify({ sub: userId, exp: Math.floor(now / 1000) + MAX_AGE })).toString("base64url");
  return `${payload}.${sign(payload)}`;
}
 
export function verifySessionToken(token: string | undefined, now = Date.now()): string | null {
  if (!token) return null;
  const [payload, signature] = token.split(".");
  if (!payload || !signature) return null;
  const expected = Buffer.from(sign(payload));
  const actual = Buffer.from(signature);
  // Constant-time comparison, so timing does not reveal how much of the signature matched.
  if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) return null;
  const { sub, exp } = JSON.parse(Buffer.from(payload, "base64url").toString()) as { sub: string; exp: number };
  return exp > now / 1000 ? sub : null;
}

And keep only the cookie glue in lib/session.ts:

lib/session.ts
import { cookies } from "next/headers";
import { createSessionToken, MAX_AGE, verifySessionToken } from "./session-token";
 
const COOKIE = "session";
 
export async function setSession(userId: string) {
  (await cookies()).set(COOKIE, createSessionToken(userId), {
    httpOnly: true, // JavaScript in the page cannot read it
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax", // not sent on cross-site POSTs, which blocks most CSRF
    path: "/",
    maxAge: MAX_AGE,
  });
}
 
export async function clearSession() {
  (await cookies()).delete(COOKIE);
}
 
export async function getSessionUserId(): Promise<string | null> {
  return verifySessionToken((await cookies()).get(COOKIE)?.value);
}

Nothing else changes: routes still import setSession and getSessionUserId from @/lib/session. This split is a pattern worth keeping everywhere: pure logic in plain modules, framework glue in thin wrappers. The pure part is where the bugs that matter live, and now it is testable in milliseconds.

4. Write the tests#

The now parameters added in Tutorial 5 pay off here: expiry and rate-limit windows are tested without waiting.

test/session.test.ts
import assert from "node:assert/strict";
import { test } from "node:test";
import { createSessionToken, verifySessionToken } from "../lib/session-token.ts";
 
process.env.SESSION_SECRET = "test-secret-that-is-at-least-32-characters-long";
 
test("a token verifies to the user it was created for", () => {
  assert.equal(verifySessionToken(createSessionToken("user_ada")), "user_ada");
});
 
test("a token with an edited payload is rejected", () => {
  const [, signature] = createSessionToken("user_ada").split(".");
  const forged = Buffer.from(JSON.stringify({ sub: "user_admin", exp: 9_999_999_999 })).toString("base64url");
  assert.equal(verifySessionToken(`${forged}.${signature}`), null);
});
 
test("a token signed with another secret is rejected", () => {
  const token = createSessionToken("user_ada");
  process.env.SESSION_SECRET = "a-different-secret-that-is-also-32-characters";
  try {
    assert.equal(verifySessionToken(token), null);
  } finally {
    process.env.SESSION_SECRET = "test-secret-that-is-at-least-32-characters-long";
  }
});
 
test("a token expires after seven days", () => {
  const issued = Date.parse("2026-10-01T00:00:00Z");
  const token = createSessionToken("user_ada", issued);
  assert.equal(verifySessionToken(token, issued + 6 * 86_400_000), "user_ada");
  assert.equal(verifySessionToken(token, issued + 8 * 86_400_000), null);
});
 
test("malformed tokens are rejected, not thrown on", () => {
  for (const token of [undefined, "", "abc", "abc.def.ghi", "."]) assert.equal(verifySessionToken(token), null);
});
test/credentials.test.ts
import assert from "node:assert/strict";
import { test } from "node:test";
import { createApiKey, verifyApiKey } from "../lib/api-keys.ts";
import { hashPassword, verifyPassword } from "../lib/passwords.ts";
 
test("a created API key verifies to its owner", () => {
  const { id, key } = createApiKey("user_ada");
  const record = verifyApiKey(key);
  assert.equal(record?.userId, "user_ada");
  assert.equal(record?.id, id);
  assert.ok(record?.lastUsedAt instanceof Date);
});
 
test("unknown and altered API keys are rejected", () => {
  const { key } = createApiKey("user_ada");
  assert.equal(verifyApiKey("sk_not-a-real-key"), null);
  assert.equal(verifyApiKey(`${key}x`), null);
});
 
test("passwords verify only with the right password", async () => {
  const hash = await hashPassword("correct-horse-battery-staple");
  assert.equal(await verifyPassword("correct-horse-battery-staple", hash), true);
  assert.equal(await verifyPassword("Correct-horse-battery-staple", hash), false);
});
 
test("the same password hashes differently each time (unique salt)", async () => {
  assert.notEqual(await hashPassword("same"), await hashPassword("same"));
});
test/limits-and-env.test.ts
import assert from "node:assert/strict";
import { test } from "node:test";
import { parseEnv } from "../lib/env.ts";
import { rateLimit } from "../lib/rate-limit.ts";
 
test("the rate limit allows `limit` calls per window, then resets", () => {
  const start = Date.parse("2026-10-01T00:00:00Z");
  const results = [0, 1, 2, 3].map((i) => rateLimit("test:a", 3, 60, start + i * 1_000).allowed);
  assert.deepEqual(results, [true, true, true, false]);
  assert.equal(rateLimit("test:a", 3, 60, start + 60_000).allowed, true); // next window
});
 
test("retryAfter counts down to the end of the window", () => {
  const start = Date.parse("2026-10-01T00:00:00Z");
  assert.equal(rateLimit("test:b", 1, 60, start + 17_000).retryAfter, 43);
});
 
test("limits are counted per key", () => {
  const now = Date.parse("2026-10-01T00:00:00Z");
  rateLimit("test:c", 1, 60, now);
  assert.equal(rateLimit("test:c", 1, 60, now).allowed, false);
  assert.equal(rateLimit("test:d", 1, 60, now).allowed, true);
});
 
test("parseEnv names every missing or invalid variable", () => {
  assert.throws(() => parseEnv({}), /SESSION_SECRET/);
  assert.throws(() => parseEnv({ SESSION_SECRET: "short" }), /at least 32/);
});
 
test("parseEnv applies defaults", () => {
  const env = parseEnv({ SESSION_SECRET: "x".repeat(32) });
  assert.deepEqual(env, { NODE_ENV: "development", SESSION_SECRET: "x".repeat(32), GIT_SHA: "dev" });
});

Add the scripts and run them:

npm pkg set scripts.test="node --test" scripts.typecheck="next typegen && tsc --noEmit"
npm test            # 14 passing
npm run typecheck

next typegen generates the route types (such as PageProps) that tsc needs, without running a full build, so type checking is fast enough to run on every push.

5. A small production image#

Tell Next.js to produce a standalone server: a minimal server.js plus only the node_modules files it actually uses.

next.config.ts
const nextConfig: NextConfig = {
  output: "standalone",
  cacheComponents: true,
  partialPrefetching: true,
};

After npm run build, .next/standalone holds about 33 MB, against roughly 386 MB for the project's full node_modules. From now on npm start warns that it does not work with standalone output; the production server is node .next/standalone/server.js, which is exactly what the image runs:

Dockerfile
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
 
FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
 
FROM node:22-alpine AS run
WORKDIR /app
ARG GIT_SHA=dev
ENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0 GIT_SHA=$GIT_SHA
RUN addgroup -S app && adduser -S app -G app
COPY --from=build --chown=app:app /app/.next/standalone ./
COPY --from=build --chown=app:app /app/.next/static ./.next/static
USER app
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s CMD wget -qO- http://127.0.0.1:3000/api/health || exit 1
CMD ["node", "server.js"]
.dockerignore
.git
.next
node_modules
.env*
npm-debug.log*

Compared with the Dockerfile in the DevOps article, three lines differ, each for a reason:

  • No COPY public. The --api template has no public/ folder, and copying a path that does not exist fails the build.
  • ARG GIT_SHA bakes the commit into the image, so /api/health tells you exactly which version is running.
  • HEALTHCHECK lets Docker itself mark a container unhealthy, using the endpoint from step 2.

.dockerignore is a security file as much as a speed one: without .env* in it, COPY . . would copy your local secrets into the build stage, and from there into the image layer cache.

Checkpoint. Build and run the image:

docker build --build-arg GIT_SHA=$(git rev-parse --short HEAD) -t secure-api .
docker run --rm -p 3000:3000 --env-file .env.local secure-api

In another terminal:

curl -s http://localhost:3000/api/health
# {"status":"ok","version":"3f9c2ab","uptime":3}
API_URL=http://localhost:3000 node scripts/client.ts
# ...signed in, created a key, and streamed a reply

Now run it without the env file: docker run --rm secure-api prints the Invalid environment variables message and the container exits immediately.

6. Continuous integration#

The workflow has two jobs. checks gives fast feedback on every push. image builds the real Docker image from the same commit, starts it with a throwaway secret, waits for the health check, and runs the client script against it: a smoke test of the artifact you are about to ship.

.github/workflows/ci.yml
name: CI
on:
  push:
    branches: [main]
  pull_request:
 
jobs:
  checks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v5
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run typecheck
      - run: npm test
      - run: npm run build
 
  image:
    needs: checks
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v5
        with:
          node-version: 22
      - name: Build the image
        run: docker build --build-arg GIT_SHA=${{ github.sha }} -t secure-api:${{ github.sha }} .
      - name: Start it with a throwaway secret
        run: |
          docker run -d --name api -p 3000:3000 -e SESSION_SECRET="$(openssl rand -base64 32)" secure-api:${{ github.sha }}
          for i in $(seq 1 30); do curl -fs http://localhost:3000/api/health && exit 0; sleep 1; done
          exit 1
      - name: Smoke test the running container
        run: node scripts/client.ts
      - name: Show container logs
        if: failure()
        run: docker logs api

Notice what the pipeline does not need: no repository secrets. The build has none by design, and the smoke test generates its own session secret, which also means pull requests from forks run the full pipeline. The smoke test needs no npm ci either: scripts/client.ts only imports lib/read-sse.ts, which has no dependencies.

Push a branch, open a pull request, and watch both jobs pass. Then break something on purpose (change the expected status in a test, or remove SESSION_SECRET from the docker run line) and confirm the pull request is blocked.

7. Deploying#

Any platform that runs a Docker image works: managed container services, a VM with Docker, or Kubernetes. Whichever you pick, configure:

  • SESSION_SECRET in the platform's secret settings, different for preview and production. Rotating it signs everyone out, which is exactly what you want after a leak.
  • The health check path /api/health, so new instances only receive traffic once they answer.
  • Streaming support: no response buffering, and request timeouts long enough for model replies.
  • Deploy by image tag (the commit SHA), never by rebuilding on the server. The image that passed the smoke test is the one that runs, and rolling back means pointing at the previous tag.
  • One instance, or a shared rate limiter. The in-memory limiter and the in-memory user and key stores from Tutorial 5 are per process. Before scaling out, move them to Redis and Postgres (Tutorial 4).

Common mistakes#

  • Validating env at import time. next build imports your routes, so the build then needs production secrets. Validate in instrumentation.ts at server start instead.
  • Throwing from register and assuming the server stops. It keeps running and returns 500s. Exit explicitly.
  • A static health check. If the build output shows ○ for a health route, it is answering from a snapshot. Use connection() and confirm ƒ.
  • No .dockerignore. COPY . . sends .env.local, .git, and node_modules into the build: slow, and a secret leak waiting to happen.
  • Only testing the code, never the artifact. Unit tests can pass while the image fails to start. Run the container in CI and hit it with a real client.

Try it

Extend the pipeline

  1. Add a release job that runs only on main, after image, and pushes the image to GitHub Container Registry tagged with the commit SHA (look up docker/login-action and the GITHUB_TOKEN permissions it needs).
  2. Add a readiness endpoint, /api/ready, separate from health, that returns 503 while the server is starting or shutting down. When would a platform use one rather than the other?
  3. Handle SIGTERM in instrumentation-node.ts: log it, and let in-flight streams finish for up to 10 seconds before exiting. Test it with docker stop.
  4. Add npm audit --audit-level=high to the checks job and decide what your team does when it fails on a transitive dependency.

Check your understanding

Why does env validation live in instrumentation.ts instead of at the top of lib/env.ts?
The build output shows ○ /api/health. What does that mean for the health check?
Why does the CI pipeline run the Docker image and a client against it, when unit tests already pass?

Key takeaways

  • Validate configuration once, at server start, in instrumentation.ts, and exit if it is wrong.
  • Read the build output: health checks and API routes must be ƒ (on demand), never ○ (prerendered).
  • Separate pure logic from framework glue so the code that matters can be unit-tested with node --test.
  • Build once with output: "standalone" into a small, non-root image, with a .dockerignore that keeps secrets out.
  • CI checks the code on every push, then builds, starts, and smoke-tests the exact image you will deploy.

Next steps#

Finished this article? Track it to see your progress on the roadmap.