Deployment

Edit docs/guide/21-deployment.md on GitHub

A dunx app is a Bun process that calls Bun.serve. There is no adapter to pick and no build step that rewrites your code. Nothing has to run before the process starts except one preload line.

The one thing that must survive to production

TOML
# bunfig.toml
preload = ["@dunx/transform/preload"]

@dunx/transform records each class's constructor parameter types when the file loads. Without it the container has no way to know what a constructor wants, and boot fails with an error naming the class rather than constructing it with undefined arguments. A deployment that loses bunfig.toml fails immediately and loudly rather than at the first request.

Three ways to supply it, all equivalent:

WhereHow
bun runpreload in bunfig.toml, next to the entrypoint
A bundled buildBun.build({ plugins: [depsPlugin] })
A testBun.plugin(depsPlugin) from a preload file

node_modules is skipped by the plugin: a published package was already transformed by its own build, and re-parsing dependencies on every load is pure cost.

Running it

SH
bun src/main.ts

That is the whole command. HttpFactory.create builds the container, listen() hands the route table to Bun.serve, and the server holds the process open. There is no cluster module to configure, see the note on horizontal scaling below.

HTTP/2

http2: true serves HTTP/2 on the same port as HTTP/1.1, through the same routes and the same 404 fallback:

TS
await HttpFactory.create(AppModule, { http2: true });

Without TLS that is h2c: a client opening with the HTTP/2 preface gets HTTP/2, and everything else gets HTTP/1.1. That is what a reverse proxy in front of the app speaks, which is the deployment this is for. Bun marks the option experimental.

What it is worth, at 64 requests in flight either way: 2.9x the requests per second on a small JSON body, 2.1x on a 4 KiB POST, 1.3x on a 64 KiB one.

The saving is per-request framing, so it shrinks as the body grows. Turn it on for an API serving small JSON; expect little from it if you mostly serve large payloads. Server CPU per request falls by the same ratios, and the method behind that is in docs/bun-apis.md.

Gateways keep working, because a websocket upgrade is an HTTP/1.1 request and both protocols share the socket. There is no websocket over HTTP/2.

http1: false, and gateways

http1: false refuses HTTP/1.x requests with a 505. A websocket upgrade is an HTTP/1.1 request, so a port with http1: false can serve HTTP/2 routes or gateways, but not both. If you set it while a gateway is declared and no second port is configured, boot fails and the error names the gateway paths. Otherwise the app would start and never accept a websocket.

gatewayPort is the way to have both. The routes keep port and the upgrades move to a second Bun.serve that takes no protocol overrides:

TS
const app = await HttpFactory.create(AppModule, {
  http2: true,
  http1: false,
  gatewayPort: 3001,
});

await app.listen(3000); // HTTP/2 only, the controllers
app.gatewayUrl; // http://localhost:3001, HTTP/1.1, the gateways

Both ports share one container, so gateways and controllers inject the same singletons. A second HttpFactory.create would build a second container with its own instances.

gatewayPort works without http1: false too, for a deployment that wants the socket port behind a different firewall rule. An HTTP request to it answers 404.

Bun's own fetch cannot call an h2c origin: protocol: 'http2' rejects with HTTP2Unsupported against any cleartext peer, whatever that peer serves. Leave protocol unset for a plain-HTTP upstream.

Shutting down cleanly

TS
const app = await HttpFactory.create(AppModule);
app.enableShutdownHooks();

await app.listen(3000);
await app.closed;

enableShutdownHooks() installs the signal handlers. On SIGTERM or SIGINT the graph is torn down in reverse construction order, so a repository is disposed before the database connection it holds. app.closed resolves once that finishes, and anything implementing OnShutdown participates.

Kubernetes sends SIGTERM, waits terminationGracePeriodSeconds, then sends SIGKILL. A process that ignores SIGTERM loses whatever was in flight.

It then ends the process. Draining differs from exiting: one handle that outlives teardown leaves a drained, idle process alive until SIGKILL, and that handle is often not yours.

A Redis client is the common case. A Bun.RedisClient whose TCP connect never completed stays open after close(), and bullmq's Bun adapter cannot cancel its own reconnect once the connection has dropped. An app that used an unreachable broker used to finish draining and then hang.

Both are upstream bugs that app code cannot work around; see bullmq over Redis.

Once the drain finishes, dunx gives the process a moment to end on its own and exits it if it has not. The timer is unref()d, so it cannot itself hold the runtime open: a process with nothing pending exits immediately, and the pause is only ever spent on a process that would otherwise have hung. A forced exit always logs a line, since it means something leaked.

You no longer need a short terminationGracePeriodSeconds to work around this.

Pass { exitAfterMs: false } to opt out. Pass it in tests that fire a signal at their own process, too, or the forced exit lands in the middle of your test run:

TS
app.enableShutdownHooks(['SIGTERM'], { exitAfterMs: false });

Configuration

Bun reads .env and .env.local itself, so there is no loader to configure and no dotenv to install. In a container you will normally set real environment variables instead and ship no .env at all.

