Lakebed Reference
Use this as the quick contract when building a Lakebed capsule.
Capsule
A capsule is one complete Lakebed app: source, server API, client UI, state, auth, logs, and deploy URL.
V0 expects this directory shape:
server/index.ts
client/index.tsx
shared/
.env.lakebed.server
server/index.tsexports the capsule definition.client/index.tsxexports the PreactAppcomponent.shared/contains pure TypeScript used by both sides..env.lakebed.serveris optional server-only configuration.
There is no lakebed.config.ts in v0.
Module Boundaries
- Server code imports from
lakebed/server. - Client code imports from
lakebed/client. - Shared code imports only pure relative TypeScript.
- App code can import relative files and Lakebed-provided Preact modules.
- App code cannot import arbitrary npm packages yet.
- Capsule modules cannot use Node built-ins.
- Shared code must not read env, secrets, DOM APIs, Node APIs, or Lakebed runtime APIs.
Server API
import { action, boolean, capsule, mutation, query, string, table, userId } from "lakebed/server";
Export one default capsule() call:
export default capsule({
auth: { requireSignIn: false },
schema: {
todos: table({
text: string(),
done: boolean().default(false),
ownerId: userId()
}).index("by_owner", ["ownerId"])
},
queries: {
todos: query(async (ctx) => {
const { userId } = ctx.auth.requireIdentity();
return ctx.db.todos
.withIndex("by_owner", (q) => q.eq("ownerId", userId))
.order("desc")
.collect();
})
},
mutations: {
addTodo: mutation(async (ctx, text: string) => {
const { userId } = ctx.auth.requireIdentity();
return ctx.db.todos.insert({
text,
done: false,
ownerId: userId
});
})
},
actions: {
summarize: action(async (ctx, label: string) => {
const { userId } = ctx.auth.requireIdentity();
const todos = await ctx.db.todos
.withIndex("by_owner", (q) => q.eq("ownerId", userId))
.collect();
return { count: todos.length, label };
})
}
});
capsule() accepts these keys, and nothing else:
| Key | Type | Purpose |
|---|---|---|
name | string | App title used for the browser tab. Defaults to Lakebed Capsule. |
favicon | string | Relative path to an .svg or .ico file in the capsule, at most 64 KiB. Without it, Lakebed uses favicon.svg or favicon.ico at the capsule root. If neither file exists, the capsule serves no favicon. lakebed new writes a starter favicon.svg so a fresh capsule has one. |
auth | CapsuleAuth | { requireSignIn?, onGuestUpgrade? }. See require sign-in and guest upgrades. |
schema | table map | Tables declared with table({ ...fields }). |
queries | handler map | Read-only handlers declared with query(). |
mutations | handler map | Read-write handlers declared with mutation(). |
actions | handler map | Read-only handlers declared with action(), called directly by the client. |
endpoints | endpoint map | HTTP routes declared with endpoint(). |
export default capsule({
name: "Todo",
favicon: "assets/icon.svg",
// schema, queries, mutations, actions, and endpoints follow
});
The favicon path must stay inside the capsule. Absolute paths, .. segments, and extensions other than .svg and .ico fail the build.
Server handlers receive:
ctx.auth: guest, signed-in account, or no-session state, withrequireIdentity()andrequireSignedIn()guards.ctx.db: table access for the capsule database.ctx.env: server-only env values.ctx.log: structured logs captured by Lakebed, withinfo,warn, anderror.
Actions and queries have read-only database access. Mutations and endpoints can write to the database. An endpoint with readOnly: true cannot write.
query(), mutation(), and action() only wrap a handler so TypeScript can infer its ctx. endpoint() does more. It rejects a non-boolean readOnly, then returns an EndpointDefinition that holds kind, the upper-cased method, the path, and readOnly. Lakebed reads that object to register the route. The capsule API guide shows each one in a full capsule.
Data API
Tables are declared with table({ ...fields }). Field helpers are:
| Helper | Row type | Notes | |
|---|---|---|---|
string() | string | ||
boolean() | boolean | ||
number() | number | Finite float64. NaN and Infinity are rejected on write. | |
id("table") | string | Row id from the named table. | |
userId() | string | Lakebed user reference that follows a guest upgrade. | |
.default(value) | same | Fills the value when an insert omits the field. | |
.optional() | `T \ | undefined` | The field may be absent. Pass null to clear it. |
.index(name, fields) | Declares an index on a table. |
scores: table({
playerId: id("players"),
points: number(),
note: string().optional(),
streak: number().optional().default(0)
}).index("by_points", ["points"])
number(), userId(), and .optional() need a database API v1 artifact. Every new build produces one. Rebuild and redeploy older capsules before using them.
Index order is numeric for number() fields. Absent optional values sort first. See the database guide for the full sort rules and the schema-change rule.
Every stored row includes:
idcreatedAtupdatedAt
Table methods are async. Use withIndex(name, range), then order("asc" | "desc") and one terminal: collect(), take(count), first(), or paginate(options). Direct get, insert, update, and delete are also awaited. Legacy where, orderBy, limit, and all calls are rejected for new artifacts.
See the database guide for index and consistency details. Use the Database API v1 migration guide for before-and-after syntax and a copy-paste agent prompt.
Treat queries and mutations as the source of truth. Get a verified caller with ctx.auth.requireIdentity(), filter user-owned data by its userId, and re-check ownership before changes or deletes. userId() does not enforce ownership. Shared queries must deliberately omit the owner filter.
External Endpoints
Use endpoint({ method, path }, handler) for webhooks and other services that call your app over HTTP.
import { endpoint, json, text } from "lakebed/server";
endpoints: {
stripeWebhook: endpoint({ method: "POST", path: "/webhooks/stripe" }, async (ctx, req) => {
if (req.headers.get("x-webhook-secret") !== ctx.env.STRIPE_WEBHOOK_SECRET) {
return text("unauthorized", { status: 401 });
}
const body = await req.text();
ctx.log.info("stripe webhook received", { bytes: body.length });
return json({ ok: true });
})
}
Endpoint handlers receive ctx.auth, ctx.db, ctx.env, and ctx.log. The request exposes method, path, url, headers.get(name), query, text(), json(), and bytes(). Successful endpoint calls can write to the database and publish subscribed queries. Use .env.lakebed.server secrets for webhook checks.
Return one of four response helpers: json(value, options?), text(value, options?), empty(options?) for a 204, or redirect(url, options?) for a 302. All four take { status?, headers? }. See endpoint responses.
An endpoint reads a Lakebed identity from the reserved X-Lakebed-Token header. Browser code gets that token from getIdentity(). See endpoint credentials.
Set readOnly: true for an endpoint that only reads the database:
endpoint({ method: "GET", path: "/api/items", readOnly: true }, async (ctx) =>
json(await ctx.db.items.withIndex("by_creation").collect())
);
Read-only endpoints can run at the same time as other reads and mutations. Lakebed rejects database writes from these handlers. They use request quota, not mutation quota. GET and HEAD endpoints default to read-only. Other methods default to writable. Set readOnly: false explicitly if a GET or HEAD endpoint must write. This default applies when an app is built with the updated SDK. Existing deployed artifacts keep their original behavior.
Custom endpoint Authorization headers belong to the app, including Bearer and Basic webhook credentials. Send a Lakebed identity token in X-Lakebed-Token, without a Bearer prefix. Lakebed verifies and removes that reserved header before calling the handler. Same-origin guest cookies also identify the caller. See endpoint credentials.
Client API
import {
canAccessApp,
createClient,
ErrorBoundary,
getIdentity,
Link,
Route,
Router,
Routes,
SignInWithGoogle,
navigate,
retryAuth,
signInWithGoogle,
signOut,
useAction,
useAuth,
useLocation,
useMutation,
useNavigate,
useParams,
usePaginatedQuery,
useQuery
} from "lakebed/client";
createClient<typeof app>(): infer query, mutation, action, and paginated-query names, arguments, and results from the server definition.useQuery<T>("name"): subscribe to a server query. Returnsundefineduntil the first result arrives.usePaginatedQuery<T>("name", args, { initialNumItems }): subscribe to cursor pages, accumulate them, and exposeloadMore()/reset().useMutation<TArgs, TResult>("name"): call a server mutation.useAction<TArgs, TResult>("name"): call a read-only server action.<ErrorBoundary>: catch query failures and show a retryable fallback. Lakebed wraps the generated app root with this boundary automatically.useAuth(): read the current client identity. Useauth.isLoadingto avoid showing signed-out UI while Lakebed confirms a stored session.canAccessApp(): true when session setup finished without an error, a guest or account exists, and the app's sign-in policy is met. Gate data components on it.getIdentity(): read the current identity snapshot, including its optional token for custom endpoint requests. Send that token only to your app's origin.retryAuth(): retry failed session setup, a failed sign-out, or a guest upgrade shown byauth.error. It does not renew an expired or revoked token. Show<SignInWithGoogle />next to it.<SignInWithGoogle />: render the built-in Google sign-in button.signInWithGoogle(): start Google sign-in from custom UI.signOut(): end the signed-in session. The app returns to a fresh guest session if guests are allowed.<Router>,<Routes>, and<Route>: render client-side pages.<Link to="/path">: navigate without a page reload. Paths are app-relative locally and on hosted app subdomains.useParams<T>(),useLocation(),useNavigate(), andnavigate(): read and change the current client route.
At most 4 mutation and action calls are in flight at once on a connection. Later calls wait in the client and go out in call order as earlier ones finish, so fast input such as drag-and-drop does not fail. Calls in flight at the same time can finish in a different order, so await a call before a call that depends on its result. A call that has not gone out yet fails with an error that says "the server did not apply it" when the connection closes or the signed-in user changes. It is not sent later. Read the current state, then call it again if it still applies. A call that already went out keeps the old rule: if the connection closes first, its result is unknown. At most 1,000 calls can wait. Put many rows in one mutation instead of one call per row.
The typed client reads the server definition through a type-only import:
import { createClient } from "lakebed/client";
import type app from "../server/index";
const client = createClient<typeof app>();
const todos = client.useQuery("todos");
const addTodo = client.useMutation("addTodo");
const summarize = client.useAction("summarize");
await addTodo("Ship the app");
const summary = await summarize("today");
client.useQuery("todos") returns undefined until the first result arrives. Mutation and action calls return promises. Query, mutation, and action arguments and results must be JSON-compatible.
On a hosted app, the client also sends anonymous timing counts to Lakebed over the app connection: how long page loads, first query results, mutations, and reconnects take. It sends only histogram counts, with no user IDs, query names, arguments, results, or URLs.
Treat query results as read-only, including nested objects and arrays. Subscribers share each result. Copy values before you edit or sort them. For example, use [...todos].sort(...) instead of todos.sort(...). Lakebed freezes query results during lakebed dev to catch accidental changes. Use mutations to change stored data.
The standalone useQuery, useMutation, useAction, and usePaginatedQuery hooks remain available when callers provide their own types. Standalone useQuery has the same loading contract as client.useQuery: it returns undefined until the first result arrives.
Paginated query handlers accept a trailing argument whose pagination property is passed to .paginate(...):
messages: query(async (ctx, args: { roomId: string; pagination: { cursor: string | null; numItems: number } }) =>
ctx.db.messages
.withIndex("by_room", (q) => q.eq("roomId", args.roomId))
.order("desc")
.paginate(args.pagination)
)
const messages = usePaginatedQuery<Message>("messages", { roomId }, { initialNumItems: 25 });
return (
<>
{messages.page.map((message) => <p key={message.id}>{message.body}</p>)}
{!messages.isDone ? <button onClick={messages.loadMore}>Load more</button> : null}
</>
);
Router example:
function TodoPage() {
const { id } = useParams<{ id: string }>();
return <main>Todo {id}</main>;
}
export function App() {
return (
<Router>
<Link to="/todos/123">Open todo</Link>
<Routes>
<Route path="/" element={<main>Home</main>} />
<Route path="/todos/:id" element={<TodoPage />} />
<Route path="*" element={<main>Not found</main>} />
</Routes>
</Router>
);
}
A server endpoint takes precedence over a client route only when both its path and HTTP method match the request. Opening or reloading a URL makes a GET request. Client router navigation stays in the loaded app. Keep API paths separate from page paths. See client routes.
Actions
action(handler) declares an action in the actions map of a capsule. useAction(name) and client.useAction(name) return a function that runs the named action.
Action handlers receive ctx.auth, read-only ctx.db, ctx.env, and ctx.log. Actions do not write to the database or trigger query invalidation. Outbound fetch and hosted server env require a claimed deploy.
The limits page defines action argument, result, and runtime limits for local development and hosted deployments. The handler capability table compares database writes, fetch, env, auth, and quota use across handlers.
Hosted actions count against both the request limit and the mutation limit. Closing the client connection cancels actions that have not completed.
Auth
Guest access uses protected browser sessions by default. User IDs are opaque references, not session credentials.
| State | userId | provider | isGuest | isSignedIn |
|---|---|---|---|---|
| No session | null | null | false | false |
| Guest | string | "guest" | true | false |
| Account | string | "google" | false | true |
isAuthenticated remains an alias for isSignedIn. displayName is present in each state. Signed-in identities can include subject, migration-only identityAliases, email, emailVerified, and picture. Profile data does not prove access.
Server guards:
const identity = ctx.auth.requireIdentity(); // Guest or account, with a user ID.
const account = ctx.auth.requireSignedIn(); // Signed-in account only.
Set auth: { requireSignIn: true } in capsule() to block all app data operations before sign-in. The shell and auth routes remain available. The client exposes auth.requireSignIn, auth.isLoading, and auth.error for UI state. Wait for session setup, show errors with a retryAuth() button, and mount private data components only when the session satisfies the app policy.
Store Lakebed user references with userId(). Those fields transfer automatically on a verified guest upgrade. Existing account rows remain. Set auth.onGuestUpgrade for app-specific conflict rules before automatic transfer. Plain strings and external data do not transfer. See the auth guide for the transaction and session contracts.
Named local test overrides use npx lakebed auth as alice or ?lakebed_guest=alice. They are not available in hosted apps and cannot upgrade to accounts. npx lakebed auth reset restores normal protected sessions. Use the default browser session to test a real upgrade.
Server Env
Put server-only values in .env.lakebed.server:
OPENAI_API_KEY=sk-...
Read them from server handlers:
query((ctx) => Boolean(ctx.env.OPENAI_API_KEY));
npx lakebed dev loads this file locally. Hosted env syncs only after the deploy is claimed. Sync is replace-based: keys removed from .env.lakebed.server are removed from the hosted deploy.
Env values are not exposed to client code and are not embedded in anonymous artifacts.
CLI environment variables
.env.lakebed.server is your app's env. These four variables configure the Lakebed CLI on your machine or in your CI, where there is no saved login. They never reach your app or the hosted service. Every one is optional.
| Name | Effect |
|---|---|
LAKEBED_TOKEN | Credential from npx lakebed token create. Used instead of the saved developer login. |
LAKEBED_TOKEN_API | The one API origin allowed to receive LAKEBED_TOKEN. Required when you also pass a custom --api. |
LAKEBED_DEPLOY_API | Default deploy API origin. --api overrides it. |
LAKEBED_INSPECT_TOKEN | Token for inspect, db list, db dump, db export, logs, and storage list against a hosted deploy. --inspect-token overrides it. |
LAKEBED_TOKEN=lkb_... npx lakebed deploy
LAKEBED_INSPECT_TOKEN=lkb_... npx lakebed db export <deploy-id-or-url> --out backup.json
Styling
Use Tailwind classes directly in JSX. Lakebed compiles them automatically during dev, build, and deploy. New builds include compiled CSS without loading the Tailwind browser compiler. Redeploy older apps with the current CLI to use compiled CSS.
Write complete class names in client files or shared files imported by the client. Conditional classes such as done ? "text-neutral-500" : "text-white" work. Constructed names such as bg-${color}-500 do not. For values loaded at runtime, use inline styles or select from a map of complete class names in the client source.
V0 does not support CSS files, CSS modules, PostCSS, or Tailwind config. You do not need a separate CSS build command.
Runtime Inspection
npx lakebed dev and the hosted runner serve the same inspection routes with the same JSON shapes. The inspect loop is identical: build locally, read a route, deploy, read the same route.
While npx lakebed dev is running:
npx lakebed inspect --port 3000
npx lakebed db list --port 3000
npx lakebed db dump --port 3000
npx lakebed db export --port 3000 --out backup.json
npx lakebed logs --port 3000
For a deployed app:
npx lakebed inspect <deploy-id-or-url>
npx lakebed db list <deploy-id-or-url>
npx lakebed db dump <deploy-id-or-url>
npx lakebed db export <deploy-id-or-url> --out backup.json
npx lakebed logs <deploy-id-or-url>
The CLI reads these GET routes on the app origin. Call them directly with curl when the CLI is not enough.
| Route | Returns |
|---|---|
/__lakebed/manifest | App name, deploy id, URL, runtime version, inspect policy, query, mutation, action, and endpoint names, schema, favicon, limits, database index and read metrics. |
/__lakebed/usage | limits, state with stored stateRows and stateBytes, and usage with the daily requests and mutations counters. |
/__lakebed/url | basePath and the app url. |
/__lakebed/db/tables | Table names, row counts, and database index state. |
/__lakebed/db | Bounded row dump per table plus truncated. Stops at the rowsReturned limit. |
/__lakebed/db/export | Backup metadata. Add ?table=<name>&schemaHash=<hash> for pages. |
/__lakebed/logs | The last 100 log entries as { at, level, message, data }. |
/__lakebed/storage | Uploaded objects with size, visibility, and uploader. |
Local /__lakebed/usage counts requests and mutations the same way the hosted runner does, so an agent can see how close an app is to a daily limit before deploying. npx lakebed dev only counts. It never rejects on a daily limit.
Two routes exist only on the hosted runner: /__lakebed/access/status (restricted access users) and /__lakebed/report (abuse reports). npx lakebed dev answers them with 404 and a JSON body with hostedOnly: true and a hint to run npx lakebed deploy, so an agent knows the route exists and what to do.
Local state is in-memory and resets when npx lakebed dev restarts.
db dump is a bounded inspection view. db export scans the built-in by_creation index in bounded pages and writes a versioned JSON backup through a same-directory temporary file before atomically replacing the destination. Tables are ordered by name; rows are ordered by immutable (createdAt, id) ascending; row object keys are serialized canonically. Each page is a separate store snapshot, so the complete backup is not a point-in-time transaction: concurrent inserts after the cursor may appear, deletes not yet visited may disappear, and updates reflect the page that reads them. A schema change, authorization failure, network/write error, or handled interruption fails the export, preserves an existing destination, and removes the temporary file.
Local inspection needs no token. npx lakebed dev binds to 127.0.0.1, so only programs on your machine can reach it. --host 0.0.0.0 serves the whole network, and then every inspection route, including row dumps and logs, is open to that network. Hosted inspection is private by default for manifests, table names, row dumps, logs, and usage. Run hosted inspection commands from the capsule directory so the CLI can find lakebed.json or .lakebed/deploy.json in the working directory tree and send developer auth from the binding or saved anonymous claim token. Direct HTTP callers can use Authorization: Bearer <token>. Non-private hosted manifests expose only app name, deploy id, client bundle hash, runtime version, and favicon metadata (content type, hash, route, and source path) when the app has one.
CLI
npx lakebed new [name] [--template todo] [--no-git]
npx lakebed create [name] [--template todo] [--no-git]
npx lakebed init [--template todo] [--no-git]
npx lakebed dev [capsule-dir] [--port 3000] [--host 127.0.0.1]
npx lakebed build [capsule-dir] [--target claimed|anonymous] [--out .lakebed/artifacts/app.json] [--json]
npx lakebed deploy [capsule-dir] [--api <url>] [--public-inspect] [--json]
npx lakebed deploy list [--api <url>] [--json]
npx lakebed deploy archive <deploy-id> [--api <url>] [--json]
npx lakebed deploy restore <deploy-id> [--api <url>] [--json]
npx lakebed deploy terminate <deploy-id> --yes [--api <url>] [--json]
npx lakebed claim [capsule-dir] [--api <url>] [--json]
npx lakebed auth login [--api <url>] [--json]
npx lakebed auth status [--api <url>] [--json]
npx lakebed auth logout [--api <url>]
npx lakebed token create --name <name> [--personal] [--api <url>] [--json]
npx lakebed token list [--api <url>] [--json]
npx lakebed token revoke <token-id> [--api <url>]
npx lakebed domains add <hostname> [--api <url>] [--json]
npx lakebed domains status <hostname> [--api <url>] [--json]
npx lakebed domains list [--api <url>] [--json]
npx lakebed domains remove <hostname> [--api <url>] [--json]
npx lakebed inspect [deploy-id-or-url] [--port 3000] [--api <url>] [--inspect-token <token>] [--usage] [--json]
npx lakebed run-many [capsule-dir] [--count 20] [--base-port 4000]
npx lakebed auth as <name>
npx lakebed auth reset
npx lakebed db list [deploy-id-or-url] [--port 3000] [--inspect-token <token>]
npx lakebed db dump [deploy-id-or-url] [--port 3000] [--inspect-token <token>]
npx lakebed db export [deploy-id-or-url] [--port 3000] [--inspect-token <token>] [--out <file>]
npx lakebed logs [deploy-id-or-url] [--port 3000] [--inspect-token <token>]
npx lakebed storage list [deploy-id-or-url] [--prefix <key-prefix>] [--port 3000] [--inspect-token <token>] [--json]
npx lakebed storage get <key> [deploy-id-or-url] [--out <file>] [--port 3000] [--json]
npx lakebed storage put <file> [deploy-id-or-url] [--public] [--content-type <type>] [--port 3000] [--json]
npx lakebed storage delete <key> [deploy-id-or-url] [--port 3000] [--json]
npx lakebed users list [deploy-id] [--api <url>] [--json]
npx lakebed users approve <id-or-email> [deploy-id] [--api <url>] [--json]
npx lakebed users deny <id-or-email> [deploy-id] [--api <url>] [--json]
npx lakebed users remove <id-or-email> [deploy-id] [--api <url>] [--json]
Build behavior
npx lakebed build builds locally without sign-in or a deploy. It defaults to --target claimed, which allows server-side fetch. Use --target anonymous only to check anonymous deploy restrictions. Building does not publish the app or change deploy ownership. Hosted fetch and server env still require an owned or claimed deploy.
Deploy Behavior
npx lakebed deploy can publish an anonymous deploy first.
Claim the deploy before relying on hosted server env or outbound server-side fetch, then run npx lakebed deploy again. Anonymous deploys intentionally disable those capabilities while preserving server handler control flow in the source runtime.
Run npx lakebed auth login before the first deploy to create an owned app. The CLI writes lakebed.json at the capsule root with only deployId; commit it for fresh-checkout and CI deploys. npx lakebed token create --name github-actions returns a deploy-scoped CI credential once. Use npx lakebed token create --personal --name local-automation when automation needs an owner-wide credential instead. Supply the returned value as LAKEBED_TOKEN. For a custom API origin, LAKEBED_TOKEN_API must match the canonical --api origin exactly. The canonical origin is the scheme and host, plus a non-standard port when present, without a path or query.
Hosted deploy inspection is private by default. Use npx lakebed deploy --public-inspect only for demos where making data and logs public is intentional.
Claimed deploys can reserve Lakebed-owned app subdomains with npx lakebed domains add my-app.lakebed.app. Reserved product names such as api, admin, docs, and www cannot be registered.
npx lakebed deploy list shows owned deploys and their lifecycle status. npx lakebed deploy archive <deploy-id> stops an app without deleting its resources. npx lakebed deploy restore <deploy-id> makes an archived app available again.
npx lakebed deploy terminate <deploy-id> --yes stops an app and prevents later deploy updates. Without --yes, the command prints a confirmation prompt and does not terminate the app. A terminated deploy cannot be restored.
Archive and terminate retain the deploy record, stored data, logs, usage records, server env, domain mappings, and referenced artifact. Archived and terminated app URLs do not serve traffic or inspection routes. The CLI does not provide a command to delete retained deployment data.
Hosted anonymous deploys enforce the state byte limit during mutation commit, cap logs by entry count and bytes, and limit deploy creation, app requests, and app mutations. limits.md describes default resource limits and their errors. Unclaimed deploys expire and stop serving. About one week after expiry, Lakebed deletes unclaimed deploys with their data, logs, and server env. Claimed deploys do not expire.
Custom Domains
domains manages the hostnames attached to a claimed, active deploy. *.lakebed.app subdomains are free on every plan. Attaching a domain you own (app.example.com, or an apex example.com) requires a boosted plan; the free plan is capped at 0 custom domains and the pro plan at 5 per account.
npx lakebed domains add <hostname> [--api <url>] [--json]
npx lakebed domains status <hostname> [--api <url>] [--json]
npx lakebed domains list [--api <url>] [--json]
npx lakebed domains remove <hostname> [--api <url>] [--json]
add registers the hostname and prints the DNS records to create: a CNAME pointing the hostname at Lakebed and a TXT record that proves ownership. Create them exactly as printed, then run status until the domain is active. For an apex domain (no subdomain), use your registrar's ALIAS/ANAME or CNAME-flattening record instead of a static A record. The hostname must not be under lakebed.app or lakebed.dev. Wildcards are unsupported, so add each hostname individually.
status re-checks verification and certificate issuance and drives the domain through its lifecycle: pending_dns → verified → active. It can also report failed, disabled, or deleting. Exit codes make it loopable: 0 active, 2 pending, 1 failed.
failed is not terminal. Fix the DNS records and keep polling; retryable certificate failures re-issue on their own. A hostname whose registration the provider removed is the exception: remove and re-add it. disabled and deleting are terminal. Requests to a failed hostname get a 404, and a disabled or deleting hostname gets a 410.
list prints every domain on the deploy. remove detaches a hostname; it works for .lakebed.app subdomains too.
Verification uses the TXT record: the CNAME can resolve while the hostname still returns 404 until the TXT record is observed. This is expected, so keep re-running status. DNS propagation can take time. TLS certificates are issued automatically after verification, with no further action. Signed-in users keep the same userId across the generated *.lakebed.app URL and the custom domain. Archiving or terminating an app releases its custom domains; restoring the app does not resurrect them, so re-add them.
HTTP
POST /v1/deploys/:id/domains {hostname}
GET /v1/deploys/:id/domains
GET /v1/deploys/:id/domains/:hostname (refresh)
DELETE /v1/deploys/:id/domains/:hostname
For a custom domain the response envelope adds status, statusDetail, certificateStatus, dnsRecords (an array of {name, type, value}), and lastCheckedAt. The same fields back --json output.
Stable error codes:
lakebed_custom_domain_plan_required(HTTP 402): the account's plan does not allow custom domains (free plan is 0).lakebed_custom_domain_cap_exceeded(HTTP 409): the account is at its custom-domain cap (5 on pro).
Reserved product names (api, admin, docs, www, and similar) remain unregisterable as .lakebed.app subdomains; see Deploy Behavior.