dhaga.docs
Self-hosting

Self-hosting

Run Dhaga on your own infrastructure — what a self-hosted deployment includes, what stays in Dhaga Cloud, and how the hosted-mode switches work.

Self-hosting is available to enterprise customers on request. Dhaga is not distributed publicly and the source is not something you fetch yourself: a self-hosted deployment comes with an agreement and a licensed build. Write to admin@ekasmi.com and we will scope it with you.

This page is the operator's reference for such a deployment — what a self-hosted instance includes, what stays in Dhaga Cloud, and how the switches work. It assumes a licensed build already in hand.

One codebase runs in two shapes:

  1. The core product — the whole CRM (capture, notes, graph, search, drafts, export, Telegram, the browser extension API, the per-user /app theme and font presets) plus real user accounts (better-auth). This is what a self-hosted deployment runs. Nothing here is crippled or trial-limited.
  2. packages/ee — Dhaga Cloud only: multi-tenant row-level security, the pending-approval gate (open signup, /pending until an admin or a payment lets you in), the admin panel, and Stripe billing. Not part of a self-hosted deployment, and not required to run the core.

A deployment for one person or a small trusted group wants just the core.

TL;DR

  • Don't set DHAGA_HOSTED_MODE. That's it — every EE feature goes inert.
  • You do not need to delete packages/ee from your build. It's harmless dead weight until that flag is set to "true".
  • Registration is open (no invite/approval step) whenever hosted mode is off, but the core is single-user: the first account is created normally and every subsequent signup is rejected (see "Single-user by design" below).
  • There is no admin panel, no "Admin" nav item, and no billing UI in this mode — not hidden, not disabled, just not rendered at all.

Two levels of "without EE"

This is the default state of apps/web/.env.example — the var isn't even listed there, only in packages/ee/.env.example. With it unset:

  • Every one of the extension points in apps/web/src/lib/hosted/gate/ (TenantGate, SignupGate, BillingGate, ApprovalGate, AdminGate, ReferralGate) short-circuits to its permissive default before it ever tries to load @dhaga/ee — so it doesn't matter whether the package is physically present.
  • The EE-only routes (/api/access-requests, /api/stripe/webhook, /api/razorpay/order, /api/razorpay/verify, /api/razorpay/webhook) additionally check the flag themselves and return 404 if it's off, so an unrelated visitor can't accidentally trigger EE's schema setup against your database even if packages/ee happens to be installed and DATABASE_URL happens to point at real Postgres.
  • /app/admin 404s for everyone (the isAdmin check always resolves false), so there's no dead link to a panel that doesn't work.

Nothing to delete, nothing to configure. This is the state a self-hosted build starts in.

Level 2 (advanced): physically remove packages/ee

Do this only if your deployment must contain no Dhaga Cloud code at all — for example, an audit that requires the hosted-mode billing, admin and multi-tenant components to be absent from the tree rather than merely inert. Delete:

packages/ee/
apps/web/src/app/app/admin/
apps/web/src/app/api/access-requests/
apps/web/src/app/api/stripe/
apps/web/src/app/api/razorpay/
apps/web/src/lib/actions/admin/
apps/web/src/components/app/admin/
apps/web/src/components/app/table/AdminTables.tsx

Also remove the "@dhaga/ee": "*" line from apps/web/package.json dependencies (and the "@dhaga/ee" entry in transpilePackages in apps/web/next.config.ts), then re-run npm install.

Everything else builds and runs unchanged — these are exactly the files that statically import @dhaga/ee; nothing else in the core references it. If you delete packages/ee but forget one of the route folders above, next build will fail with a clear Module not found: Can't resolve '@dhaga/ee/...' naming the exact file to remove.

Note the asymmetry with Level 1: lib/hosted/gate.ts itself does not need to be deleted or edited — its dynamic import("@dhaga/ee") is wrapped in a try/catch specifically so this file survives the package's removal.

Single-user by design (core only)

With hosted mode off, the core is single-user — it enforces exactly one account, and this is a hard rule, not a suggestion. The reason is structural: per-user data isolation (row-level security scoping every query to its owner) lives entirely in packages/ee. The core's getDb() hands every request one unscoped connection over one shared graph (apps/web/src/lib/db/request-scope.ts). That is completely safe for one person, but a second account on the same core instance would land in — and read and edit — the first user's contacts, notes, and facts. There is no per-user wall to hide behind.

So the signup path refuses to create a second account when hosted mode is off: the first signup succeeds normally, and any later one is rejected with a 403 explaining why (see beforeUserCreate in apps/web/src/lib/auth/config/index.ts).

If you need more than one user with real isolation between them, that's exactly what hosted mode (packages/ee) provides — enable it (DHAGA_HOSTED_MODE=true plus real Postgres; see Deploying) and multi-tenant RLS takes over. Self-hosting the core for a genuinely shared, trusted household where everyone is fine seeing everyone's data is not supported by relaxing this guard — the guard is what keeps "single-user" honest.

/app/me — your own profile, your company's details and a library of reusable document templates and email drafts — and per-contact/per-company share links are both core. They work with packages/ee removed, they need no configuration, and they make no AI calls at all: template variables ({{me.name}}, {{contact.firstName}}, {{today}}) are expanded by a pure function (apps/web/src/lib/templates/render.ts), so an instance with no ANTHROPIC_API_KEY gets the whole feature.

Three new tables, all created by core's auto-applied DDL — nothing to migrate by hand:

TableDDLWhat it holds
me_profileapps/web/src/lib/db/ddl/me.tsOne row per user: your name, headline, bio, contact details, plus your company's name, website, tagline, description and address
me_templatesapps/web/src/lib/db/ddl/me.tsDocument templates and email drafts (kind, title, optional subject, body), stored with their {{tokens}} unexpanded
share_linksapps/web/src/lib/db/ddl/share-links.tsOne row per link: the token, the owner, what it points at, the three disclosure flags, a mandatory expires_at, revoked_at, and a view count

Two things worth knowing as an operator:

  • me_profile.id is the owner's user id, and that is what keeps it to one row per user. There is no UNIQUE (user_id) — on a core-only install there is no such column, since packages/ee's RLS DDL adds it only on a hosted build — so the primary key does the work instead, and a save is one INSERT … ON CONFLICT (id) DO UPDATE. Reads order by updated_at DESC, id ASC before taking one row, which matters only if a row written by an earlier build (keyed on a random UUID) is still sitting in your table beside the user-keyed one: the newest write wins, deterministically.
  • share_links carries an explicit user_id even here, where there is only one user. It is not an oversight and it is not multi-tenant leftovers: a visitor opening /s/<token> has no session, so the row has to name its owner before any scoping can exist, and the owner read off that row is what opens the connection that reads the actual contact or company. On a hosted build this is the whole tenancy boundary; on a single-user self-host the column is written and matched identically, there simply being one owner to find. For the same reason the table is deliberately excluded from packages/ee's TENANT_TABLES — an RLS policy there would compare user_id against a session variable that is unset at exactly the moment the link is resolved, match nothing, and break every link.

