Skip to content

Configuration

plumix.config.ts default-exports one call to plumix(). Three slots are required, seventeen are optional, and each one is either a value or a factory from the package that owns it.

The config file is the CLI’s entry point and the worker’s. plumix dev, plumix build, plumix deploy, plumix migrate generate and plumix doctor all load it first, and --config <path> points them somewhere other than plumix.config.ts in the project root.

import { auth, plumix } from "plumix";
import {
cloudflare,
cloudflareDeployOrigin,
d1,
} from "@plumix/runtime-cloudflare";
import { recipes } from "./plugins/recipes";
import { theme } from "./theme";
export default plumix({
runtime: cloudflare(),
database: d1({ binding: "DB", session: "auto" }),
auth: auth({
passkey: {
rpName: "Recipes",
...cloudflareDeployOrigin({
workerName: "recipes",
accountSubdomain: "your-account",
localOrigin: "http://localhost:5173",
}),
},
}),
plugins: [recipes],
theme,
});

plumix() returns a resolved config rather than the object you passed. Five slots gain a default on the way through. A missing theme becomes the built-in welcome theme, a missing plugins becomes an empty array, a missing redirects becomes an empty array, a missing i18n becomes English only, and a missing basePath becomes "". That last one is normalization as well as defaulting, so docs, /docs and /docs/ all resolve to /docs.

One cross-slot rule is checked here rather than at request time: auth.magicLink without a mailer throws at build. Email sign-in with nothing to send the email through would otherwise fail silently on the first sign-in attempt.

Every slot below is optional unless it appears under “Required slots”. Leaving one out is a decision with a consequence rather than an oversight. With no cdn slot every public page renders live; with no storage slot there is nowhere for uploads to go; with no mcp or api block the dispatcher returns 404 for those paths before it imports either handler graph.

A slot that carries a secret does not take the secret. It takes EnvInput<T>, which is T | ((env: PlumixEnv) => T). Pass a literal value, or pass a resolver that derives one from the runtime environment.

The resolver form exists because of when things happen. On Workers a secret arrives per request through the env binding, and the config module is evaluated long before any request. A literal would have to be in the file, which means in git. A resolver runs later, with env in hand.

PlumixEnv is an interface you extend, which is what makes env.RESEND_API_KEY type-check rather than resolve to any:

import type { EnvInput, Mailer } from "plumix";
declare module "plumix" {
interface PlumixEnv {
readonly RESEND_API_KEY: string;
}
}
export const resend: EnvInput<Mailer> = (env) => ({
async send(message) {
const response = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
authorization: `Bearer ${env.RESEND_API_KEY}`,
"content-type": "application/json",
},
body: JSON.stringify({
from: "Recipes <hello@recipes.example.com>",
to: message.to,
subject: message.subject,
text: message.text,
}),
});
if (!response.ok) throw new Error(`resend: ${String(response.status)}`);
},
});

The resolver runs once per isolate rather than once per request. The result is memoized by the resolver’s identity, so a value that owns a connection, such as an SMTP transport or a pooled client, is built once and reused. env is stable across an isolate’s lifetime, which is what makes that safe.

The slots that take an EnvInput are mailer, the passkey origin and allowedOrigins, each OAuth provider’s client credentials, R2’s s3 credentials, and the whole libSQL connection config, which is typed EnvInput<LibsqlConfig> rather than exposing one resolver field. Locally their values come from .dev.vars; in production they are secrets on the deploy.

The runtime adapter. It builds the fetch handler and the scheduled handler, contributes the dev, build, deploy and types commands to the CLI, and resolves the platform bindings the other slots name. Cloudflare Workers is the one shipped adapter.

import { cloudflare } from "@plumix/runtime-cloudflare";
export const runtime = cloudflare();

The database adapter. d1 is the Cloudflare one; session: "auto" opts into D1’s Sessions API so reads can go to a replica. The alternative shipped today is libsql from plumix/db/libsql, a single endpoint with strong consistency and no replica hook.

