Skip to content

Self-hosting Repodar

Repodar is one Cloudflare Worker with a D1 database, a KV namespace, two queues and two cron triggers. This guide takes you from a fresh clone to a running instance with your repos scanned. Plan for about 30 minutes. Every command is meant to be copied as-is; replace the values in <angle brackets> and anything under your.domain.

Before you start

You need:

  • Node 26 (the version in .nvmrc; nvm install && nvm use picks it up) and pnpm 10 (corepack enable gives you the version pinned in package.json).
  • A Cloudflare account on the Workers Paid plan (5 USD a month at the time of writing). The free plan allows 50 outbound requests per Worker invocation. Scanning one repository fetches its manifests and lockfiles from GitHub and then calls endoflife.date, OSV.dev and Packagist, so a real repository can easily need more than 50, and the scan fails halfway. Workers Paid raises the limit to 10,000.
  • A GitHub account (personal or organization) to own the GitHub App.
  • Optional: a domain whose DNS is on the same Cloudflare account. Without one, Repodar runs on https://repodar.<your-subdomain>.workers.dev.
  • Optional: a Lettr account with a verified sender address, for the daily digest email. Without it, Repodar works and simply sends no email.

1. Clone and install

git clone https://github.com/gausejakub/repodar.git
cd repodar
nvm use
pnpm install

Everything below runs from apps/web unless it says otherwise.

cd apps/web
pnpm exec wrangler login

2. Create the database, cache and queues

pnpm exec wrangler d1 create repodar-db
pnpm exec wrangler kv namespace create repodar-cache
pnpm exec wrangler queues create repodar-scan
pnpm exec wrangler queues create repodar-scan-dlq

Keep the output of the first two commands: you need the D1 database_id and the KV id. The names matter: wrangler.jsonc refers to repodar-db, repodar-scan and repodar-scan-dlq (the second queue is the dead-letter queue for scans that failed three times).

3. Edit wrangler.jsonc

Open apps/web/wrangler.jsonc and change the top-level config (leave the env.preview block alone or delete it):

  1. d1_databases[0].database_id: the id from wrangler d1 create.
  2. kv_namespaces[0].id: the id from wrangler kv namespace create.
  3. routes: replace repodar.dev with your domain, for example [{ "pattern": "repodar.your.domain", "custom_domain": true }]. Cloudflare creates the DNS record and certificate on deploy. No domain? Delete the routes entry; the Worker is then served on workers.dev.
  4. vars.APP_URL: the exact URL you will open in the browser, without a trailing slash, for example https://repodar.your.domain. Sign-in only accepts requests from this origin.
  5. vars.SELF_HOSTED: "true". This turns billing and plan limits off for good (every team is unlimited, no pricing, no upgrade buttons) and enables the GitHub App manifest callback that step 6 uses.
  6. vars.BILLING_ENABLED: leave it "false" (or absent). It only matters for the hosted service; SELF_HOSTED: "true" overrides it anyway.
  7. vars.GITHUB_APP_SLUG: fill in after step 6 (the slug of your App, from its URL github.com/apps/<slug>).

If you changed any vars, regenerate the Worker types so pnpm typecheck stays green:

pnpm --filter @repodar/web cf-typegen

4. Apply the database migrations

pnpm db:migrate:prod

This runs wrangler d1 migrations apply DB --remote. Always migrate before you deploy: new code expects the new columns, and a deploy that runs ahead of its migration fails on the first request that touches them.

5. First deploy

The Worker declares its required secrets in wrangler.jsonc, and Wrangler refuses to create a new Worker without them. For the very first deploy, put them in a file outside the repository (it holds secrets; never commit it):

cat > ~/repodar.secrets <<'EOF'
BETTER_AUTH_SECRET=<output of: openssl rand -base64 32>
GITHUB_APP_ID=placeholder
GITHUB_APP_CLIENT_ID=placeholder
GITHUB_APP_CLIENT_SECRET=placeholder
GITHUB_APP_PRIVATE_KEY=placeholder
GITHUB_WEBHOOK_SECRET=placeholder
LETTR_API_KEY=
LETTR_FROM=
EOF
pnpm exec wrangler deploy --secrets-file ~/repodar.secrets

