helipod
Deploy & Operate

Deploy and build

Live hot-swap onto a running server with helipod deploy, or compile a self-contained binary with helipod build.

You've got a helipod serve deployment running (see Self-hosting). Now you want to ship a code change to it without rebuilding the container or restarting the process.

There are two ways to do that, plus a third path if you'd rather skip containers entirely:

  • helipod deploy pushes local helipod/ changes to an already-running serve and hot-swaps them in live, with no restart.
  • helipod build compiles your whole app into one self-contained executable, for when you'd rather ship a binary than a Docker image.
  • The provisioning targets (cloudflare, docker, railway, fly, aws), a different helipod deploy mode entirely. They provision infrastructure, they don't hot-swap.

This page covers all three. If you already know what you're looking for, jump straight there:

helipod deploy: live hot-swap onto a running server

helipod deploy transpiles your local helipod/ functions and additive schema changes, then pushes them to a running helipod serve deployment. The server validates everything and applies it atomically: it keeps serving requests the whole time, and a rejected deploy never leaves it half-updated.

Enable it on the server: --allow-deploy

A running serve doesn't accept deploys by default. Start it with --allow-deploy, or set HELIPOD_ALLOW_DEPLOY=1, to opt in:

helipod serve --dir helipod --allow-deploy
# or, equivalently:
HELIPOD_ALLOW_DEPLOY=1 helipod serve --dir helipod

Without this flag, POST /_admin/deploy isn't registered at all. The request falls through to the generic admin-router 404, indistinguishable from a nonexistent endpoint.

Why this isn't on by default

HELIPOD_ADMIN_KEY already grants full read and write access to your data (the dashboard and helipod deploy both authenticate with it). Without a separate opt-in, a leaked admin key would mean remote code execution: every deploy replaces the functions that run inside the transaction. Requiring --allow-deploy keeps "read/write my data" and "replace my running code" as two distinct, deliberately opted-into capabilities, even though today they share one key.

Push a deploy

From your app's project root (where helipod/ lives):

