Configuration
The full v.* validator catalog, helipod.config.ts, crons.ts, http.ts, and every environment variable the engine reads.
This page is the full reference for every configuration surface helipod has: the v.* validator
catalog, the schema builder, helipod.config.ts, crons.ts, http.ts, and every HELIPOD_*
environment variable the CLI reads. For the why behind any of it, see
Schema & tables, Queries,
and Components overview.
Validators (v)
These come from @helipod/values. Every field in defineTable, every entry in a function's
args/returns, and a composed component's own config in helipod.config.ts all build on these
validators.
Infer<typeof validator> gives you the matching TypeScript type. Every validator also serializes to
JSON with .toJSON(). That JSON is what the engine stores as a table's live schema, and what
helipod deploy's schema diff compares.
Prop
Type
import { v } from "@helipod/values";
v.object({
status: v.union(v.literal("open"), v.literal("closed")),
assigneeId: v.optional(v.id("users")),
tags: v.array(v.string()),
translations: v.record(v.string(), v.string()),
raw: v.any(),
});Every write, whether ctx.db.insert, replace, or the patch path, is checked against its table's
document validator at commit time, inside the transaction. A wrong-typed, missing-required-field,
or extra-field document is rejected with a DocumentValidationError before anything lands, and the
mutation throws. Nothing partial commits.
Turn validation off for a whole schema with defineSchema(tables, { schemaValidation: false }).
Every table becomes unchecked, and there's no per-table override. If you only need one escape
hatch, loosen a single field with v.any() instead.
See Schema & tables for the full error-message shape and the guarantee this rests on: the same validator, rehydrated from JSON.
Schema builder
import { defineSchema, defineTable, v } from "@helipod/values";
export default defineSchema({
conversations: defineTable({
title: v.string(),
}),
messages: defineTable({
conversationId: v.id("conversations"),
author: v.string(),
body: v.string(),
})
.index("by_conversation", ["conversationId"])
.shardKey("conversationId"),
});Prop
Type
Every table gets a built-in by_creation index automatically, even one with no .index(...) calls
of its own. It orders rows by creation order, so ctx.db.query(table, "by_creation").collect()
always works with zero schema declaration.
System fields
Every document, on every table, automatically carries two fields you never set yourself:
Prop
Type
Every index key also ends implicitly in _creationTime then _id. That's what keeps index keys
unique and pagination cursors stable, even under duplicate field values.
System tables
_storage is the one built-in app-namespace system table. Every project gets it automatically
(it's file storage's metadata table), whether or not the app uses ctx.storage. Its document shape:
{
status: v.union(v.literal("pending"), v.literal("ready")),
key: v.string(),
size: v.union(v.number(), v.null()),
contentType: v.union(v.string(), v.null()),
sha256: v.union(v.string(), v.null()),
visibility: v.union(v.literal("private"), v.literal("public")),
expiresAt: v.union(v.number(), v.null()),
}Id<"_storage">/v.id("_storage") type-check in your own schema.ts exactly like a reference to
any table you defined. Codegen merges system tables into DataModel ahead of your own tables. See
File storage for the ctx.storage API this table backs.
Component-composed tables, like scheduler's jobs/crons or auth's users/sessions, are
namespaced (component/table, not the app root). They aren't part of your own schema.ts at
all. See each component's own reference page under Components.
helipod.config.ts
helipod.config.ts is an optional file that composes opt-in components into your project. It
sits alongside helipod/, as a sibling of that directory, not inside it.
If the file is absent entirely, you get no components: byte-identical to an explicit
{ components: [] }. helipod dev, serve, build, and deploy all read the same file
(helipod.config.ts or .js) from the project root.
import { defineConfig } from "@helipod/component";
import { defineScheduler } from "@helipod/scheduler";
import { defineWorkflow } from "@helipod/workflow";
import { defineAuth } from "@helipod/auth";
import { defineAuthz } from "@helipod/authz";
import { defineTriggers } from "@helipod/triggers";
import { defineNotifications, resendEmail } from "@helipod/notifications";
import crons from "./helipod/crons";
import { policies } from "./helipod/policies";
export default defineConfig({
components: [
defineScheduler({ crons }),
defineWorkflow({ workflows: {} }),
defineAuth(),
defineAuthz({ policies }), // requires: ["auth"] - auth must also be composed
defineTriggers({ messages: { handler: "logChange" } }),
defineNotifications({
channels: {
email: { provider: resendEmail({ apiKey: process.env.RESEND_API_KEY! }), from: "noreply@example.com" },
},
}),
],
});HelipodConfig is three fields, one required and two optional:
export interface HelipodConfig {
components: ComponentDefinition[];
deploy?: DeployConfig;
functionsDir?: string;
}
export function defineConfig(config: HelipodConfig): HelipodConfig; // identity, just gives the file a typed default exportfunctionsDir overrides where your schema and functions live, relative to the project root
(default "helipod"). An explicit --dir flag on any command wins over this value, which in turn
wins over the default. See CLI for the full --dir precedence.
Each components entry is a component's own define*() call: defineScheduler(),
defineWorkflow(), defineAuth(), defineAuthz(), defineTriggers(), defineNotifications().
Every one of them returns a plain ComponentDefinition.
The deploy block
deploy holds helipod deploy's target configuration, so a deploy needs no flags beyond
helipod deploy itself:
export interface DeployConfig {
/** Used when --target is omitted. Effective default is "serve". */
defaultTarget?: string;
/** Keyed by target name (the --target value). */
targets?: Record<string, TargetConfig>;
}
export interface TargetConfig {
/** "serve" | "cloudflare" | "docker" | "railway" | "fly" | "aws": selects the deploy adapter. */
provider: string;
/** Per-environment overrides, merged over the shared settings; --env selects one. */
environments?: Record<string, Record<string, unknown>>;
/** Any other field is a provider-specific setting (url, adminKey, serviceArn, ...). */
[k: string]: unknown;
}The env() helper (also from @helipod/component) reads an environment variable at config-load
time, for wiring secrets into target settings without hardcoding them. It never throws: unset with
no fallback resolves to "", and the target's own preflight is what fails fast on a genuinely
missing credential, so the config still loads when no .env is present.
import { defineConfig, env } from "@helipod/component";
export default defineConfig({
components: [],
deploy: {
defaultTarget: "serve",
targets: {
serve: {
provider: "serve",
url: env("HELIPOD_DEPLOY_URL", "https://my-deployment.example.com"),
adminKey: env("HELIPOD_ADMIN_KEY"),
},
cloudflare: { provider: "cloudflare" },
},
},
});See helipod deploy for the flags this block feeds and
Deploy & build for each target's own settings.
See each component's own page under Components for its define*()
options: defineAuth(options?), defineAuthz(config), defineWorkflow(opts),
defineTriggers(opts), defineNotifications(opts), defineScheduler(opts?).
Composition rules:
- Declaration order doesn't have to be dependency order. A component declares its dependencies
via
requires: string[](defineAuthzis built withrequires: ["auth"], for example).composeComponentsruns a stable topological sort over the whole list before booting, so every component ends up after everything itrequires, regardless of array order. Omitting a required component, composingdefineAuthz()withoutdefineAuth(), fails fast at compose time withcomponent "authz" requires "auth", which is not enabled. Arequirescycle is also rejected. - The composed set is fixed at boot. Which components are active, and their table numbers, is
decided once, at
helipod dev/serve/buildstartup. Adding or removing a component fromhelipod.config.tsneeds a restart. It's not somethinghelipod deploy's live hot-swap can do: deploy only hot-swaps functions and additive schema against the already-booted component set. See Deploy and build. - Each component's config is a plain value passed to its own
define*(), not a Helipod-owned field. Provider API keys, OAuth client credentials, and similar secrets are read fromprocess.envby your ownhelipod.config.ts, not by the engine. See Component config from environment variables below.
crons.ts
crons.ts is a conventional file where an app declares recurring or scheduled work. It's composed
into the project via defineScheduler({ crons }), shown above. cronJobs() itself and its
registry come from @helipod/scheduler, re-exported through codegen's _generated/server, so
the file needs only one import:
import { cronJobs } from "./_generated/server";
import { internal } from "./_generated/api";
const crons = cronJobs();
crons.interval("cleanup", { minutes: 5 }, internal.maintenance.purge, {});
crons.cron("nightly", "0 3 * * *", internal.reports.build, {}, { tz: "America/New_York" });
crons.daily("digest", { hourUTC: 8, minuteUTC: 0 }, internal.email.digest, {});
crons.hourly("rollup", { minuteUTC: 15 }, internal.stats.rollup, {});
crons.weekly("report", { dayOfWeek: "monday", hourUTC: 9, minuteUTC: 0 }, internal.reports.weekly, {});
crons.monthly("invoice", { day: 1, hourUTC: 0, minuteUTC: 0 }, internal.billing.invoice, {});
export default crons;Then, in helipod.config.ts: import crons from "./helipod/crons"; defineScheduler({ crons }).
Prop
Type
Every method's fnRef is an internal.*/api.* function reference, and args is that function's
JSON args object, the same shape ctx.scheduler.runAfter/runAt take. name must be unique
across the whole registry: registering the same name twice throws at load time.
CronOpts (.interval/.cron only) is { tz?: string; catchUp?: "skip" | "fireOnce" | "fireAll" }.
.daily/.hourly/.weekly/.monthly only accept { catchUp? }, no tz: their fields are
already UTC-named, so a timezone override would be ambiguous about which field it applies to.
catchUp governs what happens to fires missed while the process was down. "skip" (the default)
drops them. "fireOnce" runs the single most recent missed occurrence. "fireAll" replays every
missed occurrence in order.
Editing a cron's cadence, target function, or args in helipod.config.ts and redeploying
re-anchors its schedule from the moment of the change. Editing only catchUp doesn't disturb the
running cadence. Removing an entry from crons.ts cancels its pending cadence job on the next boot
or reconcile and drops its row. Already-enqueued work jobs it spawned are left alone.
http.ts
http.ts is a conventional file that default-exports an httpRouter() of routes, each one backed
by an httpAction:
import { httpRouter } from "./_generated/server";
import { httpAction } from "./_generated/server";
import { internal } from "./_generated/api";
const http = httpRouter();
http.route({
path: "/webhooks/stripe",
method: "POST",
handler: httpAction(async (ctx, request) => {
const event = await request.json();
await ctx.runMutation(internal.billing.recordEvent, { event });
return new Response(null, { status: 200 });
}),
});
http.route({
pathPrefix: "/files/",
method: "GET",
handler: httpAction(async (ctx, request) => { /* ... */ return new Response("ok"); }),
});
export default http;Prop
Type
Matching. The method must always match. An exact path match wins over any pathPrefix, and
among pathPrefix matches, the longest wins.
/api/* and any path whose first segment starts with _ (/_*) are reserved for the engine.
route() throws immediately if you try to register one, before a malformed http.ts could ever
shadow a built-in endpoint like the storage-serving routes or /_admin/deploy.
An httpAction handler follows the same non-deterministic-context rules as a plain action:
ctx.runQuery/ctx.runMutation/ctx.runAction, native fetch/Date/timers, no ctx.db. The one
difference is its I/O, a raw Request in and Response out, instead of JSON args in and a JSON
value out. See Actions for the shared execution model.
There's no automatic CORS, no named path params (:id-style segments), and no per-route
middleware. These are non-goals, not deferred.
Environment variables
Every variable below is HELIPOD_-prefixed and read by the CLI (helipod dev/serve/deploy).
Where a CLI flag exists for the same thing, the flag always wins over its environment-variable
equivalent.
Core
| Variable | Read by | Meaning |
|---|---|---|
HELIPOD_ADMIN_KEY | serve, dev (optional) | Bearer token for the dashboard and every admin/deploy endpoint. Required for serve: it fails fast (exit 1) if unset or blank. dev generates an ephemeral one per run if unset (printed to stdout on startup), or warns and does the same if the variable is set but blank. |
PORT | serve | HTTP/WebSocket port, default 3000. Not HELIPOD_-prefixed: a bare PORT is the conventional PaaS variable. dev takes --port instead, with no env fallback. |
HELIPOD_DATA_DIR | serve | Directory for the SQLite data file, written to <dir>/db.sqlite. dev takes --data <path> instead (default .helipod/data.db), with no env fallback. |
HELIPOD_DATABASE_URL | dev, serve, compiled build binaries, fleet reshard | A Postgres connection string. Unset → SQLite. --database-url wins over this. See Postgres. |
HELIPOD_PG_STREAM | any process on the Postgres backend | 0 or false disables the Postgres adapter's streaming index scans (server-side cursors for paginated/limited reads); the adapter falls back to buffered reads with identical results. Any other value, including unset, leaves streaming on. See Postgres. |
HELIPOD_DASHBOARD | serve | Set to off to disable the dashboard (equivalent to --no-dashboard). Any other value (including unset) leaves it on. |
HELIPOD_ALLOW_DEPLOY | serve | Set to 1 to enable POST /_admin/deploy, helipod deploy's hot-swap target (equivalent to --allow-deploy). Off by default. |
HELIPOD_WEB_DIR | serve | A static web UI directory to serve at the site root, alongside the sync WebSocket and API, so a same-origin client SPA needs no separate backend-URL config. Equivalent to --web. |
HELIPOD_DEPLOY_URL | deploy | Default target URL for helipod deploy when --url is omitted. |
File storage
| Variable | Meaning |
|---|---|
HELIPOD_STORAGE_BUCKET | Selects the S3-compatible backend. Any bucket name set here switches from the filesystem default to S3. Unset → files live on disk under <data dir>/storage, zero configuration. |
HELIPOD_STORAGE_ENDPOINT | S3-compatible endpoint URL (MinIO, Cloudflare R2, etc.). Only meaningful with HELIPOD_STORAGE_BUCKET set. serve/dev fail fast if endpoint/region/public-url are set without a bucket, rather than silently starting on the filesystem backend. |
HELIPOD_STORAGE_REGION | S3 region. |
HELIPOD_STORAGE_PUBLIC_URL | Public base URL used for "public"-visibility files' getUrl() (a CDN/bucket public endpoint) instead of the private capability-token URL. |
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY | Standard AWS-style credentials for the S3 backend. Deliberately not treated as "S3 intent" on their own: they're commonly present in an environment for unrelated reasons, so only HELIPOD_STORAGE_BUCKET selects the backend. |
--storage-bucket/--storage-endpoint flags (both dev and serve) win over their env
equivalents. See File storage.
Fleet (Tier 2: Postgres multi-writer)
Opt into a symmetric fleet of serve processes sharing one Postgres database via --fleet (or
HELIPOD_FLEET=1/true). Requires the enterprise @helipod/fleet package. See
Scaling.
| Variable | Meaning |
|---|---|
HELIPOD_FLEET | 1/true enables fleet mode (equivalent to --fleet). Requires HELIPOD_DATABASE_URL/--database-url (Postgres) and an advertise URL. Boot fails fast with an actionable message if either is missing, or if @helipod/fleet isn't installed. |
HELIPOD_ADVERTISE_URL | The URL other fleet nodes reach this node at (recorded on the write lease when this node is the writer; sync nodes forward writes/proxy httpActions here). Equivalent to --advertise-url. Required in fleet mode. |
HELIPOD_FLEET_SHARDS | Number of shards the fleet runs (default 8 in @helipod/fleet). Persisted once at first boot and immutable after: a later mismatched value fails boot fast. Also read (without fleet) as the lane count for a single-node --object-store --shards N writer. |
HELIPOD_FLEET_LEASE_TTL_MS | The write lease's TTL in milliseconds, the one knob the whole failover clock (heartbeat and acquire cadence) scales from. Unset → fleet default (15000ms). Ops/test tuning knob, no CLI flag. |
HELIPOD_FLEET_MULTI_WRITER | 1/true/yes opts into multi-writer scale-out (sharded writes across more than one writer node against the same Postgres database). Off by default. |
HELIPOD_GROUP_COMMIT | 1/true/yes forces group commit on (batching concurrent commits through a staged two-buffer committer, one fsync amortized across them); any other explicit value forces it off. Unset, the default is topology-dependent: on for single-node (non-fleet) Postgres, off for SQLite, and off on a fleet node. |
Object storage (Tier 3: bucket-backed, no database)
Run the engine directly against an object-storage bucket (or local directory) as its substrate,
instead of SQLite/Postgres, via --object-store <url> (or HELIPOD_OBJECT_STORE). Mutually
exclusive with --fleet. See Scaling.
| Variable | Meaning |
|---|---|
HELIPOD_OBJECT_STORE | The bucket/dir URL (s3://…, s3+http(s)://…, file://…, or a bare path). Equivalent to --object-store. |
HELIPOD_OBJECTSTORE_GC_MS | The object-store writer's garbage-collection sweep cadence, ms. Unset → ~60s default. Env-only (no flag), mirroring HELIPOD_FLEET_LEASE_TTL_MS's ops-tuning-knob shape. |
HELIPOD_REPLICA | 1/true/yes boots this node as a read-only replica of the object store's shard (materialize and tail, no write lease, every mutation rejected) instead of a writer. Requires --object-store/HELIPOD_OBJECT_STORE also be set. Equivalent to --replica. |
HELIPOD_WRITER_URL | Only meaningful on a --replica boot: the writer node's URL. When set, every mutation/action on the replica forwards there instead of being rejected outright. Equivalent to --writer-url. |
--shards N (no bare env flag beyond the shared HELIPOD_FLEET_SHARDS above) sizes the number
of object-storage lanes a writer owns when N > 1. It's invalid combined with --replica
(single-shard by definition) or --fleet (its own shard resolution).
The wake seam (serverless / stop-between-requests hosts)
For a host that stops the process entirely between requests, so a plain setTimeout can never
fire and every scheduler/trigger/reaper driver would go dead, notably Cloudflare Containers.
| Variable | Meaning |
|---|---|
HELIPOD_WAKE_URL | An HTTP endpoint serve POSTs the next wake's absolute timestamp to (on Cloudflare, the container's Outbound-Worker hostname, which turns it into a Durable Object alarm). Equivalent to --wake-url. Unset (every non-serverless deployment) → no wake host, plain setTimeout, unchanged behavior. |
HELIPOD_BACKSTOP_MIN_MS | A floor applied to every driver's backstop poll cadence (backstopMs = max(driverDefault, this)). Unset → identity (each driver's own default, 30s/60s). Set where every wake costs a cold start, so a driver's own 30s backstop doesn't mean "boot a container every 30 seconds forever." Equivalent to --backstop-min-ms. |
Cloudflare DO-native host
| Variable | Meaning |
|---|---|
HELIPOD_DO_LOCATION_HINT | Pins the single-shard Cloudflare Durable Object host's ONE Durable Object to a home region: one of the 11 locationHint codes (wnam, enam, sam, weur, eeur, apac, apac-ne, apac-se, oc, afr, me). Unset → Cloudflare places the DO near the first requester (its normal default). Only the first get() for a given DO ever honors a hint. A DO is single-homed for life once created. |
(The durable_objects binding name itself, HELIPOD_DO in wrangler.jsonc for example, is a
Wrangler binding identifier, not a process.env variable the engine reads. See
Cloudflare for the full Workers deployment shape.)
Component config from environment variables
Helipod itself owns no environment variables for component secrets. Provider API keys, OAuth
client credentials, and similar values are plain fields on each component's define*() options,
which your own helipod.config.ts populates from process.env however you like:
import { defineConfig } from "@helipod/component";
import { defineAuth } from "@helipod/auth";
import { googleProvider, githubProvider } from "@helipod/auth";
import { defineNotifications, resendEmail, twilioSms } from "@helipod/notifications";
export default defineConfig({
components: [
defineAuth({
oauth: {
providers: [
googleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
githubProvider({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}),
],
},
}),
defineNotifications({
channels: {
email: { provider: resendEmail({ apiKey: process.env.RESEND_API_KEY! }), from: "noreply@example.com" },
sms: {
provider: twilioSms({
accountSid: process.env.TWILIO_ACCOUNT_SID!,
authToken: process.env.TWILIO_AUTH_TOKEN!,
from: process.env.TWILIO_FROM_NUMBER!,
}),
},
},
}),
],
});These variable names (GOOGLE_CLIENT_ID, RESEND_API_KEY, TWILIO_ACCOUNT_SID, and so on) are a
convention you choose, not a contract the engine enforces or reads itself. Pick whatever names fit
your deployment's secret-management story: Docker Compose env_file, a platform's secrets manager,
or a plain .env loaded before helipod serve starts. See Auth,
Notifications, and
Authorization for each component's full provider/config surface.
See also
- Schema & tables: the concepts and guarantees these validators and the schema builder serve, in prose.
- Queries: how an index declared here actually shapes a read set.
- Components overview: every component's own
define*()reference. - CLI: every command and flag that reads
helipod.config.tsand the environment variables above. - Scaling and Cloudflare: the fleet, object-storage, and DO-native deployment stories the multi-node variables belong to.
- Self-hosting and Postgres: core deployment configuration walkthroughs.