If Cloudflare rejects the empty LETTR_* values, leave those two lines out instead: a missing Lettr secret means "no email" just like an empty one. The GitHub values are placeholders until step 6 creates the App. Open APP_URL: the landing page should load. Sign-in does not work yet.

What each secret is for:

Secret Purpose
BETTER_AUTH_SECRET Signs sessions and OAuth state, and is the master key the per-team token encryption is derived from. Keep it stable: rotating it signs everyone out and makes stored provider tokens unreadable (users paste them again).
GITHUB_APP_ID Numeric id of your GitHub App, used to mint installation tokens.
GITHUB_APP_CLIENT_ID The App's OAuth client id, used for "Sign in with GitHub".
GITHUB_APP_CLIENT_SECRET The App's OAuth client secret.
GITHUB_APP_PRIVATE_KEY The App's private key (PEM, newlines escaped as \n). PKCS#1 as GitHub gives it is fine.
GITHUB_WEBHOOK_SECRET Verifies the X-Hub-Signature-256 header on GitHub webhooks.
LETTR_API_KEY Lettr API key for the digest email. Empty = no email.
LETTR_FROM Verified sender, for example Repodar <radar@your.domain>. Empty = no email.

Billing secrets (STRIPE_*) are not needed: a self-hosted instance never bills.

6. Create the GitHub App

The manifest flow creates a correctly configured App in one click. Run it from the repository root with the same BETTER_AUTH_SECRET you deployed (the callback verifies the flow with it):

cd ../..
BETTER_AUTH_SECRET='<the same value>' pnpm github-app:manifest --app-url https://repodar.your.domain

Add --org <org-login> to create the App under an organization instead of your personal account, and --name "<name>" if "Repodar" is taken (App names are global on GitHub). The script prints the manifest and the path of a small local HTML page. Open it, press the button and confirm on GitHub. GitHub redirects to <APP_URL>/github/manifest/callback, which shows the new App's credentials once, as NAME=value lines. Nothing is stored on the server.

The App it creates:

Setting Value
Callback URL <APP_URL>/api/auth/callback/github
Setup URL <APP_URL>/github/setup, "Redirect on update" on
Webhook URL <APP_URL>/webhooks/github, active
Request user authorization during installation off (it would disable the setup URL)
Repository permissions Contents: Read-only, Metadata: Read-only
Account permissions Email addresses: Read-only
Events Push (installation events are always delivered)
Visibility Private: only your account can install it

Then:

  1. Copy every line except GITHUB_APP_SLUG into ~/repodar.secrets, replacing the placeholders.
  2. Put the slug into vars.GITHUB_APP_SLUG in apps/web/wrangler.jsonc (a var, not a secret: a name cannot be both).
  3. Upload the secrets and deploy the slug:
cd apps/web
pnpm exec wrangler secret bulk ~/repodar.secrets
pnpm exec wrangler deploy

Delete ~/repodar.secrets afterwards, or move it to your password manager.

Prefer clicking through GitHub by hand? Create the App under Settings > Developer settings > GitHub Apps > New with the table above, generate a client secret and a private key, pick any random webhook secret, and fill the secrets file yourself.

7. Sign in and install

Open <APP_URL>/login and sign in with GitHub. The first sign-in creates your account and a personal workspace. Go to Repos and press Install GitHub App, pick the account and choose Only select repositories: that selection is the list Repodar watches. Repodar syncs the list and scans each repo within a minute or two. Repeat the install for every organization you want to watch. Repositories you can only reach as a collaborator can be added by URL on the same page.

Optional, under Settings: map GitHub owners to "mine" or "client" (for the owner: filter).

8. Optional: GitHub Deployments

