# 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](https://lettr.com) account with a verified sender address, for the daily
  digest email. Without it, Repodar works and simply sends no email.

## 1. Clone and install

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

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

```sh
cd apps/web
pnpm exec wrangler login
```

## 2. Create the database, cache and queues

```sh
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:

```sh
pnpm --filter @repodar/web cf-typegen
```

## 4. Apply the database migrations

```sh
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):

```sh
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):

```sh
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:

```sh
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):

```sh
pnpm exec wrangler secret put LETTR_API_KEY
pnpm exec wrangler secret put LETTR_FROM
```

3. 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](/docs/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](/docs/sources.md#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

```sh
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`.

```sh
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:

```sh
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:

```sh
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:

```sh
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:

```sh
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.