share_links has no foreign key to contacts or companies, on purpose: a plain REFERENCES is RESTRICT in this schema, so an outstanding link would block "forget this person". A link whose subject has been deleted resolves to nothing and renders "this link is no longer available" — deleting the record is itself a revocation.

Expiry is enforced at read time, not by a sweeper, so there is no cron to configure and nothing to clean up: a row past its expires_at is dead the moment it is read. Expired and revoked rows are kept as the owner's record of what was shared. All three tables are covered by self-serve account deletion — nothing cascades them otherwise, and an outstanding link must not outlive the account that made it.

One deployment note: the public page lives at /s/<token>, outside src/app/app/, which is where the session guard lives. If you front the app with a reverse proxy or an SSO gateway that requires authentication for everything, allow /s/ through, or share links will fail for exactly the audience they exist for. /s/ is also in the robots.txt disallow list and the page sets noindex. What a recipient can and cannot see is covered in the Me & share links guide.

Attached files, and the database they live in (core)

Attachments — a signed contract, a deck, a spreadsheet hung off a contact or a company — are fully core. The repo (apps/web/src/lib/repo/attachments.ts), the POST /api/attachment upload and the GET/DELETE /api/attachment/[id] routes import nothing from @dhaga/ee, so they're unaffected by Level 1 and Level 2 and don't belong on the deletion list above. There is no object storage, no third-party bucket and no new external dependency: the bytes are base64 in a Postgres text column, the same shape card photos already use, so a self-hosted instance holds its users' documents on its own disk. No AI touches them either — no extraction, no enrichment, zero credits — so the whole feature works on an instance with no LLM provider configured at all.

What it does cost you is database size, and that is the one thing to plan for. base64 inflates a file by ~33%, so a 4 MB attachment is ~5.3 MB of text, and it lands in the row itself (Postgres TOASTs a value that large out of line and de-TOASTs it on every read of the payload). Card photos have exactly this shape, but they are downscaled JPEGs; an attachment is whatever document the user picked, so the per-row cost is far higher — and it rides along in every pg_dump. Size your storage and your backup window for the documents your users will actually keep, not for the graph alone.

The 4 MB cap is a Vercel constraint, and a self-host does not share it. MAX_ATTACHMENT_BYTES (apps/web/src/utils/constants/app/attachments.ts) is 4 MB because Vercel Functions cap a request or response body at 4.5 MB and reject anything past it with 413 FUNCTION_PAYLOAD_TOO_LARGE before the handler runs — an infrastructure limit no configuration raises (Vercel function limits). 4 MB leaves headroom for the multipart envelope on top of the file. A Docker/Node deployment has no such platform limit, so raising that constant is safe on a self-host, and the only real ceiling there is your own database and backup budget. It is not safe on Vercel: a larger number there only buys a file that passes the picker, passes the repo check, and is then refused by the platform with an error the app never sees. Raise the constant, not just the picker — saveAttachment re-checks it at the write, because that is the last point before the bytes become a permanent row.

Accepted formats are one list, ATTACHMENT_TYPES (PDF, Word, PowerPoint, Excel, plain text, CSV, JPEG/PNG/WebP). Downloads are always served application/octet-stream with Content-Disposition: attachment and nosniff, never the stored media type, so a stored .html or .svg can't execute script on your instance's own origin.

Getting the files back out is core too. GET /api/export/archive streams one zip holding the JSON dump plus every stored file, reading payloads a row at a time so memory stays at one file rather than the account. It uses fflate, a zero-dependency pure-JS writer, so there is no native module and nothing platform-specific to install. The maxDuration = 300 on that route is a Vercel ceiling and does not apply to a Docker/Node deployment — a self-hosted instance can stream for as long as its own proxy allows. What does still apply is the zip format itself: the writer emits a plain, non-ZIP64 archive, so 65,534 files or 4 GB is a hard bound, checked before a byte is written and refused with a 413 rather than silently truncated.

The EE-side touches are additive and inert without hosted mode: packages/ee adds attachments to its TENANT_TABLES with a bespoke WITH CHECK policy — the contact or company a file names must be in the same tenant — so the table gets RLS when multi-tenancy is on. The core's own DDL creates the table either way, and attachments is on ACCOUNT_OWNED_TABLES and on both the contact and the company delete cascade, so forgetting a person takes their documents with them.

Facts and enrichment on a company page (core)

A company page carries the same two ways of learning something a person page does, and they sit on opposite sides of the LLM line:

  • Adding a fact by hand needs no LLM and no credits. addFact takes a contact or a company owner, and a hand-typed fact is written with a null source_note_id — no source note, no extraction job, no model call, no AI budget check. It works on a core-only self-host with no ANTHROPIC_API_KEY set, exactly as manual person facts and manual follow-ups already do. The only thing it spends is the free on-device embedder, so the fact stays semantically searchable.
  • "Enrich from public web" on a company needs a provider. The gate is identical to person enrichment on purpose: the same enrichment plan feature, the same AI budget check, and one 20-credit enrichment action — researching a company must not become a cheaper back door into the same web searches. Findings are saved as a note on the company with cited sources, and the facts extracted from it land unverified with that note as their receipt, one tap to confirm. Delete the note and everything derived from it goes.

On a self-host the plan half of that gate is a no-op — with billing not running currentPlan() resolves to self_hosted, which holds every feature including enrichment. What actually decides whether the button works is whether you configured an LLM provider: without one the action answers "Configure an LLM provider to enable enrichment" rather than enqueuing a job that could never run.

Disabling just billing (keep admin + the approval queue)

If you're running the hosted product but not ready to charge (a free beta, for instance), you don't need to touch DHAGA_HOSTED_MODE. Simply leave the processor credentials unset — both of them: STRIPE_SECRET_KEY, and RAZORPAY_KEY_ID / RAZORPAY_KEY_SECRET. The settings page's "Plan & billing" section checks for them itself and renders nothing — not a broken "Upgrade" button, no section at all — while the admin panel and the approval queue keep working normally. /pending then shows no "skip the queue" section either, since the only way in is an admin. Setting just one of the two is a valid configuration, not a half-off switch: the section appears and sells through whichever processor is configured.

Credentials alone are not enough to sell anything, and the difference bites quietly. RAZORPAY_KEY_ID/RAZORPAY_KEY_SECRET decide whether the processor is enabled; the per-plan ids (RAZORPAY_PLAN_PRO_MONTHLY, RAZORPAY_PLAN_PRO_YEARLY, RAZORPAY_PLAN_POWER_*, and STRIPE_PRICE_* on the other side) decide whether a plan can be boughtavailableCombinations skips every combination whose id is unset. Keys with no ids behind them therefore render no buttons at all, with no error anywhere. If you expect INR checkout and see none, check the plan ids before you suspect the keys.

