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"]- Tests in the build stage — that is where the transformer check runs against real output.
USER node. Running as root in a container means a code-execution bug is a container-escape starting point.--productionso dev dependencies, including the transformer, are absent from the runtime image.- Do not
COPY .into the final stage. It pulls in.env,.gitand your source. Copydistonly, and keep a.dockerignore.
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/apiOn 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:
x-forwarded-foris the client IP.req.socket.remoteAddressis the proxy. Trust the header only from a proxy you control, or an attacker sets it.x-forwarded-prototells you whether the original request was HTTPS.- Set a body limit at the proxy _and_ in your adapter — the framework has none, so a large body is a memory-exhaustion vector. See Raw Body.
- Set security headers at the proxy:
strict-transport-security,x-content-type-options: nosniff,x-frame-options: DENY. A handler can set them per response viajson(value, { headers }),
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 artefact | see above |
|---|---|
NODE_ENV=production | |
| Secrets from a secret store, not the image | |
max pool size × replicas ≤ database connection limit | Connection Pooling |
TLS to the database, rejectUnauthorized not disabled | |
| Migrations run as a separate step | Migrations |
Readiness fails on SIGTERM, with a drain delay | Health Checks |
| Body size capped at the proxy and in the adapter | Raw Body |
| Logs structured; no parameters, tokens or bodies logged | Logging |
/metrics and admin routes not publicly reachable | Multiple Servers |
| Source maps enabled | Hot Reload |
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