import { d1 } from "@plumix/runtime-cloudflare";
export const database = d1({ binding: "DB", session: "auto" });

Sign-in and session policy. passkey is required inside it and everything else is opt-in. auth() validates its input and throws on a bad shape rather than deferring to the first sign-in.

The seven optional keys are these.

  • oauth adds providers such as github(...), each carrying its own client credentials.
  • magicLink adds email sign-in, and it needs a top-level mailer or the build throws.
  • sessions replaces the session policy, which covers lifetime and renewal.
  • authenticator replaces how a request is identified. The default chains the session cookie and then the API-token bearer header. Override it for transparent SSO, such as cfAccess({ teamDomain }), and note the built-in login routes stay mounted, so firewall /_plumix/auth/* at the edge if you want them gone.
  • bootstrapVia decides how the first admin enrols on a fresh deploy. The default "passkey" refuses magic-link and OAuth signup while the users table is empty. "first-method-wins" lets any verified flow mint that first admin, which suits a deploy already gated in front of the worker.
  • selfSignup opens public registration. Omit it and signup stays gated to the allowed_domains table.
  • loginPath is where an access policy’s redirectToLogin() sends an anonymous visitor, and it defaults to /_plumix/admin/login. Point it at a page that is not itself gated, or the visitor bounces between the gate and the login forever.
import { auth } from "plumix";
export const recipeAuth = auth({
passkey: {
rpName: "Recipes",
rpId: "recipes.example.com",
origin: "https://recipes.example.com",
},
loginPath: "/sign-in",
});

The passkey block appears in two shapes across this site, and they are the same block. The literal shape above writes rpId and origin by hand, which is what a fixed hostname wants. The other shape spreads cloudflareDeployOrigin({ ... }) into the block, which returns rpId, origin and sometimes allowedOrigins computed from the host a Cloudflare Workers Builds deploy is served on, and it is what the scaffolder writes into a new project. A build that runs anywhere else, your own machine included, gets the localOrigin fallback. Open your own plumix.config.ts and you will see the spread. localOrigin is an option of cloudflareDeployOrigin, not a passkey key, so it only exists in that shape.

rpId is deliberately not an EnvInput. It anchors the credential, so a passkey enrolled against one value cannot verify against another, and it has to be constant across environments.

Object storage for uploads. r2 binds a bucket; the media plugin requires this slot and imageDelivery alongside it. The alternative shipped today is s3 from plumix/storage/s3, which talks to any S3-compatible bucket over fetch and needs no runtime binding.

import { r2 } from "@plumix/runtime-cloudflare";
export const storage = r2({ binding: "MEDIA" });

On-the-fly image transforms behind the <Image> component: width, height, fit, quality, format and DPR. It pairs with storage, which holds the original. Cloudflare’s images() is URL math onto a zone’s Image Transformations; the Node runtime’s images() of the same name renders through sharp in the process and, unlike a CDN, can take the relative URL a disk-stored upload has.

import { images } from "@plumix/runtime-cloudflare";
export const imageDelivery = images();

A key/value store exposed on the request context as ctx.kv. Core never reads it; it is there for plugins that need a cheap store outside the database.

import { kv } from "@plumix/runtime-cloudflare";
export const kvStore = kv({ binding: "KV" });

The read-through CDN for anonymous public renders. ttl is the freshness window in seconds and staleWhileRevalidate is how long a stale copy may still be served while a fresh one is fetched.

import { cloudflare as cdn } from "plumix/cdn/cloudflare";
export const cdnSlot = cdn({
ttl: 3600,
staleWhileRevalidate: 86400,
zoneId: (env) => env.CF_ZONE_ID,
purgeToken: (env) => env.CF_CACHE_PURGE_TOKEN,
});

zoneId and purgeToken are required, and the provider disables itself when either resolves to nothing, because a CDN that cannot purge would serve a stale recipe forever. That is why the CDN is inert on a workers.dev host and live on a zone. The provider lives in core rather than in a runtime package, so this line is the same whether the site runs on Workers or in a container. CDN Caching covers what each host caches, the zone rule Cloudflare needs before it caches HTML, and what rotating that token does to pages the CDN already holds.

The outbound email transport, shared by every feature that sends mail. The interface is one method, send(message), so any provider fits. consoleMailer() logs the message instead of sending it, which is how you copy a magic-link URL out of the dev terminal.

import { consoleMailer } from "plumix";
export const mailer = consoleMailer();

The site’s presentation layer. Omit it and the built-in welcome theme renders instead, so a site with no theme still serves pages.

import type { TemplateData } from "plumix";
import { fallback } from "plumix";
import { defineTemplate, defineTheme } from "plumix/theme";
const index = defineTemplate<TemplateData>({
render: () => <main>Recipes</main>,
});
export const theme = defineTheme({
templates: [fallback(index)],
document: {
titleTemplate: (title) => (title ? `${title} · Recipes` : "Recipes"),
},
});

The plugin descriptors to install, in order. This array is the only way an entry type, a taxonomy, a meta box, a block or a route reaches the site.

import type { PlumixConfigInput } from "plumix";
import { definePlugin } from "plumix/plugin";
const recipes = definePlugin("recipes", {
setup: (ctx) => {
ctx.registerEntryType("recipe", { label: "Recipes" });
},
});
export const plugins: PlumixConfigInput["plugins"] = [recipes];

Two descriptors with the same id throw at boot rather than one quietly overwriting the other.

The locale registry. defaultLocale has to appear in locales and has to be enabled, or plumix() throws. Each locale is a code or an object with a label, a text direction and an enabled flag; the direction is derived from the code when you leave it out. resolveLocale is an escape hatch for sites that want Accept-Language detection or URL-prefix routing.

import type { PlumixConfigInput } from "plumix";
export const i18n: PlumixConfigInput["i18n"] = {
defaultLocale: "en",
locales: ["en", "uk", { code: "ar", label: "العربية" }],
};

This is the admin’s language, not your content’s. Translating entries is a userland concern.

The site’s own public-route redirects and 410 Gone rules. A rule matches on a from string or RegExp, or on a match(url) function, and yields either a target or { gone: true }. The dispatcher checks them before the content route map, so a redirect shadows a page that would otherwise exist.

import type { PlumixConfigInput } from "plumix";
export const redirects: PlumixConfigInput["redirects"] = [
{ from: "/recipe/:slug", to: "/recipes/:slug" },
{ from: "/old-caponata", to: "/recipes/sicilian-caponata", status: 302 },
{ from: "/drafts/:slug", gone: true },
];

A rule with no status is a 301. The request’s query string is appended to the target only when the target contains neither a ? nor a #, and only when preserveQuery is not false. A target with a fragment, such as /recipes/sicilian-caponata#method, drops the query, because appending after a # would fold the query into the fragment. Config rules merge ahead of plugin rules and theme rules, so the site wins a tie.

Serves the whole site under a subdirectory, for a reverse proxy that mounts Plumix below the domain root. It is path-only and never touches the passkey origin, which stays a scheme and host because WebAuthn requires it.

import type { PlumixConfigInput } from "plumix";
export const basePath: PlumixConfigInput["basePath"] = "/recipes";

The Model Context Protocol endpoint at /_plumix/mcp, where an MCP client reaches the site’s tools. Default-off in production, and the gate is early. With no mcp block a production dispatcher answers 404 before it imports the handler graph at all.

plumix dev bypasses that gate. The dispatcher also serves the endpoint whenever devCsrfLocalhost is set, which the app resolves from PLUMIX_DEV=1, so a connected coding agent reaches your tools locally with no config flag. The flag is statically false in a production build, so the bypass cannot follow you to the deploy.

import type { PlumixConfigInput } from "plumix";
export const mcp: PlumixConfigInput["mcp"] = { enabled: true };

The REST API and its OpenAPI document at /_plumix/api/v1/. Default-off, and cross-origin access is default-closed on top of that. With no cors block no Access-Control-Allow-Origin header is ever emitted. origins: "*" opens anonymous reads to any origin, an array allows only those, and a response authenticated with an API token is never CORS-exposed either way.

import type { PlumixConfigInput } from "plumix";
export const api: PlumixConfigInput["api"] = {
enabled: true,
cors: { origins: ["https://recipes.example.com"] },
};

The development-only debug bar. It defaults on in development and is compiled out of a production build. Pass false to suppress it, or an object to move it, open it by default, or silence panels by id. The core panels are app, request, database, template and timeline.

import type { PlumixConfigInput } from "plumix";
export const debugBar: PlumixConfigInput["debugBar"] = {
position: "bottom-left",
disable: ["database"],
};

Where request telemetry goes. Each consumer votes on whether to sample a request, and receives the finished snapshot after the response rather than during it, so export latency never lands in the response time. With no consumers the collector is a permanent no-op and production pays nothing.

import type { PlumixConfigInput } from "plumix";
export const telemetry: PlumixConfigInput["telemetry"] = {
consumers: [
{
id: "slow-requests",
onRequestEnd: (snapshot) => {
console.log(snapshot.spans.length);
},
},
],
};

The sampling vote runs before authentication, so ctx.user is null inside it even when the request carries a session cookie. Decide from cheap request-shaped facts, and do not throw, because a throwing vote fails the request.

Block-system configuration. Today it carries htmlAllowlist, the override feeding the sanitizer that both the public render and the editor canvas apply to stored HTML. extraTags and extraAttributes merge with the baseline; schemes and allowProtocolRelative replace it.

import type { PlumixConfigInput } from "plumix";
export const blocks: PlumixConfigInput["blocks"] = {
htmlAllowlist: {
extraTags: ["figure", "figcaption"],
extraAttributes: { figure: ["data-recipe-step"] },
},
};

Under all four options is a floor no override can widen past, and it has three parts. Script-capable URL schemes stay denied, on* event attributes and style stay denied, and a hard tag denylist stays denied. That last one covers script, iframe, object, embed, style, link, meta, base, form, input, svg and the rest of the execution and parser-context surface. An extraTags: ["script"] is dropped on the way through rather than merged, so the override silently has no effect.

Image handling for the <Image> component. remotePatterns is the allowlist of remote hosts it may optimize. Same-origin sources are always allowed, and an unlisted remote source still renders without optimization.

This slot is not the Cloudflare images() factory. Two different things are called images here. The factory imported from @plumix/runtime-cloudflare fills the imageDelivery slot and picks the resizing service; this images slot is a plain object that decides which remote hosts <Image> will touch. Writing images: images() sets the wrong slot from the wrong value.

import type { PlumixConfigInput } from "plumix";
export const images: PlumixConfigInput["images"] = {
remotePatterns: [{ protocol: "https", hostname: "images.example.com" }],
};

A Vite config object merged with the one Plumix builds. It stays structurally typed so core carries no Vite dependency, which is why the value is a plain object rather than the result of defineConfig.

import type { PlumixConfigInput } from "plumix";
export const vite: PlumixConfigInput["vite"] = {
resolve: { alias: { "~": "/src" } },
};

Project Structure covers the rest of the files this one sits beside. Secrets goes further into .dev.vars, production secrets and the EnvInput slots. Bindings and Environment covers the wrangler.jsonc half of the runtime slots. Cloudflare Workers covers what each adapter factory actually binds to. Access & Identity covers the auth slot’s principals and sessions, and Passkeys covers the relying-party fields inside it. Plugins covers what goes in the plugins array. Themes covers the theme slot. Permalinks and Slugs covers the URLs redirects and basePath reshape.

With the config understood, fill the plugins array. Content Modelling shows a local plugin registering an entry type, a taxonomy and a meta box, and Entry Types covers the registration options in full. When the model is settled, Deploy Your Site takes it live, and Deployment covers the parts this page only names.