zmdbzero-maintenance data layer
Docs Benchmarks Anti-patterns OpenAPI
Docs / Operations and deployment

DeploymentSupported

An application is createApp plus an adapter, so deployment is ordinary Node deployment. This page is the checklist, in the order things go wrong.

Build#

{
  "scripts": {
    "build": "tsup",
    "start": "node dist/main.js"
  }
}

Build in CI, deploy the artefact. Building on the target host means the transformer's behaviour depends on the host's toolchain, and a host that builds differently from CI is how validation ends up disabled in production only.

Verify the transformer ran in the artefact you are shipping:

it('the transformer is running', () => {
  expect(is<{ id: number }>({ id: 'x' })).toBe(false);
});

Run this against the built output, not the source. AOT validation fails open — if the transformer is skipped, is<T>() returns true for invalid input and nothing errors. That is the single most consequential deployment mistake available with this stack.

A container image#

FROM node:26-slim AS build
WORKDIR /app
COPY package.json yarn.lock .yarnrc.yml ./
RUN corepack enable && yarn install --immutable
COPY . .
RUN yarn build && yarn vitest run

FROM node:26-slim
WORKDIR /app
ENV NODE_ENV=production
COPY package.json yarn.lock .yarnrc.yml ./
RUN corepack enable && yarn workspaces focus --production --all
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/main.js"]

Configuration#

Read environment variables once, validate at startup, and fail loudly:

export const env = assert<{ DATABASE_URL: string; PORT: string; JWT_SECRET: string }>(process.env);

A missing variable now crashes at boot instead of producing undefined in a connection string at 3am. See Configuration.

Never bake secrets into the image or commit a .env. Use the platform's secret store, and rotate anything that has ever been in a repository — including in history.

Graceful shutdown#

const server = createServer(async (req, res) => {
  const out = await app.handle(await webRequest(req));
  res.writeHead(out.status, { ...out.headers }).end(await bodyText(out));
});
server.listen(Number(env.PORT));

let ready = true;
process.once('SIGTERM', async () => {
  ready = false; // readiness now fails
  await new Promise(r => setTimeout(r, 5_000)); // let the LB stop routing
  server.close();
  await app[Symbol.asyncDispose]();
  await pool.end();
});

webRequest(req) is the Node conversion from Request Lifecycle. This compact adapter buffers streamed responses; for streaming, register an explicit Router and pass that router to toNodeHandler.

The order matters and the sleep is the part people omit. Closing the server first drops requests the load balancer has already sent, which shows up as a burst of 502s on every deploy. See Health Checks.

Migrations#

Run them as a separate step, never on application boot:

# deploy pipeline
- run: node dist/migrate.js up
- run: kubectl rollout restart deployment/api

On boot with several replicas, every replica races the same migration. Some dialects will deadlock; some will half-apply.

Make migrations backward-compatible so old and new code can run together during a rollout: add a column before writing to it, stop reading a column before dropping it. A single deploy that adds a NOT NULL column without a default fails every request from the old replicas. See Migrations.

Behind a proxy#

Terminate TLS at the proxy. Then:

but a proxy applies them to every response including errors, which is what you want here.

The pre-flight checklist#

Transformer canary test passes against the built artefactsee above
NODE_ENV=production
Secrets from a secret store, not the image
max pool size × replicas ≤ database connection limitConnection Pooling
TLS to the database, rejectUnauthorized not disabled
Migrations run as a separate stepMigrations
Readiness fails on SIGTERM, with a drain delayHealth Checks
Body size capped at the proxy and in the adapterRaw Body
Logs structured; no parameters, tokens or bodies loggedLogging
/metrics and admin routes not publicly reachableMultiple Servers
Source maps enabledHot Reload
⚠️ Warning

Never set ssl: { rejectUnauthorized: false } to make a connection work. It disables certificate verification entirely, which makes the connection interceptable — and the fix is to supply the provider's CA certificate, which every managed database publishes.

---

See also: Deployment · Serverless · Health Checks