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 usepicks it up) and pnpm 10 (corepack enablegives you the version pinned inpackage.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):
d1_databases[0].database_id: the id fromwrangler d1 create.kv_namespaces[0].id: the id fromwrangler kv namespace create.routes: replacerepodar.devwith your domain, for example[{ "pattern": "repodar.your.domain", "custom_domain": true }]. Cloudflare creates the DNS record and certificate on deploy. No domain? Delete theroutesentry; the Worker is then served onworkers.dev.vars.APP_URL: the exact URL you will open in the browser, without a trailing slash, for examplehttps://repodar.your.domain. Sign-in only accepts requests from this origin.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.vars.BILLING_ENABLED: leave it"false"(or absent). It only matters for the hosted service;SELF_HOSTED: "true"overrides it anyway.vars.GITHUB_APP_SLUG: fill in after step 6 (the slug of your App, from its URLgithub.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:
- Copy every line except
GITHUB_APP_SLUGinto~/repodar.secrets, replacing the placeholders. - Put the slug into
vars.GITHUB_APP_SLUGinapps/web/wrangler.jsonc(a var, not a secret: a name cannot be both). - 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
- Create a Lettr API key and verify the sender domain or address there.
- Set both secrets (they take effect without a redeploy):
pnpm exec wrangler secret put LETTR_API_KEY
pnpm exec wrangler secret put LETTR_FROM
- 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:
- 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.
- In your GitHub repository, Settings > Secrets and variables > Actions, add
CLOUDFLARE_API_TOKENandCLOUDFLARE_ACCOUNT_ID(the account id is on the Workers overview page). - 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_URLmust 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_SECRETvalues, or more than an hour passed. Rerun the script. - The manifest callback answers 404:
SELF_HOSTEDis not"true"on the deployed Worker. - Scans end in
errorwith 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.