helipod
Deploy & Operate

Self-hosting with Docker

helipod serve and docker compose up, the baseline production self-host path.

Think of docker compose up as the one command that starts your whole backend: the sync engine, the HTTP API, any httpAction webhooks, and the dashboard, all in one container.

This is the baseline way to self-host helipod. A generic helipod:latest image runs helipod serve, your app's helipod/ folder is mounted in, and its SQLite database sits on a named volume so data survives restarts.

This page covers serve itself (what it hardens versus dev, and every flag and environment variable it reads), the shipped Docker setup and why its two non-obvious fixes exist, the immutable-image alternative, and what's deliberately left to a reverse proxy.

helipod serve: the production entrypoint

helipod serve is helipod dev minus the file watcher and codegen-write, plus production hardening.

Both share one boot core, bootProject() (packages/cli/src/boot.ts). It loads the project, composes components, opens the store, and builds the EmbeddedRuntime and AdminApi with byte-identical options either way: routes, drivers, boot steps, context providers, table numbers.

Nothing about how your functions run (schedulers, workflows, actions, httpActions, triggers) is dev-only or serve-only. Only the process-level behavior around that shared core differs:

helipod devhelipod serve
CodegenRuns on every change, writes _generated/Never runs, fails fast if _generated/ is missing
Bind addressLoopback (127.0.0.1)0.0.0.0 by default
Admin keyEphemeral, auto-generated, embedded in the dashboard HTMLRequired (HELIPOD_ADMIN_KEY), never embedded
File watchingWatches helipod/ and hot-reloadsNone, restart (or helipod deploy) to pick up changes
ShutdownCtrl-C exits immediatelySIGTERM/SIGINT trigger a graceful drain

Fail-fast checks, before anything binds

serveCommand validates two things up front, before opening the store or binding a port, and exits 1 with a one-line, actionable message on either:

  1. HELIPOD_ADMIN_KEY must be set and non-blank. There is no default and no fallback. A trimmed-empty value is treated as unset.

    ✗ HELIPOD_ADMIN_KEY is required for `serve`: set it to a strong secret.
  2. <dir>/_generated/server.ts must already exist. serve never runs codegen. That step belongs in your build/CI pipeline, not on every production boot. Generate it and commit it before deploying:

    helipod codegen --dir helipod
    ✗ helipod/_generated not found: run `helipod codegen --dir helipod` and commit _generated/ before deploying.

Binding and graceful shutdown

serve binds 0.0.0.0 by default, versus dev's loopback-only bind. It's meant to be reached from outside the container or host, and a reverse proxy or the Docker port mapping is what actually exposes it publicly.

SIGTERM and SIGINT both trigger the same idempotent shutdown sequence. It's safe to receive twice: a second signal while shutting down is a no-op.

  1. Stop the fleet node, if running one (--fleet).
  2. server.close(): stops every registered driver (scheduler, triggers, reapers, wake heartbeats) and closes listening sockets.
  3. Release any object-storage lease (--object-store), best-effort, bounded to 2 seconds so an unreachable bucket can't hang shutdown past a container's grace period.
  4. store.close(): closes the database connection or file handle.
  5. process.exit(0).

This is what makes docker compose down / docker stop (which send SIGTERM, then SIGKILL after a grace period) a clean stop rather than a killed process. The store is closed before exit, not left in a torn state.

What it serves

One process answers everything on one origin: the sync WebSocket, /api/run and /api/action HTTP endpoints, httpAction routes from your app's http.ts, the always-on /api/storage/* file-serving routes, any component-contributed routes (for example @helipod/auth's OAuth callbacks), and, unless disabled, the dashboard SPA at /_dashboard.

The dashboard is served key-less

Unlike dev, which embeds its ephemeral admin key directly into the dashboard's HTML (safe, because that key only ever exists on loopback), serve calls loadDashboard(undefined). The admin key is never baked into the HTML the browser receives.

Instead, the dashboard SPA prompts you for the key on load. You paste in whatever you set HELIPOD_ADMIN_KEY to, and it's used from then on to authenticate /_admin calls.

This matters because a 0.0.0.0-bound serve is reachable by anyone who can reach the port. Embedding a persistent secret in served HTML would leak it to any visitor, not just the operator.

Flags and environment variables

Every serve option can be set as a CLI flag or an environment variable. Where both exist, the flag wins.

serve reads env vars once at startup. There's no live reconfiguration, so restart to change any of these.