Validation happens once, at boot, through the single function you gave ConfigModule.forRoot. A missing or malformed variable fails the process before it serves anything, so a bad config becomes a failed rollout rather than a running service returning 500s. See Configuration.

Behind a TLS-terminating proxy, turn on securityHeaders for HSTS and the rest of the browser-facing headers, and csrf with trustProxy if the proxy rewrites Host; see Security.

Container image

DOCKER
FROM oven/bun:1.4.3-alpine
WORKDIR /app

# Dependencies first, so a source change does not reinstall them.
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile --production

COPY . .
ENV NODE_ENV=production
EXPOSE 3000
CMD ["bun", "src/main.ts"]

Three details worth getting right:

  • Pin the patch, not 1.4-alpine. 1.4.2 fixes a crash on musl that Array.prototype.splice, shift or a shrinking length on an array of objects could hit while the collector was marking.
  • --frozen-lockfile, so a deploy can never silently resolve a different version than the one that was tested.
  • bunfig.toml must be in the image. It is easy to miss because it is not under src/. If your .dockerignore is written as an allowlist, add it explicitly.

@dunx/transform is needed at runtime under bun run, because the transform happens on load. It is not a devDependency. If you bundle ahead of time with Bun.build and the plugin, it becomes build-time only and can be dropped from the image.

Single executable

Bun.build with compile writes the runtime and the app into one file, which runs on a host with nothing installed.

TS
import { depsPlugin } from '@dunx/transform';

const compiled = await Bun.build({
  entrypoints: ['src/main.ts'],
  target: 'bun',
  compile: { outfile: 'dist/pulse' },
  plugins: [depsPlugin],
  minify: true,
  sourcemap: 'none',
});

Pass plugins: [depsPlugin] in the same call as compile. dunx needs each class's constructor dependencies written into the code. Under bun run, the preload does this as each file loads. A compiled binary has no preload, so the plugin does it at build time. Without it, every class with constructor parameters fails at boot with the preload message.

Two consequences:

  • The binary needs @dunx/transform only at build time, but that does not make it a devDependency. examples/binary keeps it as a dependency because the same package also runs under bun run and bun test, which use the preload. Move it to devDependencies only if the binary is all you ship.
  • bunfig.toml is not read by the binary. Anything the preload line was doing has to happen in the build instead.

sourcemap: 'none' because debug symbols add tens of megabytes to a file nobody reads a stack trace off.

examples/binary is a CLI built this way, and its test compiles the binary and asserts that an injected dependency survived the compile.

Health checks

HealthModule from @dunx/http serves /health/live and /health/ready, under whatever global prefix the app sets, so app.setGlobalPrefix('api') moves them to /api/health/*.

TS
HealthModule.forRootAsync({
  useFactory: (db: DbConnection) => ({
    readiness: [new DatabaseIndicator(db)],
    drainDelayMs: 15_000,
  }),
  inject: [DbConnection],
});

Use HealthModule instead of your own controller, because it handles draining at shutdown. Set drainDelayMs to at least your ingress's deregistration interval. Mark critical: true only on checks the process cannot work without, which in most services is just the database. Health checks covers both, the drain mechanics, and writing your own indicator.

Logging

Request logging is on by default and writes one structured line per request, carrying the request and the response together: stdout, one JSON object per line, collected by whatever runs the container.

ConsoleLogger batches info and below into a single write per event-loop turn. warn, error and fatal are never buffered and flush everything queued ahead of them, so ordering holds and the entries you go looking for after a crash were never held back.

A buffered info line can be lost if the process dies without unwinding, under a SIGKILL or an OOM kill. If you need every line, construct the logger with buffering off:

TS
provide(Logger, {
  useFactory: (context: RequestContext) =>
    new ConsoleLogger(context, 'info', false),
  inject: [RequestContext],
});

For redaction, rotation and file transports, bind LoggerModule from @dunx/infra/logger. See Logging. For OpenTelemetry spans, see Tracing.

Horizontal scaling

Nothing in dunx holds cross-request state, so running N processes behind a load balancer works with no changes. Two things need attention when you do:

  • WebSocket fan-out is per process. socket.subscribe(topic) joins a topic in the Bun runtime of that process only, so a publish reaches the clients connected to that instance. Attach the Redis relay to fan out across nodes, see WebSockets.
  • Queue workers are separate processes. QueueModule.forRoot binds the publish side alone, so a web process that publishes never accidentally starts consuming. Run workers with their own entrypoint and WorkerFactory.

Cold start

Roughly twice raw Bun.serve, from the oxc-parser preload and eager dependency resolution. It beats every Node framework measured here by a wide margin, but if you are deploying somewhere that pays cold start per request, it is the number to watch. The current figures are on the benchmarks page, regenerated from a real run rather than written down.

Eager resolution is a trade: every provider is constructed at boot, so a missing binding or a bad config is a failed start rather than a 500 on the first request that happens to need it.