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 deploypushes localhelipod/changes to an already-runningserveand hot-swaps them in live, with no restart.helipod buildcompiles 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 differenthelipod deploymode 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- The six deploy targets
- Deploying from CI (GitHub Actions)
helipod build: one self-contained binary- Choosing between
deploy,build, and Docker, the comparison table, if you're not sure which of the three fits
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 helipodWithout 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| Flag | Env fallback | Default | Description |
|---|---|---|---|
--url <url> | HELIPOD_DEPLOY_URL | (required, unless set in the config's deploy block) | The target deployment's base URL |
--dir <path> | none | helipod | The local helipod/ directory to push |
--check | none | off | Fail if committed helipod/_generated/ has drifted from a fresh codegen run |
--dry-run | none | off | Validate (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.examplerev 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
| Condition | HTTP status | CLI behavior |
|---|---|---|
--allow-deploy not set on the target | 404 | ✗ deploy failed: deploy not enabled on target (start serve with --allow-deploy), exit 1 |
| Wrong or missing admin key | 401 | ✗ 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 change | 409 | ✗ deploy failed: <reason>, exit 1 (old version stays fully live) |
| Success | 200 | the 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
stringto a union that includesstring, oranytostring. The diff can't verify a "widening" is actually sound against every existing row (ananycolumn may hold values that aren't validstrings), 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
# 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
helipod deploy runs three steps locally, then hands off to one remote step:
- Transpiles every
.tsfile underhelipod/via esbuild'stransform(notbundle). This strips TypeScript types but leaves import specifiers completely untouched. A bare import likeimport { v } from "@helipod/values"passes through as-is, and it resolves against the target server's ownnode_modules, not anything on your machine. Relative imports resolve within the pushed file tree itself. This is why a deploy target needs the same@helipod/*packages installed as your app does (the Docker image already provides this, see Self-hosting). It does not refreshhelipod/_generated/; that'shelipod codegen's job, run it yourself before deploying. - Probes
GET /_admin/deploy/modulesfor the server's current per-module content hashes (lowercase-hex SHA-256 per pushed path). An older server, or one with deploy disabled, doesn't answer; the CLI just falls back to a full-tree push. - Posts a delta to
/_admin/deploy, withAuthorization: Bearer $HELIPOD_ADMIN_KEY: modules whose hash differs go aschanged({path, code}), matching ones asunchanged({path, sha256}), so an unchanged module never crosses the wire twice. Without a probe base it posts the full{files: [{path, code}]}tree instead, and if the server reports the delta's base is stale (another deploy landed in between), the CLI retries once with the full tree. - The server validates and applies it (see the next section). This is the only step that runs remotely.
On the server, applyDeploy does the following, in this exact order:
- Writes the pushed tree to disk under a per-revision directory (
<deployRoot>/<rev>/functions/), rejecting any path that's absolute or contains a..segment (a path-traversal guard on the payload). - Loads and pushes it through the same
loadFunctionsDir → pushpipeline used at boot, against the deployment's fixed, already-composed component set and its current table numbers. Existing tables, app and component alike, keep the numbers they already have. Only genuinely new tables get fresh ones. - Diffs the schema (see the additive-only rules above). If a table or field change is destructive, the deploy is rejected here, and nothing has been swapped yet.
- Only if both of those succeed, it atomically swaps four things in sequence, with no
awaitbetween them:runtime.setModules(...),runtime.setTableNumbers(...),setRoutes(...)(forhttpActionroutes), and the admin API's live schema (adminApi.setSchema(...)). Because there's noawaitin this sequence, no request can observe a state where only some of these have updated.
A load error or a schema rejection both leave the running deployment completely untouched. The previous version keeps serving every request throughout. That's what "atomic" means here: not that the swap is instantaneous (it is), but that validation is guaranteed to fully complete, with zero mutation of running state, before the first field is swapped.
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).
| Target | What a push does | Runs codegen? | Notes |
|---|---|---|---|
serve (default) | Pushes helipod/ to a running serve --allow-deploy, live hot-swap | No, run helipod codegen yourself | This page, above |
cloudflare | Reconciles wrangler.jsonc bindings, then wrangler deploy | Yes | Cloudflare; needs wrangler installed, CLOUDFLARE_API_TOKEN in CI |
docker | docker compose up -d --build in your project | Yes | Needs Docker installed with the daemon running |
railway | railway up (Railway builds the image itself) | Yes | Needs the railway CLI; RAILWAY_TOKEN in CI; optional service/environment settings |
fly | fly deploy (Fly builds the image itself) | Yes | Needs flyctl; FLY_API_TOKEN in CI; optional app/region settings |
aws | aws apprunner start-deployment against an existing App Runner service | Yes | Needs 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.
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 deployderives its interactive mode fromstdin.isTTYand theCIenvironment variable (GitHub Actions setsCI=trueon every runner), so a missing credential fails fast with an actionable message and a non-zero exit instead of prompting. --checkgates codegen drift. It exits 1 ifhelipod/_generated/doesn't match a fresh codegen run, which matters doubly for theservetarget since a serve deploy never regenerates it.--dry-runadditionally runs the target'spreflightandpackagesteps 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.
| Component | Bundled into the binary? |
|---|---|
| Bun runtime | Yes |
| Helipod engine (query engine, transactor, sync protocol) | Yes |
SQLite (bun:sqlite) | Yes, built into Bun |
Your helipod/ functions and schema | Yes, embedded via a generated static-import entrypoint |
Composed components (@helipod/scheduler, @helipod/workflow, …) | Yes, whatever helipod.config.ts composes |
| Dashboard | Optional, included by default (--no-dashboard to exclude) |
| SQLite database file | No, 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 buildOutput: ./helipod-server (the default --outfile).
helipod build --outfile ./dist/my-backend| Flag | Default | Description |
|---|---|---|
--dir <path> | helipod | App's helipod/ directory to build |
--outfile <path> | ./helipod-server | Output path |
--target <platform> | current platform | One of linux-x64, linux-arm64, darwin-x64, darwin-arm64, windows-x64 |
--no-dashboard | dashboard included | Exclude the dashboard UI from the binary |
--verbose | off | Stream 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 ./dataHELIPOD_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.
| Flag | Env fallback | Default | Description |
|---|---|---|---|
--port | PORT | 3000 | Port to listen on |
--hostname | none | 0.0.0.0 | Address to bind to |
--data-dir | none | ./data | Directory holding the SQLite database (db.sqlite) |
--database-url | HELIPOD_DATABASE_URL | SQLite | Postgres 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/workflowtohelipod.config.tsrequires a rebuild, same as it requires a restart underserve.
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-urlto point it at Postgres, in which case the usual single-writer advisory-lock rules from Postgres apply instead). - No
helipod initscaffolder. You bring your ownhelipod/directory.helipod builddoesn't generate one.
Worked example: fixture app with a component, then cross-compiled
This mirrors the shipped end-to-end test:
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
- Refreshes codegen:
loadFunctionsDir → push → writeGenerated, same asdeploy, so the app's ownimport "./_generated/server"resolves once bundled. - Generates a static-import entrypoint.
bun build --compileonly bundles code reached via staticimportstatements. There's no filesystem to dynamically scan inside a compiled binary, sohelipod buildwrites a throwawayentry.tsthat statically imports everyhelipod/module by absolute path, the schema,helipod.config.ts(if present), and, if the dashboard is included, every dashboard asset viaimport x with { type: "file" }. It then reconstructs the exact{schema, modules}shapeloadFunctionsDirreturns at runtime and hands it torunBinaryServer(the same boot corehelipod serveuses, just fed statically imported modules instead of a dynamic directory scan). - Shells out to
bun build --compile --minify(vianode:child_process.spawnSync, so this works whether the CLI itself is invoked under Bun or Node; only the compile step itself needs Bun onPATH). - Cleans up the generated entrypoint.
Why no --bytecode: the generated entrypoint boots with a top-level await (to fully start the
runtime before serving), and bun build --compile --bytecode rejects top-level await. Cold-start speed
is negligible for a long-running self-hosted server binary, so helipod build omits --bytecode
rather than restructure the entrypoint around it.
Cross-compilation
helipod build --target linux-x64 --outfile ./dist/server-linuxhelipod build --target linux-arm64 --outfile ./dist/server-linux-arm64helipod build --target darwin-arm64 --outfile ./dist/server-macoshelipod build --target darwin-x64 --outfile ./dist/server-macos-x64helipod build --target windows-x64 --outfile ./dist/server-windows.exeA 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/helipodAn alternative to the bind-mounted image in Self-hosting: cross-compile
for Linux, then copy just the binary into a distroless base with no Bun, no node_modules, and no
source at all.
FROM gcr.io/distroless/base-debian12
COPY dist/helipod-server /helipod-server
EXPOSE 3000
VOLUME /data
ENTRYPOINT ["/helipod-server", "--hostname", "0.0.0.0", "--data-dir", "/data"]helipod build --target linux-x64 --outfile ./dist/helipod-server
docker build -f Dockerfile.binary -t my-app-binary .
docker run -p 3000:3000 -e HELIPOD_ADMIN_KEY=your-strong-secret -v helipod-data:/data my-app-binaryTauri or Electrobun can spawn the binary as a child process and parse its {"ready":...} stdout line
to learn the URL to connect to. That gives you process isolation from the app's own runtime.
[Unit]
Description=Helipod Server
After=network.target
[Service]
Type=simple
Environment=HELIPOD_ADMIN_KEY=your-strong-secret
ExecStart=/opt/helipod/helipod-server --data-dir /var/lib/helipod --hostname 0.0.0.0
Restart=on-failure
User=helipod
[Install]
WantedBy=multi-user.targetChoosing between deploy, build, and Docker self-hosting
helipod deploy | helipod build | Docker (bind-mount) | |
|---|---|---|---|
| Restart needed to ship a change? | No, live hot-swap | Yes, rebuild and redeploy the binary | Yes, restart the container |
| Schema changes | Additive only, validated, atomic | Whatever 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 changes | Requires a restart | Requires a rebuild | Requires a restart |
| Deployment artifact | None, pushes to an existing process | One native executable | Docker image plus bind-mounted helipod/ |
| Runtime dependency | serve already running | Bun/Node only to build; the binary itself needs nothing | Docker |
| Postgres | Whatever the target serve was started with | --database-url flag, same as serve | Same 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.
Related
- Self-hosting with Docker: the
servedeploymenthelipod deploypushes onto, and the bind-mounted-image alternative to the standalone binary. - Postgres:
--database-url/HELIPOD_DATABASE_URL, accepted by bothserveand the compiled binary. - Cloudflare:
helipod deploy --target cloudflare, and the general--target/--envprovisioning model. - Local dev:
helipod dev, the hot-reload loop this page'sdeployandbuildboth build on.