helipod
Deploy & Operate

Postgres

Point helipod at a Postgres database instead of SQLite, with no code changes.

SQLite is the zero-config default for helipod serve. It's a single file, and it needs nothing else installed. Postgres is the opt-in alternative: the same reactive engine, the same app code, no code changes, but your data lands in a database your infrastructure already backs up, replicates, and monitors.

This page covers turning it on, what happens at boot, how it works under the hood, the single-writer guarantee, group commit, and how to think about its performance.

Turning it on

Point serve (or dev) at a Postgres database with --database-url, or set the HELIPOD_DATABASE_URL environment variable instead. The flag wins if both are set. Leave both unset and you get SQLite exactly as before.

helipod serve --dir helipod --database-url postgres://user:pass@host:5432/db

Any string matching postgres:// or postgresql:// selects the Postgres backend. Anything else, including unset, falls back to SQLite. The same flag and env var work for helipod dev and for a compiled helipod build binary too: the storage backend is a boot-time choice, not something baked into your app code.

Docker Compose with a postgres:16 service

Add a postgres service to docker-compose.yml and swap the SQLite volume for HELIPOD_DATABASE_URL:

docker-compose.yml
services:
  helipod:
    build:
      context: .
      target: runner
    ports:
      - "3000:3000"
    volumes:
      - ./helipod:/app/helipod:ro
    environment:
      HELIPOD_ADMIN_KEY: ${HELIPOD_ADMIN_KEY}
      HELIPOD_DATABASE_URL: postgres://helipod:helipod@postgres:5432/helipod
    command: serve --dir /app/helipod
    depends_on:
      - postgres

  postgres:
    image: postgres:16
    restart: unless-stopped
    environment:
      POSTGRES_USER: helipod
      POSTGRES_PASSWORD: helipod
      POSTGRES_DB: helipod
    volumes:
      - postgres-data:/var/lib/postgresql/data

volumes:
  postgres-data:

docker compose up now persists to the postgres-data named volume instead of helipod-data. Everything else in Self-hosting, the admin key, the dashboard, restart persistence, works the same way.

What happens at boot

On every boot, whether the database is fresh or already has data in it, the engine runs setupSchema(). That's a set of idempotent CREATE TABLE IF NOT EXISTS, CREATE INDEX IF NOT EXISTS, and CREATE SEQUENCE IF NOT EXISTS statements against a small, fixed set of internal tables (see below), never against your app's own tables. Your app has no tables at the Postgres level.

This is safe to run every single time you boot. An existing database with those objects already in place just no-ops through each statement.

After the DDL, the engine takes the single-writer advisory lock (more on that below). The very first time it boots against a given database, it also seeds the commit-timestamp sequence from the existing data, or from 1 on a genuinely fresh database, so timestamps continue where they left off. There's no separate "initialize this database" step to run yourself. Pointing serve at a blank Postgres database is enough.

The single-writer invariant

Exactly one helipod engine may be connected to a given Postgres database at a time. On boot, inside setupSchema(), the engine takes a pg_advisory_lock, a session-level Postgres lock tied to that one pinned connection. A second serve or dev process pointed at the same database fails fast instead of silently corrupting state:

$ helipod serve --dir helipod --database-url postgres://...   # already running elsewhere
Error: another Helipod engine is already connected to this database (advisory lock held)

This is a single-node durability story: Postgres as a stronger, externally-managed database for one writer, not clustering or multi-node write scale-out. Running two engines against the same Postgres database for high availability isn't supported on this path. See Scaling for helipod serve --fleet, the multi-node story built on top of this same adapter.

Known limitations

Single pinned connection, no automatic reconnect. The engine holds exactly one Postgres connection for its lifetime, the same connection the single-writer lock and transaction pinning depend on. If that connection drops (a network blip, a Postgres restart, a failover), the engine does not transparently reconnect. Restart the helipod process to re-establish it.

An unclean process kill can briefly hold the lock. A graceful shutdown (SIGTERM) releases the advisory lock immediately, but an unclean kill (SIGKILL, a crash) may leave an immediate restart briefly failing with "another engine already connected" until Postgres notices the dead session, typically within seconds.

Neither of these is a clustering or HA limitation. Both are consequences of the single-node, single-writer durability story above, not new constraints on top of it.

Going deeper

  • Self-hosting with Docker: the baseline helipod serve and docker compose up this backend swaps into, unchanged: admin key, dashboard, restart persistence.
  • Scaling: helipod serve --fleet, multiple nodes sharing this same Postgres backend for multi-node write scale-out and live failover.
  • Configuration reference: the full flag/env table, including HELIPOD_DATABASE_URL and HELIPOD_GROUP_COMMIT alongside every other setting.

On this page