Skip to content

Bun

Bun is the runtime for running a Plumix site as a Bun process: on a VPS, in a container, on any host that runs Bun. @plumix/runtime-bun holds the adapter over Bun.serve, plus one factory per slot it fills, and it uses what Bun ships: bun:sqlite for the database, Bun.file or S3Client for uploads and Bun.Image for resizing, so installing it compiles nothing.

The runtime is a slot like any other. runtime: bun() tells Plumix it is running as a Bun process, and the rest of the package fills the slots beside it.

Factory Slot What it wires up
bun() runtime The Bun.serve server and the entry
bunSqlite({ path }) database A SQLite file through bun:sqlite
diskStorage({ dir }) storage A directory on disk for uploads
bunS3({ bucket, credentials }) storage An S3-compatible bucket through Bun’s S3Client
images({ widths }) imageDelivery Resizing through Bun.Image, served by the process

Dev, build, migrations and the built server all run on Bun, so a Bun-specific failure shows up while you work rather than after a deploy. Nothing fills kv yet; What Bun does not do yet says what that means for a site. The cdn slot is not a runtime’s to fill — a process behind a CDN configures it exactly as a Worker does, and CDN Caching is that path.

Terminal window
bun add @plumix/runtime-bun

The package is unreleased. It is at version 0.0.0 and not on npm yet.

Bun 1.4 or newer. On 1.3 the site does not start cleanly: the scheduler’s UTC time zone is rejected, and a stopping server does not wait for in-flight requests.

Install with Bun, not pnpm or npm. bun --bun cannot execute the shell-script shims pnpm writes into node_modules/.bin, which is why the scaffolder refuses --runtime bun beside a --pm naming another manager.

This is the path from a scaffolded site to a running process.

  1. Scaffold with the Bun runtime. The project installs with Bun and writes a bun.lock.

    Terminal window
    bun create plumix-app my-site --runtime bun
    cd my-site
  2. Read the config the scaffolder wrote. The runtime, the database and a passkey origin, plus diskStorage and images() when a plugin such as media needs them. The origin is a literal, because a process has no build environment to derive one from.

    import { plumix } from "plumix";
    import { auth } from "plumix/auth";
    import { media } from "@plumix/plugin-media";
    import { bun, bunSqlite, diskStorage, images } from "@plumix/runtime-bun";
    import { theme } from "./theme";
    export default plumix({
    runtime: bun(),
    database: bunSqlite({ path: "data/site.sqlite" }),
    storage: diskStorage({ dir: "data/media" }),
    imageDelivery: images(),
    auth: auth({
    passkey: {
    rpName: "my-site",
    // Passkeys are bound to the origin: change both to the host you deploy on.
    rpId: "localhost",
    origin: "http://localhost:3000",
    },
    }),
    plugins: [media()],
    theme,
    });
  3. Develop. Every command runs the plumix CLI with bun --bun, which runs it on Bun rather than on the Node its bin names. Before it serves, plumix dev applies every pending migration to the file bunSqlite names: core and every plugin that owns tables ship their own. One Vite server, with the site’s code evaluated on Bun behind Vite’s own middlewares. An edit to the config, the theme or a plugin is served on the next request, without a restart. The server answers loopback hosts only; set PLUMIX_DEV_ALLOW_REMOTE=1 to review from another device.

    Terminal window
    bun run dev
  4. Build. The build writes dist/client for the browser and dist/server/worker.js to run. Everything the server needs is inlined into that one file, except bun: modules and native packages. Every table owner’s migration history lands beside it, in dist/server/migrations/, and the plugins’ and the site’s data migrations land there as a bundle of their own, dist/server/migrations/data-migrations.js, which the server imports only when it starts.

    Terminal window
    bun run build
  5. Run. The built server runs on plain bun; --bun is only for the CLI. It applies every pending migration in dist/server/migrations/ to its database, then the pending data migrations in dist/server/migrations/data-migrations.js, before it listens, and exits 1 without listening if one of either kind fails.

    Terminal window
    PORT=3000 bun dist/server/worker.js
  6. Point the passkey at the deployed host. Before the first sign-in on a real host, set rpId to that host and origin to its https:// address, rebuild, and restart.

bun run dev, bun run build and bun run migrate are the scaffold’s scripts for bun --bun plumix dev, build and migrate; call either form. Run under Node instead, a Bun-runtime command stops with bun_required and names bun --bun as the fix.

The process listens on PORT and HOST, defaulting to 3000 and 0.0.0.0, and prints the address it bound once. Everything else is an option to bun():

