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:
- The landing page (
/) — static, deploys anywhere, zero config. - 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
- Import the GitHub repo at vercel.com → framework auto-detects Next.js.
- Set Root Directory to
apps/weband keep the default build command. - 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,/appcannot store anything on Vercel; with it, the full app works.BETTER_AUTH_SECRET,BETTER_AUTH_URL— onceDATABASE_URLis set.ANTHROPIC_API_KEY— AI features.DHAGA_EMBEDDINGS=offrecommended 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.jsonalready declares the nightly cron; Vercel sendsAuthorization: Bearer $CRON_SECRETto it automatically once the var is set (unset = the route 401s to everyone, including Vercel's own cron — the feature is simply off).
- 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). OmitSTRIPE_SECRET_KEYto 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 leastcheckout.session.completed,customer.subscription.updated,customer.subscription.deleted,invoice.payment_failed. DATABASE_URLis 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):
- Run
packages/ee/scripts/create-app-role.sqlagainst the target database. It creates a dedicateddhaga_approle withNOBYPASSRLSand moves table ownership to it (ownership, not justGRANTs, is required — the app's own boot-time DDL runsALTER TABLEstatements to evolve the schema and enable RLS). - Point
DATABASE_URLatdhaga_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 justdhaga_app. - 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 hasBYPASSRLS. 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:
- On the new Postgres target: install the
pgvectorandpg_trgmextensions, then runpackages/ee/scripts/create-app-role.sqlagainst it to createdhaga_appthere too (self-hosted Postgres usually keeps extensions in thepublicschema rather than Supabase's separateextensionsschema — skip that oneGRANT USAGE ON SCHEMA extensionsline if so). pg_dump --no-owner --no-acl <source> | psql <target>(orpg_restore --no-owner --no-aclif using the custom format) —--no-ownermatters because the dump would otherwise try to assign ownership to adhaga_approle that doesn't exist yet in the target cluster (roles are cluster-level, not part of a database dump).- Re-run just the ownership-reassignment loop from
create-app-role.sql(theALTER TABLE ... OWNER TO dhaga_apploop + grants) against the target —--no-ownerleft everything owned by whichever role ran the restore. - Point
DATABASE_URLat the new target (withdhaga_app's credentials) and redeploy. The boot-time check inpackages/ee/src/db/bootstrap.tswill 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 buildCreate 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=productionRun 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.targetPut 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_DIRdirectory (stop the service first), plus a periodiccurl -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 -dOpen 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-dbvolume —docker compose downkeeps it,down -vdeletes it. The database is also reachable from the host atlocalhost:54329for backups (pg_dump). -
Set
BETTER_AUTH_URLin.envto your public URL and terminate HTTPS in front (same reason as Option B: the session cookie isSecure, so login won't stick over plain HTTP on anything but localhost). -
Zero-config variant (no Postgres): run the image without
DATABASE_URLand it uses the embedded database (PGlite), persisted at/datainside 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 dhagacompose.ymlcarries this as a commented-out variant too. Backup = thedhaga-datavolume, exactly likeDHAGA_DATA_DIRin 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:
- Run
packages/ee/scripts/create-app-role.sqlagainst the database once, e.g.docker compose exec -T db psql -U dhaga -d dhaga < packages/ee/scripts/create-app-role.sql, and pointDATABASE_URLat the resulting non-BYPASSRLSdhaga_approle — see "The Postgres role DATABASE_URL connects as matters" above. - 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 inpackages/ee/src/db/bootstrap.tsrefuses to start on a known transaction pooler. - Add the hosted-mode env vars (
DHAGA_HOSTED_MODE,DHAGA_ADMIN_EMAILS,STRIPE_*) to theappservice'senvironmentblock yourself.
Checklist before going live
- Random
BETTER_AUTH_SECRET, correctBETTER_AUTH_URL -
DHAGA_DATA_DIRon persistent storage, owned by the service user - HTTPS terminating in front of port 3000
- Backup cron for the data dir / JSON export
-
ANTHROPIC_API_KEYset 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_URLswitches 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.