Deployment
Edit docs/guide/21-deployment.md on GitHubA 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
# 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:
| Where | How |
|---|---|
bun run | preload in bunfig.toml, next to the entrypoint |
| A bundled build | Bun.build({ plugins: [depsPlugin] }) |
| A test | Bun.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
bun src/main.tsThat 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:
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:
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 gatewaysBoth 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
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:
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
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 thatArray.prototype.splice,shiftor a shrinkinglengthon 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.tomlmust be in the image. It is easy to miss because it is not undersrc/. If your.dockerignoreis 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.
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/transformonly at build time, but that does not make it a devDependency.examples/binarykeeps it as a dependency because the same package also runs underbun runandbun test, which use the preload. Move it to devDependencies only if the binary is all you ship. bunfig.tomlis 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/*.
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:
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.forRootbinds the publish side alone, so a web process that publishes never accidentally starts consuming. Run workers with their own entrypoint andWorkerFactory.
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.