Skip to content

Configuration

plumix.config.ts default-exports one call to plumix(). Three slots are required, nineteen 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 { plumix } from "plumix";
import { auth } from "plumix/auth";
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. Six 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 routes keeps every framework route, 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 later, but still before any request: auth.magicLink without a mailer fails plumix build and a production boot with magic_link_requires_mailer. Email sign-in with nothing to send the email through would otherwise fail silently on the first sign-in attempt. plumix dev skips the check, because it catches mail when no mailer is set, and so do operator commands such as plumix migrate, which never send a sign-in link.

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 .env; 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 read replicas.

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 eight optional keys are these.

  • oauth adds providers such as github(...), each carrying its own client credentials. authClient.signIn.oauth(provider) takes any string until the config declares its providers to the type system. A scaffolded site already declares them; an older site adds it at the end of plumix.config.ts, after binding the config to const config and default-exporting that, with OAuthProvidersOf imported from plumix:

    declare module "plumix" {
    interface OAuthProviderRegistry extends OAuthProvidersOf<typeof config> {}
    }
  • magicLink adds email sign-in, and it needs a top-level mailer or the build throws. plumix dev runs without one and catches the mail.

  • 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.

  • allowedDomains lists the email domains a new account may sign up from, by OAuth or magic link, and the role each one gets: allowedDomains: { "example.com": "editor" }. A key is a lowercase domain and matches an email’s domain exactly, whatever its case, so ANN@Example.COM matches example.com and mail.example.com does not. To close a domain, delete its line. An uppercase or empty key, or a role the site does not have, is an error when auth() builds. The list is config rather than an admin screen because it decides who gets an account and with how much authority, so it is reviewed and deployed with the site (ADR 0034).

  • selfSignup opens public registration at selfSignup.defaultRole. A domain in allowedDomains still gets its own role, and every other address gets the default. Omit it and only the listed domains may sign up.

  • loginPath is where an access policy’s redirectToLogin() sends an anonymous visitor, and it defaults to /_plumix/admin/login. Failed magic-link sign-ins, failed OAuth callbacks, email-change confirmations, a magic-link request or sign-out posted from a plain form, and a plugin sign-in method’s failures land there too, with a query parameter the page reads to show the outcome: magic_link_error=<code>, magic_link_sent=1, oauth_error=<code>, email_change_error=<code>, email_change_success=1, signed_out=1, or <key>_error=<code>, where a method’s key is its name with - replaced by _. 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/auth";
export const recipeAuth = auth({
passkey: {
rpName: "Recipes",
rpId: "recipes.example.com",
origin: "https://recipes.example.com",
},
loginPath: "/sign-in",
});

Migrating sign-in links. The gate always sends ?redirectTo=, and neither its name nor the redirect’s 302 is configurable (ADR 0033). Links and bookmarks from a previous site that carry another name, such as ?callbackUrl=, still work: your sign-in page reads the old name in an island and passes it on to authClient as redirectTo.

import { authClient } from "plumix/auth/client";
const redirectTo =
new URLSearchParams(location.search).get("callbackUrl") ?? undefined;
await authClient.signIn.magicLink({ email, redirectTo });
const githubHref = authClient.url(
{ method: "oauth", route: "github/start" },
{ redirectTo },
);

The server validates redirectTo and keeps it only when it is a root-relative path inside the base path, sending the visitor to / otherwise, so the page needn’t check it.

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 it is the same whether the site runs on Workers or in a container. A Worker imports the same provider from @plumix/runtime-cloudflare/cdn instead, which also purges Cloudflare’s Workers Caching. 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. Under plumix dev a site with no mailer gets the same logging, and the dev server keeps each mail for the mail gallery.

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

Overrides for the mails core and your plugins declare, by name. Each override replaces any of a mail’s subject, text and html, and wins over the theme’s mail and the declaring owner’s own. Overriding a name nothing declares stops the build. Mail covers declaring, sending and overriding.

import type { MailConfig } from "plumix";
export const mail: MailConfig = {
overrides: {
magicLink: {
subject: (_props, ctx) => `Your ${ctx.siteName} sign-in link`,
},
},
};

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/theme";
import { defineTemplate, defineTheme, fallback } 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.

Which of core’s framework routes the site keeps: the author, date and search families. Each defaults to true; one set to false is never compiled, so its URLs 404 unless something else answers them.

import type { PlumixConfigInput } from "plumix";
export const routes: PlumixConfigInput["routes"] = { date: false };

Routing covers what each family holds and what changes when one is off.

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 dev server auto-enables the endpoint, so a connected coding agent reaches your tools locally with no config flag. An agent on the same machine needs no token either: a loopback request without one acts as the site’s first admin, and the revisions it writes name “dev (loopback)” as the token. A loopback request that carries a token acts as that token’s person, as in production. A request whose Origin is another site is still refused, and off loopback the endpoint asks for a token as in production. The check behind all of this is statically false in a production build, so the bypass cannot follow you to the deploy. Connect an agent covers tokens, clients and the tools.

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"] },
};

Development-only configuration. Everything under it defaults on in development and is compiled out of a production build. The app resolves this block into ctx.dev, which is undefined in a production build.

bar is the debug bar itself — pass false to suppress it, or an object to move it or open it by default. panels says which debug panels the site shows, keyed by panel id; an absent key shows the panel. The core panels are app, request, database, template, timeline and mail, and a plugin that contributes one declares its id so you can name it here too. history bounds the ring of recent requests the bar’s switcher, the request-history viewer and the dev MCP tools all read, and the same caps bound the mail the dev server catches.

import type { PlumixConfigInput } from "plumix";
export const dev: PlumixConfigInput["dev"] = {
bar: { position: "bottom-left" },
panels: { database: false },
history: { maxEntries: 50 },
};

Panel ids are checked: naming a panel no installed plugin contributes is a type error rather than a setting that quietly does nothing. panels is read by the debug bar and by the request-history viewer at /_plumix/debug/requests, so switching a panel off hides it in both.

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.

~/ and @/ resolve to the project root both while plumix.config.ts is evaluated and in the bundle, so the config, the theme, every plugin and anything they import can use them. Aliases added through vite.resolve.alias apply only inside the bundle: the config and everything it imports are evaluated before Vite starts, so an import through one of them fails to load the config.

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

vite.plugins adds Vite plugins to plumix dev and plumix build on every runtime. They run after Plumix’s own plugin.

import type { PlumixConfigInput } from "plumix";
import tailwindcss from "@tailwindcss/vite";
export const vite: PlumixConfigInput["vite"] = {
plugins: [tailwindcss()],
};

Project Structure covers the rest of the files this one sits beside. Secrets goes further into .env, 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.