Hogsend
Operating

Deployment

Deploy Hogsend your way. Self-host with Docker (the default), one-click on Railway, or bring your own orchestrator — all from one runtime image and one env contract.

Hogsend runs as one runtime image, built once, run three ways via a command override:

Run modeCommandRole
api (default)node apps/api/dist/index.jsHono HTTP server — REST, ingestion, auth. Port 3002, healthcheck /v1/health.
workernode apps/api/dist/worker.jsHatchet durable task executor — journey runs, email sends, imports. No port, no healthcheck.
migratetsx packages/db/src/migrate.tsOne-shot. Applies the engine migration track (then the client track if present), then exits.

Every deploy target is the same image plus the same validated env contract — only the values differ. There is nothing single-tenant baked in.

Before you deploy

The three required env vars are DATABASE_URL, BETTER_AUTH_SECRET, and HATCHET_CLIENT_TOKEN. The prerequisites, in order:

  1. Get a Hatchet token — Hogsend's one hard dependency. Get a token from a self-hosted hatchet-lite, Hatchet Cloud, or your own engine, and set the three HATCHET_CLIENT_* vars.
  2. A real BETTER_AUTH_SECRET — generate one with pnpm gen:secret (openssl rand -base64 32). pnpm bootstrap does this for you locally; never ship the placeholder.
  3. Redis — required in production. Better Auth's secondaryStorage (sessions, reset tokens, cross-replica auth rate limiting) gates on a raw REDIS_URL; it also backs the PostHog cache.
  4. An email provider — Hogsend sends email through a swappable provider, Resend by default. Set RESEND_API_KEY + RESEND_FROM_EMAIL for Resend, or EMAIL_PROVIDER=postmark + the POSTMARK_* vars for Postmark. RESEND_API_KEY is optional — a Postmark-only deploy boots without it.
  5. A first Studio admin — public sign-up is disabled, so set STUDIO_ADMIN_EMAIL (+ optional STUDIO_ADMIN_PASSWORD) and the API mints it on boot into an empty user table, or run hogsend studio admin create from a shell. See Authentication.

The complete, annotated env contract is in .env.example — one file projected from the validated schema in packages/engine/src/env.ts, with every var tagged [required] / [default: x] / [optional]. See Configuration for the per-variable reference.

Pick a target

TargetBest forInfra
Docker self-host — the defaultRunning Hogsend on your own box or VPS, fully in your controldocker-compose.prod.yml brings up Postgres, Redis, hatchet-lite, and the migrate/api/worker services
Railway — one paved optionA managed on-ramp with one-click and GitHub auto-deployRailway-managed Postgres + Redis, self-hosted hatchet-lite, two app services
Bring your ownAn existing Kubernetes / Nomad / ECS / VM platformWhatever you already run; you provide Postgres, Redis, and a Hatchet endpoint

The deploy matrix

What actually varies between targets is a handful of env values and the TLS strategy. Everything else is constant.

ConcernLocal dev (host process)Docker self-host (default)Railway (one paved option)BYO / Hatchet Cloud
API + workerpnpm dev / pnpm worker on hostapi + worker compose servicestwo Railway services, same buildyour orchestrator
Infradocker compose up -d (infra only)docker-compose.prod.yml (full stack)Railway services + hatchet-liteoperator-provided
DATABASE_URL…@localhost:5434/growthhog…@postgres:5432/growthhog*.railway.internalyour value
REDIS_URL (required in prod)redis://localhost:6380redis://redis:6379*.railway.internalyour value
HATCHET_CLIENT_HOST_PORTlocalhost:7077hatchet-lite:7077hatchet-lite.railway.internal:7077Cloud / your address
HATCHET_CLIENT_TLS_STRATEGYnonenonenone (internal net)tls (Cloud)
HATCHET_CLIENT_TOKENminted in local liteminted in local liteminted in Railway litepasted from Cloud / your engine
Migrations run viapnpm db:migrate manuallymigrate one-shot, gated on healthy PostgresRailway preDeployCommandyou run the migrate command first
API boot schema guardyes (exit(1) if behind)yesyesyes
Worker schema guardnone (ordered after migrate)none (gated on migrate completing)noneyou order migrate first

The two port regimes are mutually exclusive: host-process dev reaches infra on host-mapped ports (5434/6380/7077), in-compose reaches infra by in-network service names on internal ports (postgres:5432/redis:6379/hatchet-lite:7077). .env.example documents both — do not mix them.

Migrations — two tracks, one ordering

Every target runs the same two migration tracks against the same database, each with its own ledger in the drizzle schema:

TrackLives inLedgerGates boot?
engine@hogsend/db (bundled)drizzle.__drizzle_migrationsYes (fatal) — the API exit(1)s on boot if behind
clientyour repo's migrations/drizzle.__client_migrationsNo — surfaced non-fatally in /v1/health

The engine track runs first so client migrations can reference engine tables, and both serialize behind the same Postgres advisory lock. The worker has no boot guard of its own, so on every target it must be ordered after migrations complete — the compose stack enforces this with depends_on: migrate (service_completed_successfully).

The db:push ledger gotcha. pnpm db:push (and seeding a fresh DB) creates schema objects without writing ledger rows. The boot guard judges "in sync" by the ledger, so a push-bootstrapped database reports inSync: false and the API refuses to boot — even though the tables exist. Bring up real databases with pnpm db:migrate from day one; db:push is a throwaway-local-dev shortcut only.

To bypass the engine guard in an emergency: SKIP_SCHEMA_CHECK=true.

Next steps

On this page