Appearance
Deployment
A Znight application builds to a plain ESM bundle. There is no runtime compiler, no reflection and no decorator code left: the output runs anywhere Node.js 24+ or Bun 1.3+ runs.
Build
sh
vite build # or: znight buildWith the default app options, the Vite plugin produces a server build:
| Setting | Value |
|---|---|
| Entry | src/main.ts |
| Output | dist/main.js |
| Target | node24 |
| Source maps | on (dist/main.js.map) |
| Provider source locations | omitted (sourceLocations: 'dev') |
Like any Vite server build, dependencies from node_modules stay external by default: install production dependencies next to dist/ at runtime. To ship a single self-contained file (handy for serverless), bundle them:
ts
export default defineConfig({
plugins: [znight()],
ssr: { noExternal: true }, // bundle node_modules too (native addons must stay external)
})The build fails on any Znight error, so a broken dependency graph never reaches an artifact.
Run
sh
NODE_ENV=production node --enable-source-maps dist/main.jssh
NODE_ENV=production bun --no-env-file dist/main.jsNODE_ENV=productiondisables development-only checks (such as the interceptor sync/async check).--enable-source-mapsmakes Node.js stack traces point to your TypeScript sources.--no-env-filestops Bun from loading.env,.env.productionand.env.localintoprocess.envby itself (with its own precedence and${VAR}expansion): with@znight/config,dotenv()loads them, the same way as on Node.js.env = falseinbunfig.tomldoes the same for everybuncommand of the project.- Bun can also run the sources directly (
bun src/main.ts) if you generate the container first (znight generateor the Bun preload); the build output is still the most predictable artifact.
Docker
A multi-stage image that builds with the full toolchain and ships only the bundle and production dependencies:
dockerfile
FROM node:24-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev
FROM node:24-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/package.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "--enable-source-maps", "dist/main.js"]dockerfile
FROM oven/bun:1.3 AS build
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun run build && rm -rf node_modules && bun install --frozen-lockfile --production
FROM oven/bun:1.3-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/package.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER bun
EXPOSE 3000
CMD ["bun", "dist/main.js"]Use the exec form of CMD (a JSON array) so the runtime is PID 1 and receives SIGTERM directly. With the shell form, the signal goes to /bin/sh and your application is killed without a graceful shutdown.
Graceful shutdown in orchestrators
On SIGTERM, Znight stops ingress first (the HTTP server stops accepting connections and drains in-flight requests, consumers stop pulling and finish their messages), then stops your providers, then closes infrastructure. See Lifecycle.
Align the timeouts with your platform:
ts
const app = await Application.create({
metadata: __ZNIGHT_METADATA__,
plugins,
shutdown: { timeoutMs: 25_000, hookTimeoutMs: 10_000 },
})| Platform | Grace period before SIGKILL | Suggested timeoutMs |
|---|---|---|
| Kubernetes | terminationGracePeriodSeconds (30 s by default) | 25 s |
Docker (docker stop) | 10 s by default (--time) | 8 s |
| AWS ECS | stopTimeout (30 s by default) | 25 s |
On Kubernetes, endpoints are removed asynchronously: a short preStop sleep (a few seconds) before SIGTERM avoids sending new requests to a pod that is already draining.
When a signal-triggered shutdown fails, the process exits with code 1; a second signal forces exit code 130.
Event streams and scheduled jobs never finish by themselves, so they are stopped actively: HttpPlugin closes the open Server-Sent Events streams first (their sources get sse.shutdownGraceMs, 1 s, to send final events, then the clients receive a jittered retry and reconnect to another instance), and SchedulePlugin aborts the ctx.signal of the running jobs and waits for them up to its drainTimeoutMs. Keep sse.shutdownGraceMs < drainTimeoutMs (4 s) < shutdown.hookTimeoutMs (5 s), and list SchedulePlugin before HttpPlugin: plugins stop in reverse order, so HTTP stops taking requests before the jobs drain.
Event streams behind a proxy
Server-Sent Events go through proxies without special configuration: the plugin flushes the headers at once, sends x-accel-buffering: no (nginx) and cache-control: no-cache, no-transform (caches and compression), and a heartbeat comment every 8 s, below the idle timeouts of Bun.serve() (10 s), nginx and AWS load balancers (60 s) and Cloudflare (100 s). Browsers open at most 6 HTTP/1.1 connections per origin: serve HTTP/2 at the edge when pages open several streams.
Configuration and secrets
Configuration classes from @znight/config are validated when the application boots: every missing or invalid value is reported at once (ZNIGHT006) and the process fails before accepting traffic, which is exactly what a rolling deployment needs to stop a bad release.
Serverless and cold starts
Cold starts are where compile-time DI pays off most. At startup, a Znight application:
- imports the bundle (no
reflect-metadatapolyfill, no decorator side effects); - reads a precomputed resolution order (no graph discovery, no sorting of user code);
- calls generated factories, starting independent async singletons concurrently.
Make the most of it:
- Create the application once, outside the handler, and reuse it across invocations.
- Instantiate only what the function needs with
init.only. - Prune providers unreachable from your entry points with the compiler's
pruneoption. - Bundle dependencies (
ssr.noExternal: true) to avoid resolvingnode_modulesat startup. - Disable signal handlers (
shutdown.signals: false) where the platform manages the process.
A queue-triggered function (AWS Lambda with SQS) using a single service:
ts
import { Application } from '@znight/core'
import type { SQSEvent } from 'aws-lambda'
import { __ZNIGHT_METADATA__ } from './.znight/container.generated.js'
import { OrderService } from './orders/order.service.js'
const app = await Application.create({
metadata: __ZNIGHT_METADATA__,
init: { only: [OrderService] }, // build OrderService and its dependencies only
shutdown: { signals: false },
})
await app.start()
const orders = app.get(OrderService)
export async function handler(event: SQSEvent): Promise<void> {
for (const record of event.Records) await orders.process(JSON.parse(record.body))
}ts
export default defineConfig({
plugins: [znight({ app: { entry: 'src/lambda.ts' }, prune: true })],
ssr: { noExternal: true },
})For HTTP functions and edge runtimes, use the Hono adapter: adapter.fetch is a Fetch handler ((request: Request) => Promise<Response>) running the whole Znight pipeline. Routes are registered when the application is created (onBootstrap), so the handler works without app.start(), which would listen on a port:
ts
import { Application } from '@znight/core'
import { HttpPlugin } from '@znight/http'
import { honoAdapter } from '@znight/http/hono'
import { __ZNIGHT_METADATA__ } from './.znight/container.generated.js'
const adapter = honoAdapter()
await Application.create({
metadata: __ZNIGHT_METADATA__,
plugins: [HttpPlugin.configure({ adapter })],
shutdown: { signals: false },
})
export default { fetch: adapter.fetch }Without app.start(), the onStart / onReady hooks of providers and plugins do not run: keep the work they would do in factories (or lazy) for such deployments.
Checklist
vite buildpasses (noZNIGHTerrors); runznight generate --checkin CI iftscruns before the build.NODE_ENV=productionis set.- The runtime is PID 1 or otherwise receives signals (exec form
CMD). shutdown.timeoutMsis below the platform's grace period.- Configuration is validated at boot (
@znight/config); secrets come from the environment or a secret store. - Logs, metrics and traces are wired with
@znight/observability. - With several instances, database migrations run once per release, not at every boot (Data access).