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 mode | Command | Role |
|---|---|---|
| api (default) | node apps/api/dist/index.js | Hono HTTP server — REST, ingestion, auth. Port 3002, healthcheck /v1/health. |
| worker | node apps/api/dist/worker.js | Hatchet durable task executor — journey runs, email sends, imports. No port, no healthcheck. |
| migrate | tsx packages/db/src/migrate.ts | One-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:
- 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 threeHATCHET_CLIENT_*vars. - A real
BETTER_AUTH_SECRET— generate one withpnpm gen:secret(openssl rand -base64 32).pnpm bootstrapdoes this for you locally; never ship the placeholder. - Redis — required in production. Better Auth's
secondaryStorage(sessions, reset tokens, cross-replica auth rate limiting) gates on a rawREDIS_URL; it also backs the PostHog cache. - An email provider — Hogsend sends email through a swappable provider, Resend by default. Set
RESEND_API_KEY+RESEND_FROM_EMAILfor Resend, orEMAIL_PROVIDER=postmark+ thePOSTMARK_*vars for Postmark.RESEND_API_KEYis optional — a Postmark-only deploy boots without it. - A first Studio admin — public sign-up is disabled, so set
STUDIO_ADMIN_EMAIL(+ optionalSTUDIO_ADMIN_PASSWORD) and the API mints it on boot into an empty user table, or runhogsend studio admin createfrom 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
| Target | Best for | Infra |
|---|---|---|
| Docker self-host — the default | Running Hogsend on your own box or VPS, fully in your control | docker-compose.prod.yml brings up Postgres, Redis, hatchet-lite, and the migrate/api/worker services |
| Railway — one paved option | A managed on-ramp with one-click and GitHub auto-deploy | Railway-managed Postgres + Redis, self-hosted hatchet-lite, two app services |
| Bring your own | An existing Kubernetes / Nomad / ECS / VM platform | Whatever 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.
| Concern | Local dev (host process) | Docker self-host (default) | Railway (one paved option) | BYO / Hatchet Cloud |
|---|---|---|---|---|
| API + worker | pnpm dev / pnpm worker on host | api + worker compose services | two Railway services, same build | your orchestrator |
| Infra | docker compose up -d (infra only) | docker-compose.prod.yml (full stack) | Railway services + hatchet-lite | operator-provided |
DATABASE_URL | …@localhost:5434/growthhog | …@postgres:5432/growthhog | *.railway.internal | your value |
REDIS_URL (required in prod) | redis://localhost:6380 | redis://redis:6379 | *.railway.internal | your value |
HATCHET_CLIENT_HOST_PORT | localhost:7077 | hatchet-lite:7077 | hatchet-lite.railway.internal:7077 | Cloud / your address |
HATCHET_CLIENT_TLS_STRATEGY | none | none | none (internal net) | tls (Cloud) |
HATCHET_CLIENT_TOKEN | minted in local lite | minted in local lite | minted in Railway lite | pasted from Cloud / your engine |
| Migrations run via | pnpm db:migrate manually | migrate one-shot, gated on healthy Postgres | Railway preDeployCommand | you run the migrate command first |
| API boot schema guard | yes (exit(1) if behind) | yes | yes | yes |
| Worker schema guard | none (ordered after migrate) | none (gated on migrate completing) | none | you 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:
| Track | Lives in | Ledger | Gates boot? |
|---|---|---|---|
| engine | @hogsend/db (bundled) | drizzle.__drizzle_migrations | Yes (fatal) — the API exit(1)s on boot if behind |
| client | your repo's migrations/ | drizzle.__client_migrations | No — 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.