dhaga.docs
Self-hosting

Deploying

The three ways Dhaga deploys — Vercel, a single persistent server, and Docker — plus the Postgres role and pooling rules that matter in hosted mode.

Just self-hosting for yourself?

If you want no Dhaga Cloud features (billing, admin panel, invite-only signup), read Self-hosting first — it's simpler than this whole page and you can skip the "Hosted-mode extras" section below entirely.

Two very different things deploy from this repo today:

  1. The landing page (/) — static, deploys anywhere, zero config.
  2. The app (/app, /api) — stores data in an embedded database (PGlite) on the server's filesystem. That single fact decides where you can run it.

Read this first

The default storage is an embedded database on the server's filesystem — great on a laptop/VPS, impossible on serverless (read-only, ephemeral, many instances). To run the app on Vercel, set DATABASE_URL to a hosted Postgres (Neon/Supabase free tiers work; the database must support CREATE EXTENSION vector). With it set, the app uses hosted Postgres everywhere; without it, the embedded DB — and on serverless only the landing page + waitlist (email fallback) function.

Option A — Vercel

  1. Import the GitHub repo at vercel.com → framework auto-detects Next.js.
  2. Set Root Directory to apps/web and keep the default build command.
  3. Env vars:
    • RESEND_API_KEY, RESEND_FROM_EMAIL, DHAGA_OWNER_EMAIL — emails + the waitlist's fallback path.
    • DATABASE_URL — hosted Postgres (create a free Neon or Supabase project, copy its connection string). Without this, /app cannot store anything on Vercel; with it, the full app works.
    • BETTER_AUTH_SECRET, BETTER_AUTH_URL — once DATABASE_URL is set.
    • ANTHROPIC_API_KEY — AI features.
    • DHAGA_EMBEDDINGS=off recommended on Vercel for now: the local embedding model (~100 MB of native runtime) is a poor fit for serverless functions; search falls back to keyword matching.
    • CRON_SECRET, FIRECRAWL_API_KEY — optional, job-change detection + news watchlist. apps/web/vercel.json already declares the nightly cron; Vercel sends Authorization: Bearer $CRON_SECRET to it automatically once the var is set (unset = the route 401s to everyone, including Vercel's own cron — the feature is simply off).
  4. Deploy. Add your domain under Settings → Domains.

Hosted-mode extras (Dhaga Cloud only — skip for plain self-hosting)

Add these on top of the above only if you want invite-gated signup, the admin panel, and Stripe billing. See Self-hosting for what each of these actually turns on, and how to create your first admin account once it's live.

  • DHAGA_HOSTED_MODE=true — the master switch; every EE feature stays off without it, regardless of whether the other vars below are set.
  • DHAGA_ADMIN_EMAILS — comma-separated emails that bootstrap into admins on signup (see Self-hosting's "Creating the first admin user").
  • STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PRICE_PRO_ANNUAL, STRIPE_PRICE_LIFETIME — from the Stripe Dashboard (test-mode keys while developing). Omit STRIPE_SECRET_KEY to run hosted mode without billing (e.g. a free beta) — the billing UI simply doesn't render without it.
  • Register the webhook in Stripe pointing at https://your-domain/api/stripe/webhook, subscribed to at least checkout.session.completed, customer.subscription.updated, customer.subscription.deleted, invoice.payment_failed.
  • DATABASE_URL is mandatory in hosted mode regardless of platform — the multi-tenant isolation is Postgres Row-Level Security, which the embedded PGlite database doesn't support.

⚠ The Postgres role DATABASE_URL connects as matters — a lot

Hosted mode's tenant isolation is entirely Row-Level Security (see packages/ee/src/db/rls-ddl.ts) — every query core code runs is tenant-agnostic by design and relies on RLS policies to filter rows. RLS is enforced per role, not per connection: a role with the BYPASSRLS attribute ignores every RLS policy on every table, silently, regardless of FORCE ROW LEVEL SECURITY. Managed Postgres providers' default admin/owner role commonly has BYPASSRLS out of the box — Supabase's postgres role does. If DATABASE_URL connects as that role, every signed-in user sees every other user's data, with no error anywhere, because nothing is malformed — RLS is just never evaluated for that role. This is exactly what shipped originally on this project's own hosted deployment and leaked one account's contacts into another's.

Before going live in hosted mode on any provider (Supabase, Neon, self-hosted — this isn't Supabase-specific, it's how Postgres RLS works everywhere):

  1. Run packages/ee/scripts/create-app-role.sql against the target database. It creates a dedicated dhaga_app role with NOBYPASSRLS and moves table ownership to it (ownership, not just GRANTs, is required — the app's own boot-time DDL runs ALTER TABLE statements to evolve the schema and enable RLS).
  2. Point DATABASE_URL at dhaga_app, not the provider's default role. Supabase specifically: the pooler (pooler.supabase.com) routes by a <role>.<project_ref> username — e.g. dhaga_app.zgnpoeddgsrgpivqpkdk — not the plain role name; only the connection username changes shape, the role itself is still just dhaga_app.
  3. As of this project's packages/ee/src/db/bootstrap.ts, the app checks this itself at boot — SELECT rolbypassrls FROM pg_roles WHERE rolname = current_user, and refuses to start (loud error naming this doc) if the connecting role has BYPASSRLS. Don't rely on this alone for a fresh setup, though — check via the script's own verification query before traffic hits it, not after.

Migrating off Supabase (or any hosted Postgres) later

Low-risk by design: the app never uses Supabase-specific APIs (no @supabase/supabase-js, no Supabase Auth/Storage/Realtime) — it's Better Auth + plain pg/Drizzle against a connection string, using only the standard pgvector and pg_trgm Postgres extensions. The schema is self-provisioning (initHosted() in apps/web/src/lib/db/index.ts runs the app's own idempotent DDL on every boot), so this is mostly a data migration, not a code change:

  1. On the new Postgres target: install the pgvector and pg_trgm extensions, then run packages/ee/scripts/create-app-role.sql against it to create dhaga_app there too (self-hosted Postgres usually keeps extensions in the public schema rather than Supabase's separate extensions schema — skip that one GRANT USAGE ON SCHEMA extensions line if so).
  2. pg_dump --no-owner --no-acl <source> | psql <target> (or pg_restore --no-owner --no-acl if using the custom format) — --no-owner matters because the dump would otherwise try to assign ownership to a dhaga_app role that doesn't exist yet in the target cluster (roles are cluster-level, not part of a database dump).
  3. Re-run just the ownership-reassignment loop from create-app-role.sql (the ALTER TABLE ... OWNER TO dhaga_app loop + grants) against the target — --no-owner left everything owned by whichever role ran the restore.
  4. Point DATABASE_URL at the new target (with dhaga_app's credentials) and redeploy. The boot-time check in packages/ee/src/db/bootstrap.ts will refuse to start if anything about the role is wrong — treat that as the migration's final verification, not just a safety net.

Option B — a single persistent server (the real deployment today)

Any Linux VPS (Hetzner/DigitalOcean/EC2) or PaaS with a volume (Railway / Render / Fly.io). Requirements: Node 20+, a directory that survives restarts, HTTPS in front. (Prefer containers? See Option C below.)

git clone https://github.com/anchit2000/dhaga.git && cd dhaga
npm ci
npm run build

Create apps/web/.env.local (or export the vars in your process manager):

BETTER_AUTH_SECRET=<long random string, e.g. `openssl rand -hex 32`>
BETTER_AUTH_URL=https://dhaga.example.com
DHAGA_DATA_DIR=/var/lib/dhaga        # persisted dir OUTSIDE the repo
ANTHROPIC_API_KEY=sk-ant-...         # optional; enables AI features
DHAGA_AI_MONTHLY_CAP=500             # optional; default 25/month
NODE_ENV=production

Run it (systemd example — pm2 start "npm run start" works too):

# /etc/systemd/system/dhaga.service
[Unit]
Description=Dhaga
After=network.target

[Service]
WorkingDirectory=/opt/dhaga
ExecStart=/usr/bin/npm run start
Restart=always
EnvironmentFile=/etc/dhaga.env

[Install]
WantedBy=multi-user.target

Put Caddy or nginx in front for HTTPS — required in production because the session cookie is Secure (login will not stick over plain HTTP).

# Caddyfile — automatic HTTPS
dhaga.example.com {
    reverse_proxy localhost:3000
}

Backups & migration

  • Backup = copy the DHAGA_DATA_DIR directory (stop the service first), plus a periodic curl -H "x-api-key: …" .../api/export/json (create a personal access token from /app/settings).
  • Leaving = the JSON/CSV/vCard export endpoints give you everything; no lock-in is a feature.

Option C — Production deployment with Docker

The repo root has a multi-stage Dockerfile (deps → build → slim runtime; Next.js standalone output on node:22-slim, non-root user, built-in healthcheck against /login) and a compose.yml that runs the app plus a Postgres 16 + pgvector database:

git clone https://github.com/anchit2000/dhaga.git && cd dhaga
echo "BETTER_AUTH_SECRET=$(openssl rand -base64 32)" > .env
docker compose up --build -d

Open http://localhost:3000. On first boot the app applies its own idempotent DDL over the DATABASE_URL connection (including CREATE EXTENSION vector / pg_trgm) — there is no migration step, and the same schema self-heal re-runs harmlessly on every upgrade.

  • Contact data lives in the dhaga-db volume — docker compose down keeps it, down -v deletes it. The database is also reachable from the host at localhost:54329 for backups (pg_dump).

  • Set BETTER_AUTH_URL in .env to your public URL and terminate HTTPS in front (same reason as Option B: the session cookie is Secure, so login won't stick over plain HTTP on anything but localhost).

  • Zero-config variant (no Postgres): run the image without DATABASE_URL and it uses the embedded database (PGlite), persisted at /data inside the container — mount a volume there or the data dies with the container:

    docker build -t dhaga .
    docker run -d -p 3000:3000 \
      -e BETTER_AUTH_SECRET="$(openssl rand -base64 32)" \
      -v dhaga-data:/data dhaga

    compose.yml carries this as a commented-out variant too. Backup = the dhaga-data volume, exactly like DHAGA_DATA_DIR in Option B.

Hosted mode (Dhaga Cloud) in Docker

The stock compose.yml is the plain self-host path — none of the packages/ee vars are wired in. To run DHAGA_HOSTED_MODE=true multi-tenant in containers, additionally:

  1. Run packages/ee/scripts/create-app-role.sql against the database once, e.g. docker compose exec -T db psql -U dhaga -d dhaga < packages/ee/scripts/create-app-role.sql, and point DATABASE_URL at the resulting non-BYPASSRLS dhaga_app role — see "The Postgres role DATABASE_URL connects as matters" above.
  2. Keep pooling session-scoped: connect directly (what the compose file does) or via a session-mode pooler, never a transaction-mode pooler such as Supabase's port 6543. Tenant scoping rides on session-level set_config, which transaction pooling silently breaks; the boot guard in packages/ee/src/db/bootstrap.ts refuses to start on a known transaction pooler.
  3. Add the hosted-mode env vars (DHAGA_HOSTED_MODE, DHAGA_ADMIN_EMAILS, STRIPE_*) to the app service's environment block yourself.

Checklist before going live

  • Random BETTER_AUTH_SECRET, correct BETTER_AUTH_URL
  • DHAGA_DATA_DIR on persistent storage, owned by the service user
  • HTTPS terminating in front of port 3000
  • Backup cron for the data dir / JSON export
  • ANTHROPIC_API_KEY set as a server env var only — never in client code
  • Verified the app with Testing against the deployed URL

What's deliberately not here yet

  • Manual click-through verification of the Stripe checkout/webhook flow — the code is typechecked/linted/built/tested, but nobody has run a real test-mode purchase against a live Stripe account yet.

Already done (see Self-hosting and the "Hosted-mode extras" section above)

  • Hosted Postgres (Supabase/Neon)DATABASE_URL switches the driver automatically; see Option A above.
  • Real user accounts, per-user API keys, multi-tenant RLS, billing, admin panel, early-access gating — all built. Self-hosted instances run without any of the hosted-only pieces by default (open registration, no billing UI, no admin nav) — see Self-hosting.

On this page