Skip to content

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. 1

    Install and configure

    bun install
    cp .env.example .env.local   # fill in at least the two Supabase values
  2. 2

    Apply the migrations, in order

    Everything under supabase/migrations/, through the SQL editor or supabase 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. 3

    Run it

    bun run dev     # http://localhost:4000
    bun run build   # standalone output
    bun run start

Environment variables

VariableUnlocksWithout it
NEXT_PUBLIC_SUPABASE_URLRequiredThe app refuses to start and names the missing variable
NEXT_PUBLIC_SUPABASE_ANON_KEYRequiredSame
SUPABASE_SERVICE_ROLE_KEYCron sweep, admin actions, account deletion, ingest API, payment webhooksThose endpoints return 501 with an explanation
CRON_SECRETAuthenticates the external cron callerOnly in-app scheduler ticks run
SECRETS_ENCRYPTION_KEYEncryption at rest for stored tokensTokens are stored as-is
NEXT_PUBLIC_SITE_URLAbsolute links in emails and the sitemapA hardcoded default — set it in production
RESEND_API_KEYEmailing workspace invitationsThe invite is created; the admin copies the link
EMAIL_FROMSender address on a verified domainSame
GOOGLE_CLIENT_ID / _SECRETRefreshing expired Google tokens mid-runSheet writes fail after the access token expires
RAZORPAY_*Card payments for credits and seatsManual 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
Back the key up with your other secrets. Losing it makes every stored token unreadable and each user has to reconnect. 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.