Skip to content

VHosts

Maps hostnames to apps. Supports exact match and wildcard (*.example.com). base: "" (does not mount under a path) and runs in the server.fetch hook — before the plugin pipeline. Disabled by default.

@buntime/plugin-vhosts resolves which worker app handles a request based on the Host header. Instead of serving the app at the default /{app}/ path, it injects x-base: / so that the worker and the runtime’s wrapper.ts serve the app at the root (/). For wildcards (*.example.com), the captured subdomain is forwarded to the worker via the x-vhost-tenant header, enabling multi-tenant isolation without per-tenant DNS changes.

Unlike most plugins (which use onRequest), vhosts operates in the server.fetch hook — the lowest level of the Bun server, before any pipeline middleware. When a match is found, the request is forwarded directly to the worker pool and the response returns without passing through plugin-gateway, plugin-authn, or plugin-proxy.

Request (Host: tenant1.sked.ly)
server.fetch (vhosts)
1. hostname = url.hostname
2. matchVirtualHost (exact → wildcard)
3. check pathPrefix (if any)
4. resolve workerDir (getWorkerDir)
5. inject headers: x-base: / | x-vhost-tenant: tenant1
6. pool.fetch(appDir, config, req)
├── match ──> Worker serves app at root
└── 404 ──> Normal pipeline (gateway, authn, ...)

Because server.fetch returns 404 when there is no match, the Buntime runtime treats this as a signal to continue with the normal pipeline — apps matched by vhost bypass all plugin middleware. See Plugin System for the complete hook cycle.

AspectValue
enabled (default)false
base (mount path)"" (does not mount under a path — the plugin exposes no HTTP routes)
pluginEntrydist/plugin.js
Primary hookserver.fetch (intercepts before the pipeline)
Secondary hookonInit (resolves pool, getWorkerDir, loadWorkerConfig)
REST APINone — no routes, no UI, no client
Runtime configurationVia manifest.yaml only (no hot-reload of hosts)
Access to ctxLimited: ctx.pool, ctx.globalConfig.workerDirs, ctx.logger

All configuration lives in plugins/plugin-vhosts/manifest.yaml. There are no env overrides, no runtime API, and no LibSQL persistence.

name: "@buntime/plugin-vhosts"
base: ""
enabled: true
pluginEntry: dist/plugin.js
hosts:
"sked.ly":
app: "skedly@latest"
"*.sked.ly":
app: "skedly@latest"
"dashboard.example.com":
app: "admin-panel"
pathPrefix: "/admin"
FieldTypeRequiredDescription
appstringYesApp resolved by getWorkerDir (e.g. "skedly@latest", "admin-panel"). Must be installed in one of the global workerDirs.
pathPrefixstringNoIf present, the vhost only handles paths starting with this prefix. Otherwise the request returns 404 from server.fetch and falls through to the pipeline.

The map key is the hostname pattern — exact (sked.ly) or wildcard (*.sked.ly).

HeaderWhenDescription
x-baseAlways on matchAlways /. Read by the runtime’s wrapper.ts for <base href> and asset resolution.
x-vhost-tenantWildcard match onlyExtracted subdomain (the portion that matched *). Not set on exact matches.

Implemented in plugins/plugin-vhosts/server/matcher.ts (function matchVirtualHost). Two-phase algorithm:

hostname (no port — url.hostname already strips it)
Phase 1: Exact match (high priority)
hosts[hostname]? ── Yes ──> Returns { app, pathPrefix }
│ No
Phase 2: Wildcard match
1. Iterate hosts entries
2. Skip keys that don't start with "*."
3. base = pattern.slice(2)
4. hostname.endsWith("." + base)? ── No ──> (continue)
5. tenant = hostname.slice(0, -(base.length+1))
6. tenant non-empty? ── Yes ──> Returns { app, pathPrefix, tenant }
│ No match
null → 404 → pipeline

Exact match always wins. When sked.ly, *.sked.ly, and admin.sked.ly are all configured:

RequestMatched rulex-vhost-tenant
sked.ly"sked.ly" (exact)(not set)
admin.sked.ly"admin.sked.ly" (exact)(not set)
tenant1.sked.ly"*.sked.ly" (wildcard)tenant1
acme.sked.ly"*.sked.ly" (wildcard)acme
unknown.example.com(no match)— (404 → pipeline)

For wildcard *.base.com matching sub.base.com, tenant receives the portion preceding .base.com:

hostname = "tenant1.sked.ly"
pattern = "*.sked.ly"
base = "sked.ly"
tenant = "tenant1" → x-vhost-tenant: tenant1

When pathPrefix is configured, url.pathname.startsWith(pathPrefix) is required after the hostname match. Otherwise the request returns 404 and falls through to the pipeline.

pathPrefixRequestMatches?
/admindashboard.example.com/adminYes
/admindashboard.example.com/admin/usersYes
/admindashboard.example.com/No (404 → pipeline)
/admindashboard.example.com/otherNo (404 → pipeline)
  • Port: url.hostname already removes the port — sked.ly:8000 matches sked.ly.
  • Localhost: keys like myapp.localhost work (add to /etc/hosts).
  • IP: IP keys work (192.168.1.100), but this is an atypical use.
  • Duplicate keys: YAML does not support them — the second key overwrites the first. For path-routing on the same hostname, use plugin-proxy.

The plugin exposes no HTTP routes. The public interface is the server.fetch hook applied by the runtime.