HELIPOD_ADMIN_KEY=your-strong-secret helipod deploy --url https://myapp.example
FlagEnv fallbackDefaultDescription
--url <url>HELIPOD_DEPLOY_URL(required, unless set in the config's deploy block)The target deployment's base URL
--dir <path>nonehelipodThe local helipod/ directory to push
--checknoneoffFail if committed helipod/_generated/ has drifted from a fresh codegen run
--dry-runnoneoffValidate (preflight + package) without pushing

HELIPOD_ADMIN_KEY is read from the environment (there's no --admin-key flag), and it's required. helipod deploy refuses to run without it. The general --target/--env flags also apply here (this command form is the default --target serve --env production); see the CLI reference.

Run codegen yourself before a serve-target deploy

The serve target pushes your tree exactly as it sits on disk. It does not regenerate helipod/_generated/. If your schema or functions changed, run helipod codegen (and commit the result) before deploying. --check verifies exactly this and fails fast on drift.

On success, verbatim:

✓ deployed via serve (production) — rev 4b3187d88b93 (7 functions, 2 changed)
  https://myapp.example

rev is a content hash of the pushed file tree (the first 12 hex characters of its SHA-256), not a sequence number. Deploying the exact same tree twice produces the same rev. The 2 changed part appears on a delta push (see what a deploy actually does below); a full push prints just (<n> functions). The new functions are callable immediately, with no restart, and existing WebSocket subscriptions keep working. They reactively pick up writes made through the newly deployed code, exactly as if the change had been made via helipod dev's hot reload.

Responses and status codes

ConditionHTTP statusCLI behavior
--allow-deploy not set on the target404✗ deploy failed: deploy not enabled on target (start serve with --allow-deploy), exit 1
Wrong or missing admin key401✗ deploy failed: unauthorized, exit 1 (checked before any file is written)
Malformed payload (bad JSON, unsafe path)400✗ deploy failed: <reason>, exit 1
Destructive schema change409✗ deploy failed: <reason>, exit 1 (old version stays fully live)
Success200the two-line ✓ deployed via serve … output shown above, exit 0

Additive-only schema: destructive changes are rejected, not migrated

There are no data migrations in Helipod today. diffSchema compares your pushed schema against the server's live one, field by field and table by table.

Allowed (accepted, swap proceeds):

  • Adding a brand-new table.
  • Adding a new optional field to an existing table.

Rejected (409, server stays on the previous version):

  • Removing or renaming a table.
  • Changing a table's internal table number. This shouldn't happen under normal use: table numbers are stable across a deploy specifically so this check never fires on a legitimate change.
  • Removing a field from an existing table.
  • Changing a field's type, including what looks like a safe widening, like string to a union that includes string, or any to string. The diff can't verify a "widening" is actually sound against every existing row (an any column may hold values that aren't valid strings), so v1 takes the conservative position: any field-type change is rejected. Over-rejecting only fails a deploy. It can never corrupt data, which is the property this gate exists to protect.
  • Turning an existing optional field required.
  • Adding a new required field to an existing table. Existing rows don't have it, so make it optional instead, and backfill via a mutation if you need every row populated.

Any of these come back as deploy failed: <reason> describing exactly which table and field failed and why:

✗ deploy failed: field "notes.priority" changed type string→number (destructive)

The component set is fixed at boot

Components composed via helipod.config.ts (@helipod/scheduler, @helipod/workflow, and any others) are whatever was declared when serve booted. helipod deploy can push new functions and additive schema against that fixed component set, but it can't add or remove a component on a live server. Changing the component list in helipod.config.ts requires restarting the process, or redeploying the container.

Functions must be top-level under helipod/

The function loader (used by dev, serve, and deploy alike) is top-level only. It does not recurse into subdirectories. Keep your query, mutation, and action modules directly under helipod/*.ts (for example helipod/items.ts), not nested under a subfolder like helipod/lib/items.ts. Nested modules are silently not loaded, in a deploy exactly as in local dev.

Worked example

terminal
# 1. Start a deploy-enabled server (once)
HELIPOD_ADMIN_KEY=prod-secret helipod serve --dir helipod --allow-deploy &

# 2. Make a change locally: add a new query, add an optional field to schema.ts
#    (edit helipod/notes.ts, helipod/schema.ts)

# 3. Push it live (run `helipod codegen` first if schema/functions changed)
HELIPOD_ADMIN_KEY=prod-secret helipod deploy --url http://localhost:3000
# ✓ deployed via serve (production) — rev 4b3187d88b93 (8 functions, 2 changed)
#   http://localhost:3000

# 4. The new function is callable immediately, no restart:
curl -X POST http://localhost:3000/api/run \
  -H 'content-type: application/json' \
  -d '{"path":"notes:add","args":{"box":"b1","text":"hello"}}'

Any WebSocket client subscribed to a query touching the notes table before step 3 sees the write from step 4 pushed to it reactively. The deploy in between didn't drop or reset the subscription.

Going deeper: what a deploy actually does

The six deploy targets

helipod deploy --url <url> (documented above) is shorthand for one specific target: --target serve, the default. The general form is --target <name> --env <name> (plus --dry-run and --check), and the other five targets provision infrastructure instead of hot-swapping an already-running process. Target settings and credentials can live in the deploy block of helipod.config.ts (see Configuration).

TargetWhat a push doesRuns codegen?Notes
serve (default)Pushes helipod/ to a running serve --allow-deploy, live hot-swapNo, run helipod codegen yourselfThis page, above
cloudflareReconciles wrangler.jsonc bindings, then wrangler deployYesCloudflare; needs wrangler installed, CLOUDFLARE_API_TOKEN in CI
dockerdocker compose up -d --build in your projectYesNeeds Docker installed with the daemon running
railwayrailway up (Railway builds the image itself)YesNeeds the railway CLI; RAILWAY_TOKEN in CI; optional service/environment settings
flyfly deploy (Fly builds the image itself)YesNeeds flyctl; FLY_API_TOKEN in CI; optional app/region settings
awsaws apprunner start-deployment against an existing App Runner serviceYesNeeds the aws CLI and a serviceArn setting; AWS_ACCESS_KEY_ID or AWS_PROFILE in CI

Provider CLIs are never bundled into helipod; each target shells out to the one you already have installed, and fails preflight with a clear install hint if it's missing. Only the serve target uses the additive-schema gate described above. The provisioning targets ship a whole new build/image, so there's nothing to gate: the platform's own rollout replaces the process.

Deploying from CI (GitHub Actions)

CI is just another caller of the same command: the workflow below runs the identical helipod deploy a human runs locally, with credentials coming from repository secrets.

.github/workflows/deploy.yml
name: deploy
on:
  pull_request:
  push: { branches: [main] }
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: oven-sh/setup-bun@v2
      - run: bun install
      - run: bun run test
      # PRs: verify committed codegen is current; exits before any target preflight, so
      # it needs no secrets. Add --dry-run to also validate target preflight/packaging
      # (the serve target's preflight then wants HELIPOD_DEPLOY_URL + HELIPOD_ADMIN_KEY).
      - if: github.event_name == 'pull_request'
        run: bunx helipod deploy --check
      # main: the real deploy (serve target).
      - if: github.ref == 'refs/heads/main'
        run: bunx helipod deploy
        env:
          HELIPOD_DEPLOY_URL: ${{ secrets.HELIPOD_DEPLOY_URL }}
          HELIPOD_ADMIN_KEY: ${{ secrets.HELIPOD_ADMIN_KEY }}

Three properties make this workflow safe to copy:

  • It never hangs. helipod deploy derives its interactive mode from stdin.isTTY and the CI environment variable (GitHub Actions sets CI=true on every runner), so a missing credential fails fast with an actionable message and a non-zero exit instead of prompting.
  • --check gates codegen drift. It exits 1 if helipod/_generated/ doesn't match a fresh codegen run, which matters doubly for the serve target since a serve deploy never regenerates it. --dry-run additionally runs the target's preflight and package steps without pushing.
  • Exit codes are real. A rejected schema change, a failed --check, or a provider CLI error all exit 1 and fail the job like any other CI step.

For a provisioning target, swap the deploy line and secrets: bunx helipod deploy --target cloudflare with CLOUDFLARE_API_TOKEN (plus a npm i -D wrangler install step), --target railway with RAILWAY_TOKEN, --target fly with FLY_API_TOKEN, and so on per the table above. Tokens always come from CI secrets; the provider CLIs' interactive login flows have no place in a headless runner.

helipod build: one self-contained binary

helipod build compiles your entire app, the Bun runtime, the Helipod engine, bun:sqlite, your helipod/ functions and schema, and any composed components, into a single executable via bun build --compile. At deployment time you need nothing but the binary and a data directory: no bun/node install, no node_modules, no helipod/ source tree.

ComponentBundled into the binary?
Bun runtimeYes
Helipod engine (query engine, transactor, sync protocol)Yes
SQLite (bun:sqlite)Yes, built into Bun
Your helipod/ functions and schemaYes, embedded via a generated static-import entrypoint
Composed components (@helipod/scheduler, @helipod/workflow, …)Yes, whatever helipod.config.ts composes
DashboardOptional, included by default (--no-dashboard to exclude)
SQLite database fileNo, it lives on disk under --data-dir, external to the binary

Check prerequisites

You need a helipod/ directory (schema and functions) and, optionally, a helipod.config.ts next to it: the same layout helipod dev uses. There's no helipod init scaffolder. If your app composes components, install them as ordinary dependencies (bun add @helipod/scheduler @helipod/workflow). helipod build runs its own codegen internally, so there's no separate helipod codegen step to run first.

Build

helipod build

Output: ./helipod-server (the default --outfile).

helipod build --outfile ./dist/my-backend
FlagDefaultDescription
--dir <path>helipodApp's helipod/ directory to build
--outfile <path>./helipod-serverOutput path
--target <platform>current platformOne of linux-x64, linux-arm64, darwin-x64, darwin-arm64, windows-x64
--no-dashboarddashboard includedExclude the dashboard UI from the binary
--verboseoffStream the underlying bun build --compile output instead of suppressing it

On success, helipod build prints the output path and its size:

✓ built ./helipod-server (58MB)

Run it

HELIPOD_ADMIN_KEY=your-strong-secret ./helipod-server --port 3000 --hostname 0.0.0.0 --data-dir ./data

HELIPOD_ADMIN_KEY is required, exactly like helipod serve. The binary fails fast (exit 1) if it isn't set, and it's never auto-generated. This is production-facing, same as serve.

FlagEnv fallbackDefaultDescription
--portPORT3000Port to listen on
--hostnamenone0.0.0.0Address to bind to
--data-dirnone./dataDirectory holding the SQLite database (db.sqlite)
--database-urlHELIPOD_DATABASE_URLSQLitePostgres connection string, same flag serve accepts. Unset uses the embedded SQLite adapter

The binary is otherwise runtime-flag-compatible with helipod serve: the same required-admin-key fail-fast behavior, and graceful shutdown on SIGTERM/SIGINT (stop the listener, close the database, exit 0).

Immediately after the listener is up, the binary writes exactly one JSON line to stdout:

{"ready":true,"port":3000,"url":"http://0.0.0.0:3000"}

This exists for a parent process, like Electron, Tauri, or a supervisor script, to read stdout and know precisely when the server is ready and which port it actually bound (relevant when --port 0 or $PORT picks an ephemeral port). Nothing else is printed to stdout before this line. Treat "first line of stdout" as the contract, not "first line matching some pattern."

No live hot-swap in a compiled binary

Unlike helipod serve --allow-deploy, a compiled binary doesn't expose POST /_admin/deploy. Everything embedded at compile time is fixed for the life of that binary:

  • Functions and schema: shipping a change means rebuilding and redeploying the binary, not helipod deploy.
  • The composed component set: adding @helipod/workflow to helipod.config.ts requires a rebuild, same as it requires a restart under serve.

If you need live hot-swap, run helipod serve --allow-deploy instead (see above). The single binary trades that away for a zero-dependency, single-file deployment artifact.

Other limitations

  • Single instance. SQLite requires exclusive file access, so it's one binary process per data directory (unless you pass --database-url to point it at Postgres, in which case the usual single-writer advisory-lock rules from Postgres apply instead).
  • No helipod init scaffolder. You bring your own helipod/ directory. helipod build doesn't generate one.

Worked example: fixture app with a component, then cross-compiled

This mirrors the shipped end-to-end test:

terminal
helipod build --dir helipod --outfile ./helipod-server --no-dashboard
# ✓ built ./helipod-server (58MB)

HELIPOD_ADMIN_KEY=e2e ./helipod-server --port 3599 --hostname 127.0.0.1 --data-dir ./data
# {"ready":true,"port":3599,"url":"http://127.0.0.1:3599"}

curl -X POST http://127.0.0.1:3599/api/run \
  -H 'content-type: application/json' \
  -d '{"path":"notes:add","args":{"box":"a","text":"compiled"}}'
# {"value":null,"committed":true,...}

# Cross-compile the same app for a Linux server:
helipod build --dir helipod --target linux-x64 --outfile ./dist/server-linux --no-dashboard
# ✓ built ./dist/server-linux (95MB)

Going deeper: how the build works

Cross-compilation

helipod build --target linux-x64 --outfile ./dist/server-linux

A windows-x64 target automatically appends .exe to --outfile if you didn't already include it.

Deployment patterns for the binary

HELIPOD_ADMIN_KEY=your-strong-secret ./helipod-server --hostname 0.0.0.0 --port 8080 --data-dir /var/lib/helipod

Choosing between deploy, build, and Docker self-hosting

helipod deployhelipod buildDocker (bind-mount)
Restart needed to ship a change?No, live hot-swapYes, rebuild and redeploy the binaryYes, restart the container
Schema changesAdditive only, validated, atomicWhatever the new build embeds (no live validation, it's a fresh process)Same as deploy if you also run deploy against it; a plain restart has no gate
Component set changesRequires a restartRequires a rebuildRequires a restart
Deployment artifactNone, pushes to an existing processOne native executableDocker image plus bind-mounted helipod/
Runtime dependencyserve already runningBun/Node only to build; the binary itself needs nothingDocker
PostgresWhatever the target serve was started with--database-url flag, same as serveSame as serve

In practice these aren't mutually exclusive: you can run helipod serve --allow-deploy in a container and use helipod deploy against it for day-to-day shipping, falling back to rebuilding the image only when you need to change the composed component set.

  • Self-hosting with Docker: the serve deployment helipod deploy pushes onto, and the bind-mounted-image alternative to the standalone binary.
  • Postgres: --database-url/HELIPOD_DATABASE_URL, accepted by both serve and the compiled binary.
  • Cloudflare: helipod deploy --target cloudflare, and the general --target/--env provisioning model.
  • Local dev: helipod dev, the hot-reload loop this page's deploy and build both build on.

On this page