import { bun } from "@plumix/runtime-bun";
export default {
runtime: bun({
trustProxy: true,
idleTimeout: 60,
bodySizeLimit: 100 * 1024 * 1024,
}),
};
  • trustProxy, off by default. Off, the server takes the scheme from its own socket, the host from Host, and the visitor’s address from the connection, so a visitor reaching the process directly cannot forge any of them. Behind a TLS-terminating reverse proxy that is wrong: every request looks like plain http from the proxy’s address, so the session cookie is not Secure, the passkey origin does not match, and sessions record the proxy. Turn it on there, and x-forwarded-proto, x-forwarded-host and the rightmost x-forwarded-for entry are believed. The flag says whether the hop in front of the process is yours; there is nothing more granular.
  • idleTimeout, in seconds, 30 by default. It covers a connection that is waiting on a response, not only one between requests, and Bun’s own default of 10 would drop a slow render before its first byte. 0 disables it. Bun cannot go above 255, so a larger value makes bun() throw when the config loads rather than when the first slow request is cut. An event stream (text/event-stream) is never cut by it, whatever the value.
  • bodySizeLimit, in bytes, 1 GiB by default. Bun enforces it and answers 413 before the site runs, so an oversized upload never reaches memory.

.env in the project root is the one env file, in dev and in production, and it follows the rule Secrets sets for every runtime: .env fills the keys the environment has not set, and the environment always wins. bun run dev applies it and picks up edits. The built server loads it from the directory it starts in, before the site is built, and a missing file is not an error.

Bun loads env files by itself too, and its own loading also reads .env.local and .env.{NODE_ENV}. The scaffold’s bunfig.toml turns that off, so the runtime reads .env alone, the same file Node reads:

env = false

Keep that file beside package.json wherever a Bun command runs, in a container image included. Without it a stray .env.production would feed the process keys that no other runtime would see.

Run directly, the entry serves dist/client from disk ahead of the site, with hashed assets under /assets/ and /_plumix/admin/assets/ marked immutable, and the image route after that; everything else goes to Plumix. There is exactly one listener: Bun serves a module’s default export by itself when it has a fetch, so the entry default-exports the Server it started instead of the site. Imported rather than run, it starts nothing and its default export is the portable { fetch, scheduled } handler, to mount in a Bun.serve of your own.

A rolling deploy that sends SIGTERM and waits ten seconds loses nothing that can finish in that window: the process stops accepting, lets in-flight responses finish, stops the scheduler (aborting a running task’s ctx.signal, so a task that checks it stops between batches) and drains deferred work such as telemetry delivery, then exits 0. Whatever is still running at the ten-second deadline is cut, and the process exits 1 saying what it abandoned. A second signal exits at once.

A built server writes each line Plumix logs through its logger (ctx.logger, a request or scheduled run that fails, deferred work that rejects or is abandoned, the cron runner) to stdout as one JSON object: level, message, time and whatever else the call carried, with an error as its name, message, stack and cause, a cause that is itself an error written the same way. Some warnings still go straight to the console as plain text: the server’s own start and shutdown lines (listening on, draining), and a few from core, such as an unregistered block. plumix dev prints its usual readable output instead.

The process keeps nothing itself, so whatever runs it keeps the logs. Under systemd, journald stores stdout, and journalctl -u my-site -o cat prints it back. A container platform forwards stdout to its log drain, and a process manager writes it to a file it rotates. plumix doctor reports where the logs go.

bunSqlite({ path }) opens the file with WAL journaling, a five-second busy timeout, synchronous = NORMAL and foreign keys on. Bun’s own defaults differ on all four, so the slot sets each one on connect and again in plumix migrate, which is what makes the file behave as it does on Node. Missing parent directories are created, and a relative path resolves against the project root in the server and in plumix migrate alike, so both open the same file whichever directory you run them from. The server applies the migrations its build shipped to that file when it starts.

The file moves between Bun and Node: the server, plumix dev and plumix migrate record each owner’s applied migrations in the same __drizzle_migrations_<owner> tables on both, so a site can switch between the two runtimes and keep its database. It does not move to Cloudflare: D1 is a database of its own, so a site moving there runs plumix migrate --production against a new D1 database.

For a remote or shared database, plumix/db/libsql stays selectable on Bun: point it at Turso or a libsql server and leave bunSqlite out. The server applies migrations to a bunSqlite file only, so a libsql database takes its migrations from its own tooling.

Two factories fill the storage slot, and swapping one for the other is one config line.

diskStorage({ dir }) keeps uploads under the directory, each object a file under dir/objects/ with its content type and metadata in a JSON file under dir/meta/. The media plugin serves them through its own route; there is no public URL and no presigned upload, so the site receives the bytes itself. It is the scaffold’s default and a supported production setup on a single machine, as long as the directory is on a disk that outlives a redeploy — VPS and containers has the shape.