The four introductory prices are a separate, Razorpay-only set of vars — RAZORPAY_OFFER_PRO_MONTHLY, RAZORPAY_OFFER_PRO_YEARLY, RAZORPAY_OFFER_POWER_MONTHLY, RAZORPAY_OFFER_POWER_YEARLY — and they hold Razorpay Offer ids, not plan ids. An introductory purchase is the standing Plan with the offer attached to subscriptions.create as offer_id: the offer discounts the first N cycles and Razorpay steps the price up by itself when they run out. Offers are Dashboard-created — the API cannot create, list or read one — so an environment variable is the only way an id reaches the app; one per (tier, cadence) is enough, because Razorpay resolves the payment-rail variant itself. Each pair is checked on its own (getIntroOffers() / hasIntroOffer()), so an instance may legitimately sell two of the four and simply not offer the others, and with none of them set every surface shows the standing price and every button sells the standing plan — the correct default, not a degraded one. Checkout fails closed the other way: an introductory cadence that reaches it with no offer id configured is refused outright rather than silently charging the standing amount. The four older RAZORPAY_PLAN_*_INTRO_* plan ids are not a purchase path any more — they sit in the resolve-only table beside the _LEGACY ids so that a plan id which could appear on a historical row still resolves to a tier rather than throwing. An introductory price is still first-purchase-only: nothing on the plan ladder moves an existing subscriber onto one, and changing plan forfeits it.