To show "deployed 2 d ago · production" from GitHub Deployments, add one permission to the App: App settings > Permissions & events > Repository permissions > Deployments: Read-only > Save. GitHub then asks every installation to accept the new permission (the account owner gets an email and a banner under Settings > Applications). Until it is accepted, the GitHub Deployments card in Repodar's settings says "Waiting for permission" and the source skips quietly. Nothing else changes.

9. Optional: email digests with Lettr

  1. Create a Lettr API key and verify the sender domain or address there.
  2. Set both secrets (they take effect without a redeploy):
pnpm exec wrangler secret put LETTR_API_KEY
pnpm exec wrangler secret put LETTR_FROM
  1. In Repodar, Settings > Daily digest: send yourself a test digest.

The digest goes to the workspace owner's verified GitHub email, at 07:00 UTC, and only when the findings changed since the last one. Without Lettr ("no email" mode) leave both secrets empty: the dashboard works the same and pending digests are logged and kept.

10. Optional: provider tokens

Each source is a card under Settings > Sources. Tokens are stored encrypted, used only for read requests and shown afterwards only by their last four characters. Use the narrowest token each provider allows:

Source Token Permissions
Laravel Forge Forge > Account > API tokens server:view and site:manage-commands (the daily version command; optional, else the header probe), add organization:view to read every organization
Cloudflare Custom API token (My Profile > API Tokens) Account Settings: Read, Workers Scripts: Read, Cloudflare Pages: Read; add Workers CI: Read so Workers Builds link Workers to repositories
Vercel Account Settings > Tokens, scoped to the teams Repodar should see Vercel tokens cannot be read-only; Repodar only sends GET requests
DigitalOcean API > Tokens, Custom Scopes app:read only
GitHub (URL-added repos) Fine-grained personal access token, "Only select repositories" Contents: Read-only, Metadata: Read-only, nothing else

How each source matches what it finds to your repositories, and how to map the rest by hand, is in sources.md.

Server runtime reports need no provider token and no extra configuration: generate a report token in the Server reports card (under Other servers in Settings) and add the one line to a cron job or a deploy script (server reports). Your instance serves the script at /report.sh with your APP_URL as its default endpoint, so set APP_URL to the public origin your servers can reach. The paths are GET /report.sh and POST /ingest/runtime; if you put Cloudflare Access or a WAF rule in front of the Worker, let both through.

11. Optional: deploy from GitHub Actions

.github/workflows/deploy.yml runs on every push to main: the usual lint, typecheck and test job, then wrangler d1 migrations apply --remote, then wrangler deploy. It does nothing until you add the token, so a fork stays inert. To turn it on:

  1. Create a Cloudflare API token from the "Edit Cloudflare Workers" template and add D1: Edit and Queues: Edit. With a custom domain, include that zone under Zone Resources.
  2. In your GitHub repository, Settings > Secrets and variables > Actions, add CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID (the account id is on the Workers overview page).
  3. Push to main. Without the token the deploy job logs "skipped" and stays green.

Commit your wrangler.jsonc changes (ids, domain, APP_URL, slug) to your fork for this: CI deploys exactly what is in the repository. The resource ids are identifiers, not credentials.

If your fork is public, also enable Settings > Code security > Private vulnerability reporting: .github/SECURITY.md points reporters at the "Report a vulnerability" button, which only appears when that setting is on. Replace [SECURITY EMAIL] there with your own address.

12. Optional: analytics and indexing

Everything here is off until you set it, in vars of apps/web/wrangler.jsonc (these are public values, not secrets). Redeploy after a change.