bunS3({ bucket, endpoint?, region?, credentials, publicUrlBase? }) keeps them in any S3-compatible bucket through Bun’s S3Client, which is what several processes sharing uploads need. endpoint defaults to AWS S3 in region, and region to us-east-1. For R2 point endpoint at the account’s R2 host with region: "auto"; for MinIO, at the MinIO server.

import { bunS3 } from "@plumix/runtime-bun";
export default {
storage: bunS3({
bucket: "media",
endpoint: "https://<account>.r2.cloudflarestorage.com",
region: "auto",
credentials: (env) => ({
accessKeyId: env.S3_ACCESS_KEY_ID,
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
}),
}),
};

credentials is the key pair, or an (env) => credentials resolver as above when it is a secret, and it is handed to S3Client explicitly, so Bun’s own S3_* and AWS_* variables never pick the account. One gap remains in Bun 1.4.2: when credentials carries no sessionToken, the client still adds one from S3_SESSION_TOKEN or AWS_SESSION_TOKEN, so leave those unset unless they belong to this key. Presigned uploads sign the content type, so the browser uploads straight to the bucket and cannot send a different type than the one approved. The bucket is laid out exactly as s3() from plumix/storage/s3 and R2 lay it out, so a site can move between them without moving objects.

publicUrlBase is the CDN or custom domain in front of the bucket, such as an R2 custom domain or a MinIO bucket behind a public host. Set it and an object’s URL is <publicUrlBase>/<key>, with the key encoded as s3() and R2 encode it, so browsers fetch media from there and no byte passes through the Bun process. Leave it unset and the URL is null: the media plugin serves each object through its own route, which checks the entry’s status on every request. A public URL is not re-checked, so set it only for a bucket whose objects may be read by anyone holding the key. With images(), a public URL is a remote source, so add the host to remotePatterns.

import { bunS3 } from "@plumix/runtime-bun";
export default {
storage: bunS3({
bucket: "media",
endpoint: "https://<account>.r2.cloudflarestorage.com",
region: "auto",
credentials: (env) => ({
accessKeyId: env.S3_ACCESS_KEY_ID,
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
}),
publicUrlBase: "https://media.example.com",
}),
};

images() fills imageDelivery with the process itself, through Bun.Image, so nothing native is installed. url() points at /_plumix/image, which the server answers ahead of the site. A same-origin source, which is what a disk-stored upload has, is read through the site itself as an anonymous GET, so what the media plugin gates stays gated. A remote source must match remotePatterns, and every redirect it takes is checked again, ten at most. Anything else gets a 400. The server refuses a source over 32 MiB with 413, one that does not answer within fifteen seconds with 502, and bytes Bun.Image cannot decode with 415. The width snaps up to the next entry in widths (320 to 1920 by default) and quality is clamped.

import { images } from "@plumix/runtime-bun";
export default {
imageDelivery: images({
widths: [320, 640, 1280, 1920],
remotePatterns: [{ hostname: "images.example.com" }],
}),
};

The format follows Accept, among the formats the host can encode, which the process probes once on connect. Linux has no AVIF: Bun.Image encodes AVIF only through the system codecs on macOS and Windows, so a browser asking for AVIF from a Linux server, which is every container, gets WebP. Bun.Image has no crop either, so fit: "cover" scales the image to cover the box and leaves the overflow to the page’s object-fit. An animated GIF comes out as its first frame.

A variant is rendered once and kept under cacheDir (.cache/plumix/images) under a hash of the request, with an immutable cache header and an ETag that turns a revalidation into a 304. When a media item is trashed or deleted, the media plugin asks the slot to forget its variants. The cache has no size cap, so give it room or clear the directory; losing it costs a cold cache, not data.

The process fires the site’s scheduled tasks itself, in UTC, at the same minutes as on Node and Cloudflare. Bun’s own scheduler defaults to local time and reads some day fields differently, so core’s scheduler drives the tasks rather than Bun.cron, and a 0 3 * * * job runs at 03:00 UTC whatever the host’s time zone. Nothing to configure: plumix build wires it, and the process starts it once it is listening.

Each firing takes the scheduled-run lease in the site’s database, so processes sharing one database fire each minute once. On SIGTERM the scheduler stops before deferred work drains, and its timer never keeps the process alive.

plumix build produces a directory and a file, not an image; the scaffold ships no Dockerfile. This is the shape one takes, because the pieces above only add up to a working deploy when they are put together in the right order.

A Plumix Bun deploy is not stateless. Three things on this page write to disk, and on a container every one of them is gone on the next redeploy unless a volume survives it:

  • bunSqlite({ path }) — the database, data/site.sqlite in the scaffold
  • diskStorage({ dir }) — uploads, data/media
  • images() — the resized-variant cache, .cache/plumix/images by default