That offer has no closing date — it is a per-customer 12-month term (INTRO_TERM_MONTHS, billing/intro/term.ts): a buyer holds the introductory price for their own first twelve months, counted from their own subscription start, then pays the standing price for that tier and cadence. A 12-cycle monthly offer and a 1-cycle yearly offer both mean "your first year" — a yearly plan bills once a year, so twelve cycles there would discount twelve years. Whether the prices are still sold is a runtime admin toggle rather than an env var or a constant — introOfferOpen() / setIntroOfferOpen() (billing/intro/availability.ts) over the intro_offer_availability key in EE's billing_settings table, defaulting to open, closing only to new buyers and never touching anyone already subscribed. The step-up needs no code of ours at all, which is the point of the design. It used to be a nightly sweep that booked each finished term onto the standing Plan, and that sweep was a confirmed defect: measured against Razorpay on 2026-08-20, PATCH /subscriptions/{id} with {plan_id, schedule_change_at:"cycle_end"} was refused with a 400 on both Indian payment rails — card: "Only offers can be updated for subscriptions when payment mode is domestic card."; UPI: "subscriptions cannot be updated when payment mode is upi" — and the e-mandate registered max_amount at the plan amount, ₹199, so the ₹499 debit would have been refused anyway (the ₹99,000 SDK default previously cited belongs to the registration flow, not to Subscriptions; that inference was wrong). Under the offer model neither constraint is met by any code path: the mandate registers at the standing amount — that is what makes the step-up automatic, and why a buyer's bank shows a cap higher than their first charge — and no update call is ever made. runIntroPriceStepUps, billing/intro/step-up/ and updateSubscriptionPlan() are deleted. Honest limits: the offer path was measured end to end in the sandbox, on both rails (₹499 plan + limited-cycle offer → ₹199 invoice paid, mandate max_amount ₹499), and nothing has been sold at an introductory price yet, so no term has ended in the field. A separate, pre-existing bug fell out of the same refusal — ordinary tier changes went through the same call — and that one is fixed (2026-08-21) by a different design again: a Razorpay plan change now mints a second, future-dated subscription the customer authorises, so upgrading Pro → Power works again. None of this is reachable on a self-hosted deployment, which runs no billing at all. What still rides the cron is the warning, re-keyed: /api/jobs/daily runs runIntroStepUpNotices (apps/web/src/lib/jobs/intro-step-up/notices.ts, email template lib/email/intro-step-up.ts) on the same CRON_SECRET as the other jobs there. The old "sweep before notice" ordering went with the sweep — there is no booked change left to read, so the ~30-day notice derives the term from subscriptions.intro_offer_id (a nullable column recording the offer a purchase was made with; the row's cadence stays monthly/yearly, because the subscription is on the standing Plan) plus the deterministic introTermEndsAt(), and quotes the computed anniversary. The final-month in-app banner ships alongside the email (apps/web/src/components/app/billing/, mounted in app/app/layout.tsx): it reads those same two things, so the two warnings quote one date, and it is pure over the plan summary the app shell already loads, so it costs no extra read for anyone not on an introductory price. Both survived the sweep's deletion deliberately — a customer whose price is about to rise deserves the warning whichever mechanism raises it. Two channels, and neither has yet reached a customer.

The whole subscription lifecycle sits behind the same wall. Plan changes (packages/ee/src/billing/plan-change/), cancel and resume, the introductory term, availability toggle and term-end notice (billing/intro/), the charge/refund/dispute ledger (billing/payments/) and the webhook receivers (billing/webhook/, billing/razorpay/) are all EE-only, and the subscriptions, payments and billing_settings tables they write are created by EE's schema — a core-only self-host has neither the code nor the tables, and needs neither. Its hosted-gate defaults (apps/web/src/lib/hosted/gate/defaults.ts) hand back an empty offer list, so there is no introductory pricing to configure and nothing to schedule. The Level 2 removal list above already covers this: it deletes packages/ee/ and the two API route folders wholesale, so new billing modules never add entries to it.

The pending-approval gate (hosted only)

On a hosted instance signup is open — anyone can create an account — but a new account is created unapproved (user.approved_at is null, a column packages/ee adds; core's own schema never has it). An unapproved account can authenticate and reach exactly three things: /pending, the checkout that pays for it, and sign-out. Every other /app/* page redirects to /pending and every authenticated API route refuses it, enforced once in apps/web/src/lib/auth/guard.ts.

Approval is granted by an admin approving the access request, by a payment the processor has confirmed (the webhook — never at checkout-intent time, so an abandoned checkout grants nothing), or by an admin comp plan. A refund or chargeback revokes it; a cancellation does not.

None of this exists on a self-host. Without packages/ee the ApprovalGate falls back to its permissive default — isApproved is always true, /pending is unreachable, and the approved_at column is never even created. Same hosted-gate pattern as billing and the admin gate (apps/web/src/lib/hosted/gate).

That default is also what keeps the welcome email honest here. The one onboarding email a new account gets (apps/web/src/lib/auth/config/welcome.ts) asks the same gate at send time and only mentions the waiting list — and the option to subscribe past it — when the account is unapproved. On a core-only self-host that never happens, so your users get the product-guide link and nothing else: no queue is promised where no queue exists, and nothing is offered for sale on an instance with no billing.

Referral rewards (hosted/EE only)

The two-sided referral program (a free month of Pro for both advocate and referee) lives entirely in packages/ee and only functions in hosted mode — it extends a user's subscriptions row, which the core has no concept of, and a self-host is single-user anyway (there's nobody to refer). On a self-host the referral surfaces are simply absent: /api/referral returns { referral: null } and /app/referral shows an "unavailable" note — the same permissive-fallback pattern as billing (getReferralGate() in apps/web/src/lib/hosted/gate).

In hosted mode the reward is delivered Stripe-safely. An advocate who already has a live Stripe subscription is given a Stripe coupon — set STRIPE_REFERRAL_COUPON_ID to a duration: once, 100%-off coupon you create in the Stripe dashboard — while free/comp users get an additive comp Pro month (their current_period_end is extended, never downgrading a higher tier). If STRIPE_REFERRAL_COUPON_ID is unset when a paying advocate qualifies, the grant fails loud and the referral stays pending for retry rather than silently half-rewarding.

Creating the first admin user

There's a deliberate chicken-and-egg problem here: the admin panel can only promote a user to admin if you're already an admin, and (in hosted mode) a new account lands unapproved on /pending, which normally only an admin can clear. DHAGA_ADMIN_EMAILS breaks that circle:

  1. Set DHAGA_HOSTED_MODE=true and DHAGA_ADMIN_EMAILS=you@yourdomain.com (comma-separated if more than one) in packages/ee's environment.
  2. Go to /signup and create an account with that exact email address. These emails are approved on the way in rather than parked on /pending, and an admin is let through regardless of approved_at — so an admin can never be locked out of their own instance.
  3. You're now an admin automatically — isAdmin checks DHAGA_ADMIN_EMAILS in addition to the database flag, so nothing needs to be flipped manually. /app/admin is live for that account immediately.
  4. From /app/admin/users, you can now promote other accounts by setting their isAdmin flag through the UI — they don't need to be in DHAGA_ADMIN_EMAILS themselves once that's done.

DHAGA_ADMIN_EMAILS is safe to leave set permanently as a break-glass path (e.g. if you ever lock yourself out of the only admin account) — it's env-config, not a stored credential, and only your deployment operator controls it.

Managing a user's subscription and AI allowance (hosted/EE admin)

From a user's detail page (/app/admin/users/[id]) an admin can, without Stripe, comp that user's access:

  • Plan — set free, pro, or power. free revokes the comp — usually by removing the subscription row, so the account falls back to the instance default allowance (see the rule below); pro and power move it onto that plan's monthly allowance — 300 credits a month for Pro, 1,000 for Power.
  • Expiry — an optional date on a paid plan. Once it passes the plan stops being in play and the account drops back to the instance default allowance (leave it blank for no expiry).
  • AI credits — a per-user monthly cloud-AI credit allowance, stored as the ai_monthly_cap_override setting. It sits at the top of the precedence ladder for that one user, beating a running promotion, the plan allowance and the instance default alike; blank or 0 clears it. Credits are charged per user-visible action, not per model call — a card scan costs 1 credit whether it takes one round-trip or three, and deep research costs 20 (packages/core/src/metering/credits.ts, BRD §8.3).

An admin can comp a plan up, and can lower one only as far as the tier the user actually pays for. That floor is the whole rule — an admin may take back exactly what an admin gave, and no more. Raising a tier is always allowed.

  • Nothing billing (no row, a pure comp, a cancelled or never-completed subscription) — the floor is free, so every option is settable.
  • A goodwill comp granted on top of a live subscription (bumping a paying Pro to Power) — the floor is that paid tier. The bump is reversible back to Pro, and no further, because below it is where the money is.
  • A genuinely paying subscription — the floor is the user's own plan, so nothing below it can be set at all. Those changes belong in the customer's own Plan & billing settings, or in the processor dashboard: our row and the processor would otherwise disagree and the card would keep being charged for access we just revoked.

The plan selector disables exactly the options the server would refuse and states why — but the refusal itself is enforced server-side and re-checked inside the transaction, so it holds however the request arrives.

Two facts are recorded on the row at the moment a comp is granted, rather than inferred from it afterwards: that an admin granted it (subscriptions.admin_granted) and what it was granted over (admin_granted_over_plan / admin_granted_over_status). Inferring broke exactly where it mattered, because comping an existing row keeps that row's processor ids and sets the status to active, so a plan comped to unblock a user whose first charge never settled read back as a paying customer and could never be lowered again. The underlay can't be re-read from the processor either: the whole plan/entitlement path is deliberately DB-only, and the comp overwrote the columns that held the answer. A comp granted before those columns existed has no underlay, which reads as "nothing paid underneath" — the behaviour it already had.

The flag is cleared the moment a processor reports a paying status (active or past_due) on any write path, so a comp never outlives the comp: when a stuck 3DS charge finally settles through customer.subscription.updated, the row becomes an ordinary paying customer and the lock comes back. A status that bills nobody leaves the flag alone — a comp over an abandoned checkout is still a comp.

Setting an account back to free revokes the comp; it does not necessarily cancel a subscription. Those are different things, and conflating them aborted payments that were still on their way:

  • The row carries a processor subscription still waiting on its first charge (incomplete) — the comp is undone: the row goes back to the plan and status the comp overwrote and keeps its processor ids. Nothing is cancelled and nothing is deleted, so "move them to Pro, then back to free until their payment goes through" leaves the payment able to go through, and the later webhook still finds the row by subscription id. Deleting it would be worse than cancelling — the charge would settle against no row at all and the user would pay for access they never receive.
  • Anything else — whatever processor subscription the row still carries is cancelled before the row is deleted, so a downgrade can't leave a processor billing a subscription the database has forgotten.

These controls live only in the EE admin panel. On a core-only self-host billing isn't running, so no plan is ever in play and every user resolves through the instance-wide default — which is what DHAGA_AI_MONTHLY_CAP seeds (see the env table below).

Instance-wide AI credit controls (/app/admin/ai-credits, hosted/EE)

Beside that per-user override, /app/admin/ai-credits (titled "AI cost & credits") carries five levers that apply to the whole instance — three that size the credit allowance, and two that size the independent dollar ceiling behind it:

  • Plan-cap enforcement — a master switch that is on by default (AI_PLAN_CAP_ENFORCEMENT_DEFAULT = true). In the shipped state every user is held to the monthly allowance for their plan: Free, Pro and Power each have a number. That is what the pricing page states — it sells Pro and Annual as 300 credits a month and says what runs out when they do — so leave it on unless you have a reason not to. Turning it off is an escape hatch (a migration, an incident), not a resting state: the allowances below are then stored but ignored, every plan resolves through its raw billing entitlement (hasUnlimitedAi) instead, and users with no plan fall back to the instance default. Promotions and grants keep working either way.
  • Monthly allowance per plan — runtime-editable overrides of the shipped numbers (PLAN_AI_CREDITS_PER_MONTH), per plan, each of which can also be set to "no cap". Free is editable here exactly like Pro and Power, and it does double duty: whatever it is set to is also the instance-wide default (rung 4 below). The card names the live number and where it came from — e.g. "Effective default: 10 credits / month — from the shipped default in code", or from "the Free allowance set here", or from "the DHAGA_AI_MONTHLY_CAP seed".
  • Promotional month — lifts every user to one allowance for a window ("everyone gets 1,000 credits this month"). It works whether or not enforcement is on, and it ends at the start of its end date, evaluated on every read — so it expires by itself with no cron job and no admin cleanup.
  • Dollar-ceiling enforcement — a second master switch, also on by default (AI_DOLLAR_CAP_ENFORCEMENT_DEFAULT = true), for a per-user monthly ceiling denominated in real inference dollars rather than credits. It exists because credits stopped bounding spend: three metered features cost 0 credits on purpose (the nightly signal, person-classification and goal-match sweeps — billing them would be ~26× their real cost), so an uncredited sweep moves no counter but still costs money. The gate is enforced inside the same metering path as the credit cap, so it covers every action including those three, and it is checked after credits — the credit message is the one a user can act on (upgrade); this one is the operator's backstop.
  • Multiplier and floor — the two numbers that turn a subscription into a ceiling: its monthly revenue × multiplier (default 3.0), or, for any plan with no recurring revenue, a flat floor in USD (default $0.50). So Pro bought at the standing monthly price (₹499 ≈ $5.74) resolves to a $17.21 ceiling, and the same tier bought on the introductory monthly offer (₹199 ≈ $2.29) resolves to $6.86. The floor is not a rounding detail — Free earns $0, and $0 × any multiplier is $0, which would refuse every AI action a free user takes, including the ten their credit allowance is meant to buy. The card shows a per-plan ceiling table live as you change either number, but read it as representative: that screen lists plans, not subscribers, so it has no cadence or currency to resolve against and feeds the arithmetic the standing-USD figure (PLAN_MONTHLY_REVENUE_USD), where a real user's gate feeds it their own subscription. A per-user ai_monthly_dollar_cap_override beats both (0 is a valid override, unlike its credit sibling).

The revenue basis was reversed on 2026-08-19, and the multiplier moved with it. The ceiling used to be sized from the plan's standing list price, with offer prices structurally kept out of the arithmetic. It is now sized from what this subscriber actually pays — tier × cadence × the currency the processor charges, introductory offer prices included — resolved by monthlyPlanRevenueUsd() in apps/web/src/utils/constants/ai-budget/plan-revenue.ts. (That path is a directory, not a file: the old utils/constants/ai-budget.ts was split under the 150-line rule, and the import path @/utils/constants/ai-budget is unchanged.) A subscription with no cadence to read — an admin comp, a referral grant, a processor we could not reach — falls back to the standing price rather than to $0, because refusing it a basis would be a $0 refusal. INR is converted through a pinned INR_PER_USD = 87 (utils/constants/pricing/currency.ts) that exists only to denominate this backstop and never charges anyone: a few percent of FX drift moves a ceiling by cents, where a live rate would put a network call — and an outage mode — behind every metered AI action.

The multiplier went 2.0 → 3.0 in that same change, and the reason matters more than the number. Sizing the ceiling off the price paid pulls the thinnest charged row down to Pro introductory yearly, ₹167/month ≈ $1.92. The measured heavy-user month in BRD §8.3 is $2.84 of inference, plus an estimated (never measured) ~$0.40 of uncredited watchlist scanning ≈ $3.24 — at 2.0 that is 84% of a $3.84 ceiling, so real paying customers would have started being refused mid-month by a gate they had never met, with nothing on screen explaining why. At 3.0 the ceiling is $5.76 and the same month sits at 56%: a backstop again. The constraint did not disappear when the invariant flipped, it changed shape — it used to be "an offer price must never reach the ceiling", and it is now "the multiplier must clear the measured heavy month on the cheapest thing we sell", which has to be re-checked every time a cheaper offer is added or an uncredited feature grows. apps/web/src/lib/__tests__/ai-action-metering/dollar-cap.test.ts fails if it stops holding.

On a core-only self-host the dollar gate is inert. Its bottom rung is deliberately no ceiling rather than a number: with billing not running no plan is ever in play, so effectiveMonthlyDollarCap() resolves to null and never refuses an action. That is the opposite of the credit ladder, whose bottom rung (the instance default) is a real number — and it is intentional, because a self-hoster pays their own provider bill and inventing a dollar ceiling they never asked for would break their instance. There is no new environment variable: the multiplier, floor and switch live in ai_budget_settings and there is no DHAGA_AI_MONTHLY_DOLLAR_CAP. DHAGA_AI_MONTHLY_CAP is still credits, and still the only AI-budget env var. Resolver: apps/web/src/lib/ai/metering/dollar-cap.ts.

The grant form on this page is additive make-good credits for everyone on the instance — it always broadcasts, with no free-text user id field — with a required reason and an expiry that defaults to the end of the current month. Granting to one specific user instead happens from that user's own /app/admin/users/[id] page, where the same card is pinned to them. Either way, a grant only moves the ceiling — ai_actions, the only record of what cloud AI actually cost, is never rewritten, and "End now" stops a grant counting without deleting its row.

Every grant ever made lives in its own searchable, paginated ledger at /app/admin/ai-credits/grants (linked from this page) — search matches the recipient's name or email, or the word "everyone" for broadcast grants.

Precedence, highest first (apps/web/src/lib/ai/metering/cap/index.ts):

  1. Per-user admin override (ai_monthly_cap_override) — wins outright, including over a running promotion.
  2. Active instance-wide promotion — applies whether or not enforcement is on.
  3. Plan allowance — when the master switch is on (it is, by default) and a paid plan is in play. The admin-edited value if one is set, else the constant in apps/web/src/utils/constants/plans/; null means no ceiling.
  4. The instance default (instanceDefaultCap() in apps/web/src/lib/ai/metering/cap/instance-default.ts): the admin-set Free allowance, else DHAGA_AI_MONTHLY_CAP, else the shipped FREE_TIER_AI_CREDITS_PER_MONTH (10 credits a month).

Then, on top of whichever rung won, every active grant for this user is added.

Rung 4 is the one that catches a free user, a user no plan governs (a self-host, where billing isn't running), and everyone when the master switch is off. Free users resolving there rather than through the plan ladder is deliberate: it means DHAGA_AI_MONTHLY_CAP means the same thing on a self-host as it does on an instance that has billing. Note what "seed" implies — the env var supplies the instance default only while nothing has been set in the database. The moment an admin sets a number (a per-user override, a promotion, a plan allowance, or the Free allowance), that stored number wins and the env var stops mattering. Nothing is copied into the database at boot; env is simply read last, so there is one live number and the admin screen can say where it came from.

What a core-only self-host gets. The two tables this feature stores its state in — ai_budget_settings and ai_credit_grants (apps/web/src/lib/db/ddl/ai-budget.ts) — belong to the core, so they are created on your database whether or not packages/ee is present. Nothing else follows from that: there is no admin UI to write to them (both simply stay empty), and no row-level security on them either — ai_credit_grants gets its bespoke user_id IS NULL OR user_id = <tenant> policy only from packages/ee/src/db/rls-ddl.ts, and ai_budget_settings deliberately gets none anywhere, being operator config rather than user data. With both tables empty and no billing running, every user resolves at rung 4 and one number governs the whole instance: whatever DHAGA_AI_MONTHLY_CAP seeds, else the shipped 10 credits a month — and the dollar ceiling resolves to none, as described above. Nothing here is required from packages/ee to self-host, and the Level 2 removal list above needs no additions — the new admin page and its server actions and components live under apps/web/src/app/app/admin/, apps/web/src/lib/actions/admin/ and apps/web/src/components/app/admin/, which are already on it. The dollar gate added files in those same three directories (plus packages/ee, which Level 2 removes whole); its own resolver, cost helper and constants are core, and the ai_actions.batch column it reads is created by the core DDL like any other.

Running with docker compose up

The repo root has a Dockerfile and compose.yml that run the web app plus a Postgres 16 + pgvector container. The app creates its own schema (including the vector extension) on first connection — there is no migration step.

  1. Create a .env file next to compose.yml:

    BETTER_AUTH_SECRET=   # openssl rand -base64 32
    # Optional: ANTHROPIC_API_KEY, BETTER_AUTH_URL (defaults to
    # http://localhost:3000), POSTGRES_PASSWORD (defaults to "dhaga" —
    # change it if the DB port is ever exposed), RESEND_*, DHAGA_*
  2. docker compose up --build

  3. Open http://localhost:3000 and sign up. Contact data lives in the dhaga-db volume; docker compose down keeps it, down -v deletes it.

None of the packages/ee vars are wired into compose.yml — this is the plain self-host path (Level 1 above).

Contact import and contact sync are both fully core — the .vcf/CSV file importer, the mobile POST /api/import endpoint, and the OAuth contact connectors all live in the core (no @dhaga/ee). The Connect Google / Outlook buttons are env-gated: they appear only when GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET or MICROSOFT_CLIENT_ID/MICROSOFT_CLIENT_SECRET are set, so a self-host with none of them simply shows file import — no missing-feature errors. See docs/CONTACT_IMPORT_SETUP.md to configure the connectors.

A connected account is a row in contact_connections, one per (provider, account_email), so several Google and several Microsoft accounts can be connected at once. Its tokens are encrypted at rest with AES-256-GCM (lib/crypto/tokens.ts, keyed off CALENDAR_TOKEN_SECRET falling back to BETTER_AUTH_SECRET) — a different mechanism from better-auth's encryptOAuthTokens, which covers social sign-in tokens in the account table. Connected accounts re-sync on a schedule: the daily cron at /api/jobs/daily covers it, and /api/jobs/contact-sync (same CRON_SECRET bearer auth) can be driven more often if you want changes to land sooner.

Custom database deployments

compose.yml is a working reference, not a requirement — DATABASE_URL can point at any Postgres 15+ (self-hosted, RDS, Neon, Supabase, …). What the app needs from the database:

  • pg_trgm — always; it's a contrib extension every Postgres ships, and the app's boot DDL runs CREATE EXTENSION IF NOT EXISTS itself.
  • pgvector — needed by the default semantic search, optional if you set DHAGA_VECTOR_STORE to a registered external vector store (see Providers); the boot DDL skips the vector schema entirely in that case.
  • Any pooling mode works (hosted mode only) — tenant scoping is transaction-local: each unit of work runs inside one BEGIN … COMMIT whose first statement is set_config('app.current_user_id', …, true), and Postgres discards that setting at COMMIT. Because the scope never outlives its transaction, a transaction-mode pooler (Supabase's port 6543, PgBouncer, Supavisor, Neon's -pooler endpoint) can't run a query unscoped or leak the setting onto another user's backend — so a direct connection, a session-mode pooler, and a transaction-mode pooler are all safe, with no pooling-mode boot guard to satisfy (the earlier one, and its DHAGA_ALLOW_TRANSACTION_POOLER override, were removed as obsolete). Supabase specifically: moving from 5432 to 6543 is a DATABASE_URL change with no code change — the 6543 path is designed-correct but not yet verified against a live transaction pooler; see docs/SCALING.md §2.
  • A role without BYPASSRLS or SUPERUSER (hosted mode only) — either attribute makes the role ignore RLS (a superuser bypasses it unconditionally even while rolbypassrls reads false), and the boot guard rejects both. Run packages/ee/scripts/create-app-role.sql and connect as dhaga_app; see Deploying's "The Postgres role DATABASE_URL connects as matters" for why the provider default role is dangerous. Plain single-user self-hosting (hosted mode off) needs none of this — any role that can create tables works.

Geocoding, the Home globe, and calling windows

The city map resolves a contact's free-text location through the core geocoding gateway (GEOCODING_PROVIDER, default nominatim). Public Nominatim needs no API key, but Dhaga enforces its one-request-per-second ceiling and stores each distinct answer in geocode_cache; use NOMINATIM_URL for your own instance. Every map keeps OpenFreeMap/OpenStreetMap attribution visible.

Home's globe is a read-only projection of that cache. It never queues geocoding while rendering and sends only city coordinates, counts, time-zone ids and calling-window status to the browser — no contact names. tz-lookup (CC0-1.0) converts cached coordinates to an IANA zone offline on the server; Intl supplies current daylight-saving rules. There is no time-zone API, key, account, request bill, or new contact-data recipient.

The globe adds no hosted service. Three.js (MIT) runs in the browser; bundled NASA-derived Earth textures and the Dhaga-generated thread illustration are ordinary same-origin static assets. Exact sources, usage notes and credits live in apps/web/public/assets/{globe,home}/README.md. None of this depends on packages/ee.

Nightly signal detection (job-change + news watchlist, opt-in)

The web-search sweep behind a contact's "Watch for job changes & news" toggle (BRD §6.7) runs from /api/jobs/detect-signals, not a background process — there is no job queue to run in a container. Point any scheduler at it:

0 6 * * * curl -fsS -H "Authorization: Bearer $CRON_SECRET" \
  https://your-domain/api/jobs/detect-signals

Requires CRON_SECRET and a search provider. Since 2026-08-08 the default provider is Anthropic's own server-side web_search tool, so ANTHROPIC_API_KEY on its own is enough — FIRECRAWL_API_KEY is optional and only takes precedence where you set it. See "Search providers" in Providers and .env.example. Without CRON_SECRET the route always returns 401, so it's safe to leave unconfigured if you don't want the feature. Without any search provider the sweep returns { skipped: "no_search" } and writes nothing, and the contact-page toggle that would enrol someone in it is greyed out "Coming soon" rather than arming a scan that can't run — see "Optional providers, and what the UI does without them" below.

Two honest caveats. This path has never been run against a live Anthropic key — it typechecks and is unit-tested, but no end-to-end sweep has been observed, so treat it as armed rather than proven. And it is not free: Anthropic bills $10 per 1,000 searches on top of charging every retrieved page as input tokens to the searching model. The token half is recorded against your instance's dollar ceiling; the per-search charge is not.

Nightly curation passes (person/service classification, goal matching)

Two more sweeps ride the /api/jobs/daily endpoint below rather than a queue, both over the Anthropic Message Batches API and both zero-credit (see BRD §8.3):

  • Person-vs-service classification labels imported address-book rows ("Ola Support", "Vegetable Vendor") so they stop appearing on proactive surfaces. Nothing is deleted or hidden — the row stays in People, search, merge, Wrapped and every export.
  • Goal matching judges contacts against the one objective a user has set, writing the cohort that Home's goal tile and /app/goal draw their daily slice from. Anything the model scores below GOAL_MIN_FIT is stored rejected rather than dropped, so the next night's pass reaches new people instead of re-judging the same ones. Goal people are not merged into the daily suggestions — that surface is theirs alone.

Both are two-phase, exactly like signal detection: one invocation applies the previous run's batch and submits a fresh one, so a graph drains over several nights rather than in one call. Each is capped per run (PERSON_CLASSIFICATION_RUN_CAP 1,000 contacts, GOAL_MATCH_RUN_CAP 150) and each reports a remaining count in the endpoint's JSON — contacts still to classify, and cohort slots still to fill — so you can watch a backfill drain. A goal batch pointer that is never applied expires after GOAL_MATCH_POINTER_MAX_AGE_MS (36h) and is abandoned, so one wedged or expired batch cannot freeze a user's goal matching indefinitely. Both need a working LLM provider; without one they return skipped: "no_llm" and change nothing. They run before the daily brief on purpose, so its reach-out section reflects the freshest labels.

The daily brief (one scheduled email a day)

Dhaga sends at most one scheduled email per person per calendar day, the daily brief, from the one /api/jobs/daily endpoint, on the single Vercel cron in apps/web/vercel.json ("17 6 * * *", unchanged).

Until 2026-08-09 the reach-out digest, the confirmations digest, the morning follow-up reminder, the due-follow-up sweep, the birthday/anniversary reminder and the LinkedIn-export nudge each decided independently whether they were allowed to send, so one person could receive three messages inside a minute. They are now sections of one message (lib/jobs/daily-brief/, one file per section under sections/) and the send decision is made once, in sweep.ts.

Sections appear in urgency order — the subject line comes from the first one that has content — and each still honours the toggle it always had in Settings → Suggestions:

SectionWhat it listsPer-user toggle
Follow-ups dueCommitments due inside FOLLOW_UP_LEAD_DAYS (3), plus overdue onesmorning_reminder_enabled
Important datesBirthdays and anniversaries inside the user's lead timeimportant_date_reminders_enabled
Waiting for your reviewQueued confirmationsconfirmations_digest_enabled
People to reach out toToday's suggestions, or threads going quietdaily_digest_enabled
Also waitingTotals for the whole open backlog, not just the lead windowmorning_reminder_enabled
Your LinkedIn exportThe day-1/3/6/7 upload nudgeNone — clicking "Get contacts from LinkedIn" is the opt-in, unchanged

Three consequences worth stating plainly:

  • If every enabled section is empty, nothing is sent. There is no "you have nothing today" email; a long-time user having a quiet Tuesday hears nothing.
  • daily_digest_enabled is the daily check-in. When the suggestion engine has nobody due, the reach-out section falls back to contacts going quiet (listQuietContacts, capped at QUIET_CONTACTS_IN_BRIEF = 3) rather than vanishing. If that is empty too the section is omitted, so a check-in day can still end in no email at all.
  • Empty accounts get an activation nudge instead. If there is nothing to report and the graph has zero contacts and morning_reminder_enabled is on, the brief is replaced by a short email pointing at adding a first contact and the product guide. At most ACTIVATION_NUDGE_MAX (3) sends, ACTIVATION_NUDGE_INTERVAL_DAYS (7) apart, tracked in the activation_nudges_sent settings key, then silence forever. It is an alternative to the brief, never an addition, and shares the same one-send-per-local-day record.

Both the brief and the activation nudge carry an opt-out line — "If you'd rather not receive these, turn them off in Settings → Suggestions", linking to /app/settings#suggestions (notificationEmailShell, lib/email/send.ts). Transactional mail deliberately does not: welcome, email verification, password reset, magic link, access-request and approval notices, admin notices, feedback, the user-triggered event digest and background-job notifications keep the plain shell, because no setting turns those off and the line would be a promise the Settings page cannot keep.

Duplicate suppression is one settings record per user, daily_brief_last_local_day, covering the brief and the nudge together. The five retired per-job records (morning_reminder_last_local_day, daily_digest_last_local_day, confirmations_digest_last_local_day and friends) are inert and left in place — no migration deletes them. One-off consequence on upgrade: a user who already received the old emails on the morning this ships can also receive one brief that day, because the new record starts empty.

The whole thing degrades to a clean no-op without RESEND_API_KEY / RESEND_FROM_EMAIL (and, on a single-user self-host, DHAGA_OWNER_EMAIL), and the endpoint reports it under a single dailyBrief key — { sent, activation, skipped } — where there used to be six (digest, confirmationsDigest, reminder, followUpReminders, importantDateReminders, linkedinReminders).

Each user's time zone (Settings → Suggestions → Time zone, default UTC) decides which calendar day the brief is reasoning about, so a birthday lands on the recipient's day rather than the server's, and a re-triggered cron is a no-op for someone already emailed on their local day. What the time zone does not yet change is when the mail goes out: every send still happens on the one cron run, at whatever UTC time it fires. Per-user local-morning delivery needs the endpoint driven hourly with EMAIL_JOBS_HOURLY=true (see the env table below).

Don't add a sub-daily cron on Vercel Hobby

Vercel Hobby caps crons at once per day, and a more-frequent entry in apps/web/vercel.json can break Hobby deploys — so hourly delivery is not available there. Off Vercel, an hourly system crontab or container timer hitting the same URL with the same Authorization: Bearer $CRON_SECRET header is enough.

The plan-lapse notice ("your plan has ended")

One more email rides the same /api/jobs/daily endpoint, and it is deliberately not inside the one-scheduled-email-a-day budget above: lib/jobs/plan-lapse. When a paid plan stops — a subscription that stopped renewing, an admin comp that hit its expiry, a spent referral month — nothing flips a column, because entitlement is decided at read time. The paid features simply stop being there, silently. This is the message that says so.

It is transactional, like a password reset, so it renders through the plain emailShell and carries no opt-out footer — there is no toggle in Settings that silences a billing state change. It names the tier ("Your Dhaga Pro plan has ended"), says the account is now on Free and that nothing has been deleted, and links to /app/settings#billing. A plan that was granted rather than bought gets one extra line pointing at DHAGA_OWNER_EMAILas well as the buy button, never instead of it, because an expiring comp is exactly when someone might pay and "ask your admin" as the only route leaves them waiting.

Idempotency is the subscriptions.lapse_notified_for column, not the daily brief's per-local-day record. The two answer different questions: a digest asks "has this person been emailed today?", while a lapse must be announced exactly once and never again — a day-scoped guard would repeat it every night forever. The column also re-arms itself when someone resubscribes (a later current_period_end moves past the stored value), so no cleanup job exists. There is consequently no hour gate: EMAIL_JOBS_HOURLY does not apply, since holding a billing notice for someone's local 08:00 only delays the one action it asks for. PLAN_LAPSE_NOTICE_BATCH_LIMIT (200) caps a single run and the query is ordered oldest-lapse-first, so a backlog drains in the order people were cut off.

Two statuses are never notified: past_due (Stripe and Razorpay run their own dunning, and our mail would contradict a processor mail still asking for a card, possibly while a retry is about to succeed) and incomplete (a checkout whose first charge never settled — the plan never started, so it cannot have ended).

Nothing to configure on a core-only self-host

Without packages/ee the billing gate's lapse sweep returns no rows and its mark is a no-op — nobody can lapse on an instance that sells nothing — so the job runs, finds nobody and reports { sent: 0, skipped: 0, failed: 0, reason: null }. Without RESEND_API_KEY / RESEND_FROM_EMAIL it returns reason: "no_email" and never touches the database. The endpoint reports it under a planLapseNotices key. No new environment variable.

Optional providers, and what the UI does without them

Everything above is core and self-hostable, but four capabilities need something this repo deliberately doesn't ship: a third-party provider, or a browser feature. Dhaga is in beta, and the product rule is that a control which cannot do its job is greyed out with a "Coming soon" label and the reason — never rendered live to silently no-op. So an unconfigured provider is visible in the UI rather than a mystery. Every one of these is a runtime check, so the control lights itself up the moment the missing piece is in place, with no code change and no extra flag to flip.

CapabilityNeedsWhat happens without it
Job-change detection + news watchlistANTHROPIC_API_KEY (the default provider is Anthropic's own server-side web search), or FIRECRAWL_API_KEY, or another registered SEARCH_PROVIDERhasSearch() is false, so /api/jobs/detect-signals returns { skipped: "no_search" } and writes no signals, and the contact page's "Watch for job changes & news" toggle is greyed out "Coming soon" instead of arming a scan that would never run. Since 2026-08-08 an instance that set ANTHROPIC_API_KEY for the other AI features has this on by default — the same runtime hasSearch() check un-greys the toggle with no code change and no extra flag. Unproven, though: no end-to-end sweep has been run against a live key, and searches cost $10/1k on top of tokens
Semantic (vector) searchDHAGA_EMBEDDINGS left unset (it defaults on), plus pgvector or a DHAGA_VECTOR_STOREWith DHAGA_EMBEDDINGS=off, embeddingsEnabled() is false: search runs on keywords + trigram only, and the search palette's Semantic similarity weight slider is greyed out "Coming soon" because it would be weighting an empty result set
SMSTWILIO_ACCOUNT_SID + TWILIO_AUTH_TOKEN + TWILIO_FROM_NUMBERsmsEnabled() is false and no code can be delivered. This one stays gated even with Twilio configured: there is no phone-number sign-in path anywhere in the app (email, magic link, passkey and social are the ways in), so Settings → Security → Phone number is "Coming soon" either way — only the wording changes, to name which half is missing
Voice notes (in-browser dictation)WebGPU in the visitor's browser — not a server setting you can supplyDhaga Voice runs the Moonshine speech model on the user's own device and has no CPU/WASM fallback, so on iOS Safari and most mobile browsers the mic button renders greyed out "Coming soon" up front rather than failing after a tap. Chrome or Edge on desktop works today. (Transcribing voice notes forwarded to a WhatsApp/Telegram bot is a separate, server-side gateway keyed off TRANSCRIPTION_PROVIDER, which ships no provider yet either)

The copy for all four lives in one place, apps/web/src/utils/constants/coming-soon.ts, and the affordance is apps/web/src/components/app/ComingSoonNotice.tsx. Nothing in it links to pricing: "coming soon" is an admission that nobody can have the feature yet, not an upsell.

Two things this table does not cover, because they degrade rather than gate: ANTHROPIC_API_KEY (see the env table below — AI features fall back to heuristic parsing or switch off), and RESEND_* (every recurring email becomes a clean no-op).

Self-host env var reference

Everything below lives in apps/web/.env.local — see apps/web/.env.example for the full annotated list. None of the packages/ee/.env.example vars (DHAGA_HOSTED_MODE, DHAGA_ADMIN_EMAILS, STRIPE_*) are needed for a plain self-host.

VarRequired?Notes
BETTER_AUTH_SECRETYesopenssl rand -base64 32
BETTER_AUTH_URLYesYour instance's base URL
BETTER_AUTH_TRUSTED_ORIGINSNoExtra allowed origins beyond BETTER_AUTH_URL (comma-separated or wildcard) — avoids INVALID_ORIGIN; Vercel preview URLs are auto-trusted
NEXT_PUBLIC_SITE_URLNoCanonical origin for sitemap/robots/OG/llms.txt; defaults to the production deployment origin when unset
DATABASE_URLOnly on serverless (Vercel)Otherwise defaults to embedded PGlite
ANTHROPIC_API_KEYNoAI features degrade to heuristic parsing / disabled without it
RESEND_API_KEY, RESEND_FROM_EMAIL, DHAGA_OWNER_EMAILNoUser-triggered event digests, plus the one scheduled email: the daily brief (follow-ups due, important dates, confirmations, reach-outs, backlog totals, the LinkedIn-export nudge) and the activation nudge that replaces it on an empty account. All degrade to a clean no-op when unset
EMAIL_JOBS_HOURLYNoSet true only if you drive /api/jobs/daily hourly — then the daily brief sends only on the run matching the recipient's local ~08:00 (their Settings time zone). Leave it unset on a once-a-day cron, including Vercel Hobby's: the single run always sends, and a per-user local-day record is what stops duplicates. See "The daily brief" above
MORNING_REMINDER_HOURLYNoDeprecated alias for EMAIL_JOBS_HOURLY, still honoured for one release. Despite the name it gates the whole daily brief, not just the follow-up sections — prefer the new name
TELEGRAM_*NoOwner-only bot capture
DHAGA_WEBHOOK_URLNoOutbound automation
SEARCH_PROVIDER, FIRECRAWL_API_KEYNoJob-change detection + news watchlist. Both optional: leave them unset and search runs on ANTHROPIC_API_KEY via Anthropic's own web-search tool, which is the default. Set FIRECRAWL_API_KEY and Firecrawl wins instead; SEARCH_PROVIDER overrides both. With no key at all the nightly sweep no-ops and the watch toggle is greyed out — see "Optional providers" above
TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_FROM_NUMBERNoSMS delivery. Note phone-number sign-in is unbuilt regardless, so the Settings phone section stays gated either way — see "Optional providers" above
CRON_SECRETNoRequired to enable /api/jobs/detect-signals — see above
DHAGA_EMBEDDINGSNoDefaults on. Set off to skip local semantic indexing — search then runs keyword + trigram only and the semantic weight slider is greyed out; see "Optional providers" above
DHAGA_AI_MONTHLY_CAP, DHAGA_DATA_DIRNoSee .env.example for defaults

See Deploying for the full deploy walkthrough (Vercel and single-server options), including the additional packages/ee vars if you do want the hosted-product features.

To add an LLM, search engine, embedding model, or external vector store, see Providers. Providers can be distributed as independent npm packages and registered from the server startup bootstrap.

On this page