Limestone cloud setup — accounts, organizations & billing¶
Operator guide for the hosted backend that powers accounts, organizations, and
seat billing (ADR-0078). It builds on the multi-user control plane (ADR-0053):
the Account service (apps/account, FastAPI) owns identity, orgs, and
memberships and never stores vault content — vault files live in each user's
own storage (Google Drive today).
Design goal: keep backend ops on as few platforms as possible. The backend is Python/FastAPI + SQLAlchemy + Postgres, so we host it on Render, which runs the API, its Postgres, and the static web build in one place. Two things are specialist services you plug in on any platform — payments (Stripe) and transactional email (Resend) — so those stay external. DNS stays with Porkbun, your registrar. That's the whole footprint.
1. Architecture¶
Local-first (ADR-0084): the local app is the product and the only
front-end. It's free and works without an account; account creation/login,
org management, and invites happen inside the local app, which calls the
hosted control plane. app.limestone.bio is not a second app — it's a
minimal portal: a landing page and the download CTA. Identity pages (join,
signup, login, reset, verify) are served by the account service at
accounts.limestone.bio (ADR-0122). Administration is NOT here — it lives in a separate repo
(limestone-admin-internal) behind Tailscale, calling the account service's
locked /internal/* API (ADR-0085).
Local app (apps/web + apps/api) ── the product; talks to ──┐
Log in / orgs / invites, inline │ VITE_ACCOUNT_URL
Porkbun (registrar + DNS) ──► accounts.limestone.bio ──────▼
(Render Web Service: FastAPI limestone_account)
│ │ │
│ │ └─► Render Postgres
│ └─► Stripe (per-seat subs)
└─► Resend (invite / verify / reset email)
app.limestone.bio (Render Static Site, VITE_DEPLOY_TARGET=web)
= landing + email deep-links only (portal); admin is a separate repo (ADR-0085)
Local app validates account JWTs offline via JWKS ─────────┘
| Concern | Service | One platform? |
|---|---|---|
| Account+Org API (FastAPI) | Render Web Service | ✅ Render |
| Relational DB | Render Postgres | ✅ Render |
Web static (apps/web) |
Render Static Site | ✅ Render |
| Domain + DNS | Porkbun (registrar you already use) | — |
| Payments / per-seat subs | Stripe Billing | external (any host needs it) |
| Transactional email | Resend (or Postmark) | external (any host needs it) |
| Signup bot protection | Cloudflare Turnstile (standalone — no DNS) | external, optional |
| Backups / object storage | Cloudflare R2 or AWS S3 (S3-compatible) | external, optional |
Naming: the product was renamed wetline → limestone. The service package is
limestone_accountand all env vars areLIMESTONE_ACCOUNT_*. Any older doc sayingwetline_account/WETLINE_ACCOUNT_*/.wetline/is stale.
Deploy it agentically (Blueprint as code + Render API/MCP)¶
The goal is minimal dashboard clicking. Render supports two layers of control, both used here:
render.yaml(Infrastructure as Code) — committed at the repo root, it is the deployment: the account Web Service, the Static Site, and the Postgres database. Edit it + push and Render syncs the topology. The DB URL wires itself (fromDatabase, normalized byconfig.py), so there's no connection string to copy.Dockerfile(repo root) builds the service.- Render API / MCP / CLI — for the few imperative bits: setting the
sync: falsesecret values, triggering deploys, and reading logs. Add the Render MCP server to your agent so it can drive all of this:
claude mcp add render -- npx -y @render/mcp-server
# then export your Render API key (Account Settings → API Keys) for it:
# RENDER_API_KEY=rnd_...
With that, an agent can create/update services, set env vars, deploy, and tail logs via natural language — no dashboard.
The irreducible manual steps (can't be automated away): create the Render account, generate one API key (unlocks everything above), authorize Render's GitHub app on the repo once (or create the Blueprint through the API), and obtain the third-party secret values (Google client secret, Stripe keys, the JWT PEM) from those providers — then hand them to the agent to set via the API.
First deploy, two ways:
- Dashboard (one time): New → Blueprint → pick this repo → Render reads
render.yamland provisions everything. Then the agent sets thesync: falsesecrets via MCP. - Fully agentic: ask the agent (Render MCP) to create the Blueprint from the repo and set the secrets — zero dashboard.
Sections 2–8 below are the underlying details (what each service/secret is);
render.yaml already encodes 2–5.
2. Render — the Account service (apps/account)¶
Render runs long-running Python natively. Deploy from the repo with a Dockerfile
(so the build is reproducible and the prod group's Postgres driver is present):
# Dockerfile (repo root)
FROM python:3.12-slim
RUN pip install uv
WORKDIR /app
COPY pyproject.toml uv.lock ./
COPY apps/account ./apps/account
# prod group adds the Postgres driver (psycopg)
RUN uv sync --frozen --group prod --no-dev
EXPOSE 8100
CMD ["uv","run","uvicorn","limestone_account.main:app","--app-dir","apps/account","--host","0.0.0.0","--port","8100"]
In Render: New → Web Service → from the repo, runtime Docker. Render
sets $PORT; either bind to it (--port $PORT) or set Render's health-check and
container port to 8100. Add the env/secrets from §8. Health check path
/api/health. Once live: GET https://accounts.limestone.bio/api/health →
{"service":"limestone-account"} and /.well-known/jwks.json returns a key.
Optionally define both services + the database as code in a render.yaml
blueprint (web service, static site, and a databases: entry) so the whole
backend deploys from one file.
3. Render Postgres — the database¶
Render has managed Postgres in the same dashboard (no external DB provider):
- New → Postgres, name
limestone-accounts, pick a region/plan. - Copy the Internal Database URL and convert the scheme to SQLAlchemy's:
postgresql+psycopg://USER:PASSWORD@HOST/limestone_accounts. Use the internal URL so DB traffic stays on Render's private network. - Set it as the
LIMESTONE_ACCOUNT_DATABASE_URLenv var on the web service.
Schema is created on boot (create_all). Before the first production data, add
an Alembic baseline — the db.py docstring flags this; altering columns later
(as this epic did) needs migrations once real accounts exist.
4. Render Static Site — the web client (apps/web)¶
- New → Static Site → from the repo.
- Build command
pnpm --filter @limestone/web build, publish directoryapps/web/dist. Node 24. - Environment variable
VITE_ACCOUNT_URL = https://accounts.limestone.bio— switches on account sign-in (unset ⇒ Local-only). Build-time. - Add a rewrite rule
/* → /index.html(SPA fallback). Render serves it over its CDN with automatic TLS.
5. Porkbun — domain & DNS¶
Keep the domain and DNS where you already are. In Porkbun's DNS editor, point the hostnames at Render (Render then auto-issues the TLS certs):
| Host | Type | Value |
|---|---|---|
app.limestone.bio (web) |
CNAME | the Render Static Site target (…onrender.com) |
accounts.limestone.bio (API) |
CNAME | the Render Web Service target (…onrender.com) |
limestone.bio (root, optional) |
ALIAS | the Render target (Porkbun supports ALIAS at the apex) |
| verification TXT (if Render asks) | TXT | the value Render shows |
Add each hostname as a custom domain on the matching Render service so Render provisions the certificate. No Cloudflare needed; if you ever want edge CDN/WAF/DDoS, you can move the nameservers to Cloudflare later (a 15-minute change, no code impact).
6. Google OAuth (existing) + RS256 signing key¶
- Google Cloud Console → OAuth consent screen (External), scopes
openid email profile— no Drive scopes. - Create an OAuth client → Web application; redirect URI
https://accounts.limestone.bio/auth/google/callback. Note client id/secret. - Generate a stable RS256 key (rotating it invalidates live sessions):
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048. Store the PEM asLIMESTONE_ACCOUNT_JWT_PRIVATE_KEY(paste as a Render secret / secret file).
Email + password signup works with no Google setup — handy for local/staging: bring up only the Account service + Postgres (§2–3) and use the email/password flow.
7. Externals — Stripe, Resend, Turnstile¶
These aren't Render-specific; any backend needs them.
Stripe — per-seat billing ($2/seat/month). Cloudflare/Render/etc. have no payments product.
- Create a Stripe account; enable Stripe Tax (with Stripe you're the merchant of record and owe sales tax/VAT — Paddle would remove that burden at higher fees, the trade-off from planning).
- Product "Limestone seat" → a recurring Price, $2.00 USD / month,
per-unit (quantity = seat count). Note the
price_…id. - API keys →
STRIPE_SECRET_KEY. Webhook →https://accounts.limestone.bio/billing/webhookforcheckout.session.completed,customer.subscription.updated/deleted,invoice.payment_failed; copy the signing secret →STRIPE_WEBHOOK_SECRET. - Seat model: an org's seat count = the subscription quantity. The service
ships a billing stub today (
billing.py,SeatBilling); the billing slice swaps in aStripeBillingimpl behind the same interface — no route changes.
Resend — transactional email (org invites now; address verification / password reset later). Render has no email; Cloudflare Email Routing is inbound-only. Wired as of ADR-0079 — an admin inviting an email with no account yet sends an accept link. Without a key the service still records the invite; it just doesn't deliver it.
- Create a Resend account → Domains → Add domain
mail.limestone.bio(a subdomain keeps the apex DNS free for the website). Resend shows a set of DKIM/SPF/DMARC records — add them in Porkbun DNS, then click Verify. - API Keys → Create (sending permission) → the
re_…value goes to BWS asRESEND_API_KEY(never pasted into Render by hand — Claude sets it on the Render service from BWS). - The sender/from-address is
LIMESTONE_ACCOUNT_EMAIL_FROM(defaultLimestone <noreply@mail.limestone.bio>inrender.yaml) — its domain must match the verified domain above. Identity links — invite, password reset, email verification — point atLIMESTONE_ACCOUNT_ACCOUNTS_URL(https://accounts.limestone.bio) →/join?token=…,/reset-password?token=…,/verify-email?token=…, served by the account service itself (ADR-0122).LIMESTONE_ACCOUNT_APP_URLnow carries only links that just open the product.
Cloudflare Turnstile — signup bot protection (standalone). Turnstile needs
only a Cloudflare account for widget keys — no DNS or proxy, so it works with
Porkbun DNS + Render hosting. Create a widget → TURNSTILE_SITE_KEY (client) +
TURNSTILE_SECRET_KEY (server verify); the service verifies the token before
creating a user. (Wired with the email/password UI slice.)
8. Environment variable reference¶
Account service (Render Web Service env / secrets):
| Variable | Purpose |
|---|---|
LIMESTONE_ACCOUNT_DATABASE_URL |
Render Postgres URL (postgresql+psycopg://…); dev default = SQLite file |
LIMESTONE_ACCOUNT_ISSUER |
public base URL https://accounts.limestone.bio — JWT iss + Google redirect base |
LIMESTONE_ACCOUNT_GOOGLE_CLIENT_ID / _SECRET |
Google OAuth client (§6) |
LIMESTONE_ACCOUNT_JWT_PRIVATE_KEY |
RS256 PEM, stable across deploys |
LIMESTONE_ACCOUNT_ACCESS_TTL / _REFRESH_TTL |
token TTLs (default 900s / 30d) |
STRIPE_SECRET_KEY / STRIPE_PRICE_ID / STRIPE_WEBHOOK_SECRET |
billing (§7) |
RESEND_API_KEY |
transactional email — Resend send key (§7); unset ⇒ invites persist but don't send |
LIMESTONE_ACCOUNT_EMAIL_FROM |
invite sender, e.g. Limestone <noreply@mail.limestone.bio> (Resend-verified domain) |
LIMESTONE_ACCOUNT_APP_URL |
portal base for emailed deep-links (default https://app.limestone.bio) |
LIMESTONE_ACCOUNT_CORS_ORIGINS |
browser origins allowed to call the API — the local app + portal (ADR-0084) |
LIMESTONE_ACCOUNT_REDIRECT_ORIGINS |
OAuth token-handback allowlist (local app + portal); blocks open-redirect (ADR-0084) |
LIMESTONE_ACCOUNT_INTERNAL_TOKEN |
service token gating the internal admin API /internal/* (ADR-0085); held only by the admin plane |
TURNSTILE_SECRET_KEY |
signup verification (§7) |
Local app (apps/api) — validates account JWTs offline:
LIMESTONE_ACCOUNT_JWKS_URL = <ISSUER>/.well-known/jwks.json and
LIMESTONE_ACCOUNT_ISSUER (must match). Unset ⇒ Local-only mode.
Web (apps/web, build-time): VITE_ACCOUNT_URL = <ISSUER> (the local app
defaults to https://accounts.limestone.bio in code — set this only to override /
self-host / disable with "") and VITE_TURNSTILE_SITE_KEY. The
app.limestone.bio static build additionally sets VITE_DEPLOY_TARGET=web,
which makes it a portal (landing + email deep-links only) and
never the app (ADR-0084); local builds leave it unset.
9. Secrets & key rotation¶
- Never commit secrets; use Render Environment / Secret Files.
- The JWT signing key must be stable and shared by every instance — rotating it signs out everyone. To rotate safely, publish both keys in the JWKS during an overlap window before retiring the old one.
- Restrict Stripe/Resend keys to least privilege; verify the Stripe webhook
signature on every call (
STRIPE_WEBHOOK_SECRET).
10. Smoke test¶
GET https://accounts.limestone.bio/api/health→ok;/.well-known/jwks.jsonreturns a key.- Web: Create account with email + password → you're signed in as
@handle. - Settings → Organization → Create organization; you're its admin (1 seat, used 1).
- Add a seat (stubbed/free today), then invite a teammate by email → they get a pending invite; "used" increments; the (seats+1)-th invite is refused.
- Teammate signs up with that email and accepts → becomes an active member.
- Sign in with Google still works; both land you signed in.
Optional — Cloudflare edge later¶
You don't need Cloudflare to launch. If you later want DDoS scrubbing, a WAF, or edge caching, move the nameservers from Porkbun to Cloudflare and turn on the proxy in front of the Render services. Cloudflare then sits at the front door; Render still runs all the code + data. No application changes. (Turnstile, above, needs none of this.)
What's next (billing slice — see ADR-0078)¶
Wire the live Stripe path behind the existing SeatBilling seam: Checkout for
adding seats, the webhook to sync subscription quantity ↔ Account.seats,
proration, and the failed-payment/downgrade flow. Then Turnstile + Resend for
signup/invite email, and (later) encryption-gated vault keys (VaultKey).