Self-hosting
Devlog is a Next.js app in front of a Supabase project. If you would rather keep the database in your own account, this is everything the hosted service does that you now have to do yourself.
Requirements
A Supabase project (Postgres, Auth, and the GitHub and Google OAuth providers enabled in its dashboard), Node 22 or Bun, and somewhere to run a Next.js server. There is no other service to stand up; email, payments and the cron are each optional and switched on by an environment variable.
Setup
- 1
Install and configure
bun install cp .env.example .env.local # fill in at least the two Supabase values
- 2
Apply the migrations, in order
Everything under
supabase/migrations/, through the SQL editor orsupabase db push. All are required; the last one revokes operator-only functions from public roles and must not be skipped. Then confirm they landed:node scripts/check-schema.mjs
- 3
Run it
bun run dev # http://localhost:4000 bun run build # standalone output bun run start
Environment variables
| Variable | Unlocks | Without it |
|---|---|---|
NEXT_PUBLIC_SUPABASE_URL | Required | The app refuses to start and names the missing variable |
NEXT_PUBLIC_SUPABASE_ANON_KEY | Required | Same |
SUPABASE_SERVICE_ROLE_KEY | Cron sweep, admin actions, account deletion, ingest API, payment webhooks | Those endpoints return 501 with an explanation |
CRON_SECRET | Authenticates the external cron caller | Only in-app scheduler ticks run |
SECRETS_ENCRYPTION_KEY | Encryption at rest for stored tokens | Tokens are stored as-is |
NEXT_PUBLIC_SITE_URL | Absolute links in emails and the sitemap | A hardcoded default — set it in production |
RESEND_API_KEY | Emailing workspace invitations | The invite is created; the admin copies the link |
EMAIL_FROM | Sender address on a verified domain | Same |
GOOGLE_CLIENT_ID / _SECRET | Refreshing expired Google tokens mid-run | Sheet writes fail after the access token expires |
RAZORPAY_* | Card payments for credits and seats | Manual UPI/invoice intents an admin approves by hand |
Encrypting stored tokens
user_secrets and source_connections hold live credentials. Row-level security keeps them out of the API, which is no defence against a dump or a leaked service-role key — so set a key:
openssl rand -hex 32 # → SECRETS_ENCRYPTION_KEY bun run secrets:check # what is still in plaintext bun run secrets:encrypt # convert it
GET /api/scheduler reports whether encryption is configured on a deployment.The nightly sweep and the digest
The in-app runner only fires while someone has a tab open. For a team, schedule two calls from any cron:
*/5 * * * * curl -X POST https://your-host/api/scheduler -H "x-cron-secret: $CRON_SECRET" 0 8 * * 1 curl -X POST https://your-host/api/digest -H "x-cron-secret: $CRON_SECRET"
The first sweeps every enabled schedule and runs the ones that are due in their own timezone. The second sends the Monday digest to users who opted in and logged something last week.
Proving tenant isolation
Before a second customer exists, run the proof:
bun run tenancy:proof # two workspaces, every cross-tenant read and write attempted as a signed-in user bun run tenancy:cleanup # remove anything an interrupted run left behind
It uses the anon key and a signed-in session — the same credentials a customer's browser holds — so the database policies are what have to stop it, not application code.