Var Default Effect
NOINDEX "false" ("true" in env.preview) "true" marks every page noindex, sends X-Robots-Tag: noindex and makes robots.txt disallow everything. Use it on any copy that should stay out of search engines.
CF_WEB_ANALYTICS_TOKEN "" Cloudflare Web Analytics site token (Analytics & Logs > Web Analytics > Add a site, manual setup). Cookieless, so no consent banner. When set, pages load Cloudflare's beacon and the CSP allows https://static.cloudflareinsights.com in script-src and https://cloudflareinsights.com in connect-src, and nothing else changes.
POSTHOG_KEY "" PostHog project API key (phc_…). Turns on the consent banner on the public pages. Nothing loads and no event is sent until a visitor chooses "Allow analytics" (cookie rd_consent=granted, 1 year). posthog-js and its requests go through the Worker at /ph/*, so the CSP stays 'self'.
POSTHOG_HOST "https://eu.i.posthog.com" PostHog ingestion host. https://us.i.posthog.com for the US cloud, or your own instance's origin.

With PostHog on, the app also sends a few server-side events, again only for visitors who allowed analytics: signup_completed, app_installed, scan_requested and digest_test_sent. The distinct id is Repodar's internal user id, never an email address. Page views come from posthog-js ($pageview). Update your privacy policy (content/privacy.md) before you turn any of this on.

Updating

git pull
pnpm install
pnpm db:migrate:prod
cd apps/web && pnpm exec wrangler deploy

Migrations are additive; run them before the deploy, every time.

Local development

Local development needs no Cloudflare account: wrangler dev runs D1, KV, Queues and Cron in Miniflare, with the database under apps/web/.wrangler/state.

pnpm install
cp apps/web/.dev.vars.example apps/web/.dev.vars
pnpm db:migrate:local
pnpm db:seed
pnpm dev

apps/web/.dev.vars holds the same secrets as production, plus APP_URL=http://localhost:8787 and GITHUB_APP_SLUG for your development App. Set BETTER_AUTH_SECRET first (openssl rand -base64 32), then create a development App while pnpm dev is running:

pnpm github-app:manifest

It reads APP_URL and BETTER_AUTH_SECRET from .dev.vars and the callback page prints the credentials as .dev.vars lines: paste them in and restart pnpm dev. GitHub cannot deliver webhooks to localhost, so the App's webhook starts inactive. To receive pushes locally, create a relay channel (for example on smee.io), rerun the script with --webhook-url <channel url> and forward the channel to http://localhost:8787/webhooks/github.

The seed (pnpm db:seed) creates one user, one personal team and five repositories with state and findings from the detection fixtures, so the dashboard has something to show without GitHub. To start over, delete apps/web/.wrangler/state, then migrate and seed again.

Database commands:

pnpm db:generate        # after a schema change: writes a new SQL migration (commit it)
pnpm db:migrate:local   # apply migrations to the local D1
pnpm db:seed            # local only, re-runnable

pnpm dev passes --test-scheduled, so you can fire the crons by hand:

curl "http://localhost:8787/__scheduled?cron=0+6+*+*+*"   # collect sources, then scan every repo
curl "http://localhost:8787/__scheduled?cron=0+7+*+*+*"   # send pending digests

The queue consumer runs in the same process; its work shows in the pnpm dev output and in the scans and findings tables.

Screenshots of the main pages, light and dark, come from apps/web/scripts/screenshots.mjs (drives a local Chrome; local database only). From apps/web, with APP_URL in .dev.vars pointing at the port you use:

pnpm dev --port 8795
node scripts/screenshots.mjs ./shots --port 8795

Troubleshooting

  • "The following required secrets have not been set" on the first deploy: pass --secrets-file (step 5). Later deploys reuse the stored secrets.
  • Sign-in fails with an origin error: APP_URL must match the URL in the address bar exactly, scheme included, no trailing slash.
  • "invalid or expired state" after the manifest flow: the script and the Worker used different BETTER_AUTH_SECRET values, or more than an hour passed. Rerun the script.
  • The manifest callback answers 404: SELF_HOSTED is not "true" on the deployed Worker.
  • Scans end in error with a subrequest limit message: the account is on Workers Free.
  • No digest email: check that both Lettr secrets are set and that something actually changed; Repodar stays quiet otherwise.

Last updated · View as Markdown