helipod
Reference

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

helipod/schema.ts
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

helipod/schema.ts
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.

helipod.config.ts
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 export

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

helipod.config.ts
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[] (defineAuthz is built with requires: ["auth"], for example). composeComponents runs a stable topological sort over the whole list before booting, so every component ends up after everything it requires, regardless of array order. Omitting a required component, composing defineAuthz() without defineAuth(), fails fast at compose time with component "authz" requires "auth", which is not enabled. A requires cycle 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/build startup. Adding or removing a component from helipod.config.ts needs a restart. It's not something helipod 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 from process.env by your own helipod.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:

helipod/crons.ts
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:

helipod/http.ts
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

VariableRead byMeaning
HELIPOD_ADMIN_KEYserve, 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.
PORTserveHTTP/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_DIRserveDirectory 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_URLdev, serve, compiled build binaries, fleet reshardA Postgres connection string. Unset → SQLite. --database-url wins over this. See Postgres.
HELIPOD_PG_STREAMany process on the Postgres backend0 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_DASHBOARDserveSet to off to disable the dashboard (equivalent to --no-dashboard). Any other value (including unset) leaves it on.
HELIPOD_ALLOW_DEPLOYserveSet to 1 to enable POST /_admin/deploy, helipod deploy's hot-swap target (equivalent to --allow-deploy). Off by default.
HELIPOD_WEB_DIRserveA 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_URLdeployDefault target URL for helipod deploy when --url is omitted.

File storage

VariableMeaning
HELIPOD_STORAGE_BUCKETSelects 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_ENDPOINTS3-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_REGIONS3 region.
HELIPOD_STORAGE_PUBLIC_URLPublic 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_KEYStandard 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.

VariableMeaning
HELIPOD_FLEET1/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_URLThe 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_SHARDSNumber 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_MSThe 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_WRITER1/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_COMMIT1/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.

VariableMeaning
HELIPOD_OBJECT_STOREThe bucket/dir URL (s3://…, s3+http(s)://…, file://…, or a bare path). Equivalent to --object-store.
HELIPOD_OBJECTSTORE_GC_MSThe 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_REPLICA1/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_URLOnly 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.

VariableMeaning
HELIPOD_WAKE_URLAn 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_MSA 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

VariableMeaning
HELIPOD_DO_LOCATION_HINTPins 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:

helipod.config.ts
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.ts and 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.

On this page