StepActionFailure
1hostname = new URL(req.url).hostname
2matchVirtualHost(hostname, hosts)returns null → 404 (falls through to pipeline)
3If match.pathPrefix and path does not start with it404 (falls through to pipeline)
4appDir = getWorkerDir(match.app)appDir falsy → 502 text/plain Virtual host app not found: <app>
5workerConfig = await loadWorkerConfig(appDir)propagates load error
6Clone request: headers.set('x-base', '/') and (if wildcard) headers.set('x-vhost-tenant', tenant)
7return pool.fetch(appDir, workerConfig, workerReq)response returns directly, bypassing the pipeline
export interface VHostsPluginConfig {
hosts: Record<string, VHostConfig>;
}
export interface VHostConfig {
app: string;
pathPrefix?: string;
}
export interface VHostMatch {
app: string;
pathPrefix?: string;
tenant?: string;
}
HookResponsibility
onInitReceives ctx.pool, dynamically imports apps/runtime/src/utils/get-worker-dir (creates getWorkerDir(workerName)) and apps/runtime/src/libs/pool/config (loadWorkerConfig). Logs Virtual hosts configured: <keys>.
server.fetchRouting as described above.
app.get("/api/data", async (c) => {
const tenant = c.req.header("x-vhost-tenant");
if (!tenant) return c.json({ error: "no tenant" }, 400);
const rows = await db.query("SELECT * FROM data WHERE tenant = ?", [tenant]);
return c.json(rows);
});

Minimal steps for a SaaS with tenant subdomains:

StepAction
1. DNSA record for sked.ly + wildcard A *.sked.ly pointing to the same IP/load balancer.
2. TLSWildcard certificate covering sked.ly and *.sked.ly (Let’s Encrypt via DNS-01, cert-manager on K8s, or Traefik with HostRegexp).
3. Buntime manifestenabled: true + entries for "sked.ly" (landing) and "*.sked.ly" (tenant subdomains).
4. WorkerMiddleware reads x-vhost-tenant, validates against DB, scopes queries by tenant_id.
5. ProvisioningOnly insert a row in tenants — wildcard DNS and wildcard cert cover new subdomains without a deploy.

See Multi-tenant for the full platform-level setup.

name: "@buntime/plugin-vhosts"
enabled: true
hosts:
"sked.ly":
app: "skedly@latest"
"*.sked.ly":
app: "skedly@latest"
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: sked-ly-tls
spec:
secretName: sked-ly-tls-secret
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
dnsNames:
- sked.ly
- "*.sked.ly"

For tenants with their own domain (scheduling.acme.com), add an exact entry pointing to the same app. The worker maps host → tenant via a custom_domains lookup:

hosts:
"*.sked.ly":
app: "skedly@latest"
"scheduling.acme.com":
app: "skedly@latest"

Each custom domain requires its own cert and DNS — outside the scope of this plugin.

AspectOn a vhost-matched request
plugin-gateway (rate limit, CORS, shell)Bypassed
plugin-authn (authentication)Bypassed
plugin-proxy (rewrites, WS)Bypassed
onRequest of any pluginDoes not run
<base href>Forced to / (not /{app}/)

By design, vhosts is for white-label/custom-domain scenarios where the worker takes full responsibility for auth, rate limiting, and CORS. If you need these concerns at the runtime level, serve the app via its default path without vhost.

server.fetch calls pool.fetch(appDir, workerConfig, workerReq) directly — without going through the runtime’s standard router. This means:

  • The same pool handles both vhosted and non-vhosted requests (no separate instance).
  • The appDir resolved by getWorkerDir(match.app) must be in globalConfig.workerDirs. If it is not, the plugin returns 502 with a text payload.
  • loadWorkerConfig(appDir) is called on every request — no cache in the plugin. If this becomes a bottleneck, caching should live in loadWorkerConfig (runtime layer).
  • The forwarded request is a clone (new Request(req.url, req)) with extra headers — the original body is preserved.

Pool details, worker lifecycle, and isolation are covered in Worker Pool.

SymptomLikely causeResolution
Request falls through to normal pipeline despite Host: foo.com being configuredPlugin disabled (enabled: false) or hostname does not match exactlyConfirm enabled: true and check case-sensitivity of the Host header
502 Virtual host app not found: <app>getWorkerDir(match.app) returned falsyConfirm the app is installed in global workerDirs and the name (with @version) matches
x-vhost-tenant absent on wildcard requestHostname matched an exact entry with higher priorityCheck whether an exact entry is overriding the wildcard
Captured tenant is a.b instead of aHostname is multi-level (e.g. a.b.sked.ly) — matcher accepts itAdd an exact entry for b.sked.ly if different handling is needed
Auth is not running for the vhosted domainExpected behavior: pipeline bypassMove auth into the worker (Hono middleware etc.)
Changes to hosts in manifest have no effectHosts are read in onInitRestart Buntime (no hot-reload for vhost configuration)
Path returns 404 even with the correct hostnamepathPrefix configured and path does not start with itAdjust pathPrefix or remove it from the entry
Duplicate keys in YAML — second entry “disappeared”YAML does not allow duplicate keysUse a single key per hostname; for path-routing use plugin-proxy
Terminal window
# Exact match
curl -i -H "Host: sked.ly" http://localhost:8000/
# Wildcard match (worker receives x-vhost-tenant: tenant1)
curl -i -H "Host: tenant1.sked.ly" http://localhost:8000/
# Path prefix filtering
curl -i -H "Host: dashboard.example.com" http://localhost:8000/admin/users # serves
curl -i -H "Host: dashboard.example.com" http://localhost:8000/other # 404 → pipeline
  • Plugin System — plugin pipeline and hook ordering (vhosts runs first).
  • Worker Poolpool.fetch, worker lifecycle, loadWorkerConfig.
  • Proxy — alternative when path-routing on the same hostname is needed.
  • Gateway and KeyVal — concerns that vhosts bypasses.
  • Multi-tenant — platform-level multi-tenancy on shared domains.