Mount a volume over data/ and the database and uploads survive a redeploy. The image cache is different: losing it costs a cold cache, not data — worth mounting too if the first view of every page after a deploy shouldn’t pay for a fresh render, but not required the way data/ is.

The built server applies its pending migrations to the database on the volume each time it starts, the histories first and then dist/server/migrations/data-migrations.js, before it listens, so the image needs no migration step and no entrypoint script. A start against an up-to-date database applies nothing.

Two stages on the official oven/bun image: one that installs every dependency and builds, one that installs only production dependencies and runs. The build stage’s source and dev dependencies never reach the image that ships, and neither does the config: the server bundle carries it, and dist/server/migrations/ carries every history it applies and the data-migrations.js it runs after them. As Dockerfile:

FROM oven/bun:1.4.2 AS build
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun run build
FROM oven/bun:1.4.2-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package.json bun.lock bunfig.toml ./
RUN bun install --frozen-lockfile --production
COPY --from=build /app/dist ./dist
VOLUME /app/data
EXPOSE 3000
CMD ["bun", "dist/server/worker.js"]

COPY package.json bun.lock ./ before COPY . . means an edit to source alone reuses the installed layer. bunfig.toml goes into the runtime stage because the server is started there, and without it Bun’s own env loading comes back. List what the build context must not carry in .dockerignore — .env above all, because the built server would load it, and data/, because the volume owns it:

node_modules
dist
data
.cache
.plumix
.env

Build it, then run it with the volume and the secrets from the environment:

Terminal window
docker build -t my-site .
docker run -p 3000:3000 -v my-site-data:/app/data --env-file .env my-site

PORT and HOST come from the environment; turn on trustProxy when an ingress or load balancer terminates TLS in front of the process. Pass secrets the same way — through the environment at container start, not a build-time ARG or ENV, which persists in the image’s layer history for anyone who can pull it.

Give the container more than the ten seconds the drain needs before it gives up: a stop timeout of 10 races the drain’s own deadline, so 30 leaves room for the SIGTERM to reach the process and the last response to leave before the platform sends SIGKILL.

The same three paths live on the host’s disk, under the project directory, so nothing extra is needed for them to survive a redeploy: deploy new code over the old, keep data/. Install Bun on the host, copy the project with its bunfig.toml, and run bun install --frozen-lockfile --production and bun run build there, or copy a dist/ built elsewhere.

Run the process under a supervisor rather than a shell, so it restarts after a crash or a reboot and receives SIGTERM on a stop. With systemd, as /etc/systemd/system/my-site.service:

[Unit]
Description=my-site
After=network-online.target
[Service]
User=my-site
WorkingDirectory=/srv/my-site
Environment=PORT=3000
ExecStart=/usr/local/bin/bun dist/server/worker.js
Restart=on-failure
TimeoutStopSec=30
[Install]
WantedBy=multi-user.target

Use the path which bun prints. The server reads .env from WorkingDirectory, so a .env kept there, readable only by the service’s user, is where production secrets can live on a VPS; the environment systemd sets still wins over it.

It is single-node by default. bunSqlite’s file, diskStorage’s directory and the image cache all belong to one machine, and a second process on another machine sees none of them. Scaling out means moving each off the disk: the database to plumix/db/libsql, uploads to bunS3. The scheduler follows the database: replicas sharing a libsql database fire each minute once, while replicas each holding their own bunSqlite file each fire every task, so keep those to one. There is no switch to turn the in-process scheduler off on Bun.

There is no kv, so a plugin requiring that capability cannot be scaffolded on Bun; the scaffolder says so by name.

Public pages render on every request until the site sits behind a CDN with the cdn slot declared, exactly as on Node. CDN Caching covers it.

plumix dev applies pending migrations, then runs the site in Vite on Bun, and accepts --port and --host. plumix build builds the client first and the server second, because the server bakes the client’s asset manifest into its bundle, copies every table owner’s history into dist/server/migrations/, and bundles the plugins’ and the site’s data migrations into dist/server/migrations/data-migrations.js, which the server runs after the histories. plumix migrate applies every owner’s migrations, then its data migrations, to the file bunSqlite names, through bun:sqlite; it takes no target option. There is no deploy and no types: the deployment artefact is a directory and a command. Each runs as bun --bun plumix <command>.

Node.js is the other process runtime, with the same deploy shape on Node. Secrets covers the .env rule and the (env) => value resolver form, which reads the process environment here. CDN Caching is what to read next if this process will sit behind a CDN. Runtime Adapters is the contract this package implements.

Go to Secrets for what a secret-bearing slot looks like, then Configuration for every slot the factories above plug into.