FlagEnv varDefaultWhat it does
--dir <path>helipodThe app directory to load (schema, functions, helipod.config.ts, _generated/).
--data <path>HELIPOD_DATA_DIR (sets <dir>/db.sqlite)./data/db.sqliteSQLite database file path. Ignored when --database-url selects Postgres.
--ip <address>0.0.0.0Bind address.
--port <n>PORT3000Bind port.
--no-dashboardHELIPOD_DASHBOARD=offdashboard onDisable the dashboard SPA entirely (not just hide it, the route isn't served).
--allow-deployHELIPOD_ALLOW_DEPLOY=1offEnable POST /_admin/deploy, the target of helipod deploy's live hot-swap. See Deploy and build.
--database-url <url>HELIPOD_DATABASE_URLunset (SQLite)Point at a Postgres database instead of the SQLite file. See Postgres.
--storage-bucket <name>HELIPOD_STORAGE_BUCKETunset (local FS)Selects the S3-compatible file-storage backend; presence of a bucket is what switches ctx.storage from local disk to S3/MinIO/R2.
--storage-endpoint <url>HELIPOD_STORAGE_ENDPOINTAWS defaultCustom S3 endpoint (MinIO, R2, etc.), only meaningful alongside a bucket.
--web <dir>HELIPOD_WEB_DIRunsetServe a static frontend (index.html + assets) at the site root, same origin as the sync WebSocket.

HELIPOD_ADMIN_KEY is required and has no flag equivalent. That's deliberate: a secret belongs in the environment, not in a process argument list visible via ps.

Docker: the baseline self-host path

Before you start

You need a helipod/ directory with committed _generated/. serve fails fast without it, as covered above. Generate it before building the image:

helipod codegen --dir helipod

You also need Docker and Docker Compose.

Set a strong admin key

Put it in a .env file next to docker-compose.yml. Compose loads .env automatically:

.env
HELIPOD_ADMIN_KEY=$(openssl rand -hex 32)

The shipped docker-compose.yml requires this at the environment-variable level (HELIPOD_ADMIN_KEY: ${HELIPOD_ADMIN_KEY:?set HELIPOD_ADMIN_KEY in a .env file}). Compose itself refuses to start the container if the variable is unset, before serve's own check ever runs.

Run docker compose up

docker compose up

The shipped docker-compose.yml:

docker-compose.yml
services:
  helipod:
    build:
      context: .
      target: runner
    image: helipod:latest
    ports:
      - "3000:3000"
    environment:
      HELIPOD_ADMIN_KEY: ${HELIPOD_ADMIN_KEY:?set HELIPOD_ADMIN_KEY in a .env file}
      HELIPOD_DATA_DIR: /data
    volumes:
      - ./helipod:/app/helipod:ro
      - helipod-data:/data
    command: ["serve", "--dir", "/app/helipod", "--data", "/data/db.sqlite"]
    restart: unless-stopped

volumes:
  helipod-data:

This builds the runner stage of the repo Dockerfile, binds the container to 0.0.0.0:3000, bind-mounts ./helipod read-only into /app/helipod, and persists SQLite on the named helipod-data volume at /data/db.sqlite. The container's command is exactly serve --dir /app/helipod --data /data/db.sqlite.

bind mount port 3000 ./helipod (read-only) helipod:latest running serve Your browser or client helipod-data volume: db.sqlite

Open the dashboard

http://localhost:3000/_dashboard. Paste the admin key from .env when prompted (see "The dashboard is served key-less" above for why it prompts instead of just working). The API itself is reachable at http://localhost:3000: sync WebSocket, /api/* HTTP, httpAction routes, all on the same origin.

Confirm data survives a restart

The SQLite database lives on the helipod-data Docker volume, not inside the container's writable layer. docker compose down && docker compose up does not lose data. Only docker compose down -v (which explicitly deletes volumes) does.

Verify it end to end

# 1) Bring it up
docker compose up -d

# 2) Health check
curl -f localhost:3000/api/health
# {"status":"ok","functions":N,"tables":N}

# 3) Open the dashboard, paste the admin key from .env
open http://localhost:3000/_dashboard

# 4) Write some data via the dashboard or a mutation, then restart
docker compose down
docker compose up -d

# 5) Confirm the data written in step 4 is still there

Going deeper

Using Postgres

SQLite is the zero-config default. Point serve at Postgres instead with --database-url or HELIPOD_DATABASE_URL. No other change to the compose file's shape is required beyond swapping the SQLite volume for the connection string.

Postgres is a single-node durability upgrade (a managed database instead of a SQLite file on a volume) via a pg_advisory_lock single-writer guard, not multi-node scale-out. It needs no app-schema migrations as schema.ts evolves. See Postgres for the full compose example, the single-writer constraint, and its known limitations.

Other platforms (Railway, Fly.io)

Any platform that can run a long-lived process with WebSocket support and a persistent disk can host helipod serve; Docker is the baseline, not a requirement. The checklist is always the same: set HELIPOD_ADMIN_KEY as a platform secret (never in a config file), mount a persistent volume for the data directory, and point health checks at GET /api/health.

railway.toml
[build]
builder = "nixpacks"

[deploy]
startCommand = "helipod serve --dir ./helipod --data /app/data/db.sqlite"
healthcheckPath = "/api/health"
healthcheckTimeout = 30

[[mounts]]
source = "data"
destination = "/app/data"

Set HELIPOD_ADMIN_KEY in the service's Railway variables. Railway terminates TLS for you.

A managed Postgres on either platform works through the same HELIPOD_DATABASE_URL as anywhere else. Both platforms also have an automated push path: helipod deploy --target railway and --target fly shell out to the platform's own CLI (railway up, fly deploy) after refreshing codegen. See Deploy and build.

Reverse proxy / TLS

No built-in TLS

helipod serves plain HTTP. serve has no TLS termination built in. Put a reverse proxy (nginx, Caddy, or Traefik) in front of the container to terminate TLS and forward to helipod:3000.

The sync WebSocket and httpAction routes both proxy transparently over standard HTTP upgrade. No special configuration is needed beyond normal WebSocket passthrough in your proxy of choice.

  • Postgres: swap the SQLite volume for a Postgres database, still single-node.
  • Deploy and build: helipod deploy for live hot-swap onto a running serve (no restart, --allow-deploy opt-in), and helipod build for a single self-contained binary instead of a runtime-based image.
  • Scaling: when one serve process isn't enough, the multi-node fleet story (--fleet) built on the same Postgres backend.
  • Cloudflare: an alternative host if you specifically want Cloudflare's edge/scale-to-zero economics.

On this page