Skip to content

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_account and all env vars are LIMESTONE_ACCOUNT_*. Any older doc saying wetline_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:

  1. 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 by config.py), so there's no connection string to copy. Dockerfile (repo root) builds the service.
  2. Render API / MCP / CLI — for the few imperative bits: setting the sync: false secret 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.yaml and provisions everything. Then the agent sets the sync: false secrets 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):

  1. New → Postgres, name limestone-accounts, pick a region/plan.
  2. 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.
  3. Set it as the LIMESTONE_ACCOUNT_DATABASE_URL env 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)

  1. New → Static Site → from the repo.
  2. Build command pnpm --filter @limestone/web build, publish directory apps/web/dist. Node 24.
  3. Environment variable VITE_ACCOUNT_URL = https://accounts.limestone.bio — switches on account sign-in (unset ⇒ Local-only). Build-time.
  4. 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

  1. Google Cloud Console → OAuth consent screen (External), scopes openid email profile — no Drive scopes.
  2. Create an OAuth client → Web application; redirect URI https://accounts.limestone.bio/auth/google/callback. Note client id/secret.
  3. Generate a stable RS256 key (rotating it invalidates live sessions): openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048. Store the PEM as LIMESTONE_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.

  1. 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).
  2. Product "Limestone seat" → a recurring Price, $2.00 USD / month, per-unit (quantity = seat count). Note the price_… id.
  3. API keys → STRIPE_SECRET_KEY. Webhook → https://accounts.limestone.bio/billing/webhook for checkout.session.completed, customer.subscription.updated/deleted, invoice.payment_failed; copy the signing secret → STRIPE_WEBHOOK_SECRET.
  4. 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 a StripeBilling impl 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.

  1. 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.
  2. API Keys → Create (sending permission) → the re_… value goes to BWS as RESEND_API_KEY (never pasted into Render by hand — Claude sets it on the Render service from BWS).
  3. The sender/from-address is LIMESTONE_ACCOUNT_EMAIL_FROM (default Limestone <noreply@mail.limestone.bio> in render.yaml) — its domain must match the verified domain above. Identity links — invite, password reset, email verification — point at LIMESTONE_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_URL now 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

  1. GET https://accounts.limestone.bio/api/health → ok; /.well-known/jwks.json returns a key.
  2. Web: Create account with email + password → you're signed in as @handle.
  3. Settings → Organization → Create organization; you're its admin (1 seat, used 1).
  4. 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.
  5. Teammate signs up with that email and accepts → becomes an active member.
  6. 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).