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.
Running a single-user deployment?
If your deployment has 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 codebase 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 + access-request form (email fallback) function.
Option A — Vercel
- Create a Vercel project from the licensed source bundle — push it to a
private Git repository you control and import that, or run
vercel deployfrom the unpacked bundle. Either way the framework auto-detects as Next.js. (Dhaga's own repository is private; a self-hosted deployment starts from the bundle that comes with your agreement — see Self-hosting.) - Set Root Directory to
apps/weband keep the default build command. - Env vars:
RESEND_API_KEY,RESEND_FROM_EMAIL,DHAGA_OWNER_EMAIL— emails + including the verification link that approves a new hosted account, plus the access-request form'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.BETTER_AUTH_TRUSTED_ORIGINS— optional; extra origins allowed on sign-in/sign-up beyondBETTER_AUTH_URL(comma-separated or a wildcard), to avoidINVALID_ORIGIN. Vercel preview URLs are auto-trusted, so this is usually unneeded.ANTHROPIC_API_KEY— AI features.JEV_API_KEY— optional, server-only. TypeSafe's Jev (pinnedjev-1.13.0) answers some yes/no / pick-one / score questions in front of their Claude call, only when it can give the whole existing output with no generated text above a confidence threshold. Unset, those features run their Claude call as before and nothing is sent to TypeSafe; set, their inputs (contact names, companies, message text) are. $0.042/M input tokens, output free, metered in the action'sai_actionsrow.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— optional, job-change detection + news watchlist.apps/web/vercel.jsonalready declares all three crons — the nightly/api/jobs/daily, a quarter-hourly/api/jobs/tick(since 2026-09-04) and a per-minute/api/jobs/deliver, which empties the nudge queue and does nothing else; Vercel sendsAuthorization: Bearer $CRON_SECRETto each of them automatically once the var is set (unset = the routes 401 to everyone, including Vercel's own cron — the feature is simply off). The two sub-daily entries need Vercel Pro, whose minimum interval is one minute and which fires crons with per-minute accuracy; on Hobby you get at most one cron a day, triggered only within ±59 minutes of its schedule, so a Hobby deploy has to drop both and loses the per-user digest send time and every nudge with them. The sweep's web search needs no separate provider: it defaults to Anthropic's own server-sideweb_searchtool on theANTHROPIC_API_KEYabove.FIRECRAWL_API_KEYis optional and takes precedence only where you set it. Two caveats — the Anthropic search path has never been run against a live key, and searches are billed $10/1k on top of the input tokens every retrieved page costs.NEXT_PUBLIC_SITE_URL— set once a custom domain is attached (step 4): the canonical origin for the sitemap, robots.txt, OG tags, and llms.txt. Defaults to the Vercel preview URL until then.
- 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, and one price id per (tier, cadence):STRIPE_PRICE_PRO_MONTHLY,STRIPE_PRICE_PRO_ANNUAL,STRIPE_PRICE_POWER_MONTHLY,STRIPE_PRICE_POWER_ANNUAL. Any left blank is simply not offered — the picker renders only combinations that have a price. OmitSTRIPE_SECRET_KEYentirely to run hosted mode without billing (e.g. a free beta). Reference prices: Pro $4.99/mo or $48/yr, Power $8.99/mo or $89.99/yr (BRD §8.3 carries the margins these imply — they are materially thinner than the figures the same section quoted before 2026-08-19). Every tier is recurring — there is no one-time purchase.- Local currency is Stripe Adaptive Pricing, a Dashboard setting on Checkout — not code and not extra env vars. Turn it on there and a visitor in the UAE is quoted dirhams off the same price id.
RAZORPAY_KEY_ID,RAZORPAY_KEY_SECRET,RAZORPAY_WEBHOOK_SECRET, one Plan id per (tier, cadence) —RAZORPAY_PLAN_PRO_MONTHLY,RAZORPAY_PLAN_PRO_YEARLY,RAZORPAY_PLAN_POWER_MONTHLY,RAZORPAY_PLAN_POWER_YEARLY. Optional and independent of Stripe: either processor alone is enough for the billing UI to render. Reference prices: Pro ₹499/mo or ₹4,799/yr, Power ₹899/mo or ₹8,999/yr — roughly parity with the USD figures, so BRD §8.3's recomputed margins carry over.RAZORPAY_PLAN_PRO_MONTHLY_LEGACY,RAZORPAY_PLAN_PRO_YEARLY_LEGACY,RAZORPAY_PLAN_POWER_MONTHLY_LEGACY,RAZORPAY_PLAN_POWER_YEARLY_LEGACY— set these whenever you reprice a standing tier, to the plan id the var above held before the change. A Razorpay Plan is immutable, so repricing means minting a new Plan and repointing the env var, and every subscriber still billing against the old id becomes unrecognisable the moment it leaves the table: the renewal webhook resolves no tier and silently drops a paying customer to free.RAZORPAY_LEGACY_PLAN_ENV(packages/ee/src/billing/catalog/plan-env/tables.ts) is consulted by the reverse lookup only —isSellableCadencerefuses these, so an older, cheaper id can never become purchasable again.RAZORPAY_PLAN_PRO_FOUNDING_YEARLY— retired, but do not unset it. Founding Pro is no longer sold: it is on no public surface, andisSellableCadencerefuses the cadence, so this id can no longer mint a subscription. What survives is a reverse lookup only. A Razorpay Plan is immutable, so an existing founding subscriber renews against this exact plan id forever, and the renewal webhook resolves which tier to grant by looking the id up inpackages/ee/src/billing/catalog/plan-env/tables.ts. Remove the variable and that renewal resolves no tier, grants nothing, and silently drops a paying customer to free. Leave it set for as long as one founding subscription is live.- Processor routing is by
x-vercel-ip-country: India leads with Razorpay, everywhere else with Stripe (RAZORPAY_COUNTRIES,apps/web/src/lib/billing/processor.ts). It only reorders the buttons — both stay clickable wherever both are configured, because IP geo is wrong often enough that locking someone out of paying is the worse failure. - Register the Razorpay webhook at
https://your-domain/api/razorpay/webhook, subscribed to the twelve events the handler switches on (packages/ee/src/billing/razorpay/webhook/index.ts):subscription.authenticated,subscription.activated,subscription.charged,subscription.pending,subscription.halted,subscription.cancelled,subscription.completed,subscription.expired,refund.created,refund.processed,payment.dispute.created,payment.dispute.lost. An event you do not subscribe to is a state change the app never hears about — the refund and dispute events are what withdraw access after money goes back, andauthenticated/pending/completed/expireddrive the plan-change and end-of-term transitions. Its secret is per-endpoint and not the API key secret. Without the webhook, a customer who closes the tab mid-redirect is charged and never upgraded, and renewals never update the stored status. Razorpay-paid customers cancel (and resume) in the app, under Settings → Billing (cancelPlan/resumePlanon the billing gate); there is no separate portal, and the dashboard is not required. - 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.
Retired intro price
The intro price was removed completely on 2026-10-06. Only the standing prices are sold (Pro $4.99/mo or $48/yr, ₹499/₹4,799; Power $8.99/mo or $89.99/yr, ₹899/₹8,999).
- Removed: the Razorpay Offer attached at checkout, the admin availability toggle and per-customer hold, the step-up notice email, job and in-app banner, and the intro cadences.
- Env vars you can delete from Vercel (now inert): the four
RAZORPAY_PLAN_*_INTRO_*plan ids. TheRAZORPAY_OFFER_*andNEXT_PUBLIC_DHAGA_INTRO_PRICE_*vars are likewise no longer read. - Keep:
RAZORPAY_PLAN_PRO_FOUNDING_YEARLYand everyRAZORPAY_PLAN_*_LEGACYid. They are recognise-only (never sold), so a renewal still resolves a tier. Delete one only when no live Razorpay subscription bills against that plan id. - Retained, unused, never deleted: the
subscriptionscolumnsintro_offer_id,intro_price_held_atandintro_price_held_by, and thebilling_settingstable. - Evidence it was safe: read-only production counts on 2026-10-06 showed
zero subscriptions with an intro cadence or an
intro_offer_id, and zeropaymentsrows ever.
⚠ 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, rolsuper FROM pg_roles WHERE rolname = current_user, and refuses to start (loud error naming this doc) if the connecting role hasBYPASSRLSorSUPERUSER(a superuser bypasses RLS unconditionally even whilerolbypassrlsreads false). 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. - To prove the isolation itself rather than just the role's attributes,
run
packages/ee/src/db/__tests__/rls-isolation.integration.test.ts— it asserts, per tenant table, that a second tenant's scoped read sees nothing of the first's. Its file header carries the run instructions. It writes and then deletes rows, so point it at a disposable Postgres (thecompose.ymldatabase is one) and never at production.
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 — it runs unmodified on both, no editing step: the one Supabase-specific statement (GRANT USAGE ON SCHEMA extensions) is guarded and no-ops on a self-hosted Postgres, which keeps extensions inpublicrather than Supabase's separateextensionsschema. 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.)
cd dhaga # the licensed source bundle, unpacked
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
JEV_API_KEY= # optional; TypeSafe's Jev answers some classifier questions before Claude
DHAGA_AI_MONTHLY_CAP=500 # optional; seeds the default AI CREDITS/month until an admin sets one at /app/admin/ai-credits (default 10)
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. That is the default and it is not filtered: called
plainly,
/api/export/json,/api/export/csvand/api/export/vcardeach return every contact, whatever its provenance. The CSV and vCard routes also accept an opt-in?scope=authoredused for seeding an address book — a deliberately narrower file for a different job. Omit it and you get everything; nothing about the portability guarantee changed.
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:
cd dhaga # the licensed source bundle, unpacked
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. - Pooling mode doesn't matter here. Tenant scoping is transaction-local
(
set_config('app.current_user_id', …, true)inside oneBEGIN … COMMIT, which self-clears at COMMIT —packages/ee/src/tenant/scoped-db.ts), so it is correct whether you connect directly (what the compose file does), through a session-mode pooler, or through a transaction-mode pooler (Supabase's port 6543, PgBouncer, Supavisor, Neon's-pooler). There is no pooling-mode boot guard to satisfy — the earlier one, and itsDHAGA_ALLOW_TRANSACTION_POOLERoverride, were removed as obsolete. (Supabase specifically: 5432 vs 6543 is just aDATABASE_URLchange — the 6543 path is designed-correct but not yet verified against a live transaction pooler; seedocs/SCALING.md§2.) - 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_KEY(andJEV_API_KEY, if used) set as a server env var only — never in client code - WhatsApp, if used (zero-touch): set
WHATSAPP_ACCESS_TOKEN,WHATSAPP_PHONE_NUMBER_ID,WHATSAPP_VERIFY_TOKENandWHATSAPP_APP_SECRET, and nothing else. The token must be a system-user token that never expires, with bothwhatsapp_business_messagingandwhatsapp_business_management. With/api/jobs/tickand/api/jobs/dailyscheduled, the message templates submit themselves within about 15 minutes of the first production boot and are re-checked daily; approval takes Meta hours to days - 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.
- Ordinary plan changes on Razorpay had the same cause and are now FIXED
(2026-08-21) — by a redesign of the flow.
changePlanno longer makes the refused call at all: a tier or cadence change mints a second Razorpay subscription starting at the current one's renewal date, the customer authorises its mandate in checkout, the old subscription is set to end at that boundary, and the new one takes over when it first charges (an upgrade is granted free in the meantime). Two things an operator needs to know: an account with a change in flight legitimately holds two Razorpay subscriptions, and authorising takes a small refundable debit (₹5 observed) — so no screen may say "nothing is charged today". Built and unit-tested; never exercised against a live Razorpay subscription.
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.