# Convoy [![CI](https://github.com/juaniartal/convoy/actions/workflows/ci.yml/badge.svg)](https://github.com/juaniartal/convoy/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) ![Node](https://img.shields.io/badge/node-%3E%3D20-brightgreen) Self-hosted, real-time GitHub Actions pipeline visualizer for your whole organization — every repo, every run, on one screen, live. Convoy installs a GitHub App on your org (or just your own account) and shows every repo's workflow runs on one screen, split into three views: - **Deploys** — runs triggered by a production release (a version tag, or a published GitHub Release) - **Pipelines** — everything else (main, qa, feature/*, bugfix/*, whatever) - **Overview** — a summary page: what portion of your repos are healthy vs. down right now, one donut chart per view Updates come in through GitHub webhooks, not polling. That's the part that lets it scale to hundreds of repos without hammering the GitHub API. > **Every instance is fully independent.** Running Convoy means creating > your *own* GitHub App, with your own credentials, on your own account or > org. Nobody who self-hosts Convoy ever needs mine, or anyone else's — > that's the whole point of self-hosted. ![Convoy dashboard](docs/assets/screenshot.png) ## Contents - [Why this exists](#why-this-exists) - [Try it out](#try-it-out) - [Create your GitHub App](#create-your-github-app) - [Run it — just for you](#run-it--just-for-you) - [Run it — for your team](#run-it--for-your-team) - [Using the dashboard](#using-the-dashboard) - [Is it actually real-time?](#is-it-actually-real-time) - [Design principles](#design-principles) - [How classification works](#how-classification-works) - [Access control](#access-control) - [Configuration reference](#configuration-reference) - [Status](#status) - [License](#license) - [Maintainer](#maintainer) ## Why this exists I work somewhere that deploys a lot of small services on Thursdays, and watching that roll out used to mean 40 open tabs, one per repo's Actions page, refreshing them by hand to see what landed and what didn't. Convoy is the tool I wished existed: point it at your org once, and it just shows you what's deploying right now and what's normal CI traffic, live, without anyone pasting a list of links into Slack. ## Try it out No GitHub App, no webhooks, nothing to configure: ```bash git clone && cd convoy npm install npm run demo ``` Opens at `http://localhost:3000` with fake seeded data — good for a first look before you commit to anything real. ## Create your GitHub App Everything past this point — trying it against your own repos, running it for yourself, running it for a team — starts with the same one-time step: creating a **GitHub App**. It's just an identity Convoy uses to talk to GitHub on your behalf: GitHub notifies it the instant a workflow run changes (a webhook), and it can read — only read — Actions run data through the API. You create your own, once. It's free, and it never talks to anyone else's Convoy instance, or vice versa. **The easy way (recommended):** ```bash git clone && cd convoy npm install cp .env.example .env npm run dev ``` The first run opens your browser through GitHub's own "app manifest" flow — it already knows which permissions and webhook events Convoy needs (from `app.yml`), so you just confirm a name and click **Create GitHub App**. It writes the App ID, private key, and webhook secret into `.env` for you automatically. Then: 1. Probot logs `Probot has been set up, please restart the server!` — that's expected, not an error. Stop it (Ctrl+C) and run `npm run dev` again; the first process was only running in "setup mode" and won't serve the real dashboard until it restarts with the credentials that were just written. 2. Install the App on whichever repos or org you want Convoy to watch — GitHub prompts you for this right after creating it. 3. Open `http://localhost:3000` — your real pipelines should be there. For local webhook delivery, this flow relays through [smee.io](https://smee.io) by default — free, but a third-party relay, and ephemeral (not meant to stay up forever). That's fine for local dev; production never uses it — see [Run it — for yourself](#run-it--just-for-you) and [for your team](#run-it--for-your-team) below, GitHub delivers straight to your own server or ingress there, no relay involved.
The manual way — if the automatic flow crashes, or you want the App ready before running any code I hit a real bug here myself: Probot 14's automatic manifest flow can crash with `SmeeClient is not a constructor` on Node 20/24 — a known incompatibility in how it dynamically imports `smee-client` at runtime (confirmed it's not a network/proxy issue on my end, since a plain `npx smee-client@5.0.0` runs fine on its own). If that happens, set the App up by hand instead: **1. Create the App.** Go to and fill in: - **GitHub App name** — anything unique (e.g. `yourname-convoy`). - **Homepage URL** — GitHub requires *something* here even though nothing reads it for local testing. `http://localhost:3000` is fine. - **Callback URL** and **"Request user authorization (OAuth) during installation"** — leave both alone (empty/unchecked). That section is for apps that implement "Login with GitHub" for end users, which Convoy deliberately doesn't do — see [Design principles](#design-principles) for why. - **Webhook → Active** — checked. - **Webhook → Webhook URL** — a smee.io channel. Open in another tab first, it gives you a URL like `https://smee.io/AbCdEfGh123`; paste that here. - **Webhook → Webhook secret** — *you* make this up, it isn't generated for you. Any string works, but a real random one is one command away: `openssl rand -hex 20`. Remember it, it goes in `.env` next. - **Repository permissions → Actions** — Read-only. - **Repository permissions → Metadata** — Read-only (usually auto-selected once Actions is set). - **Subscribe to events** — check `Workflow run` and `Workflow job`. - **Where can this GitHub App be installed?** — "Only on this account" is fine for personal testing. Click **Create GitHub App**. GitHub creates it and shows its settings page — note the **App ID** near the top, you'll need it in a minute. **2. Generate the private key.** Still on that settings page, scroll to **Private keys** → **Generate a private key**. Your browser downloads a `.pem` — a key GitHub just generated specifically for this App, not any SSH key you might already have (that mix-up is easy to make and doesn't work — GitHub App keys are always RSA and start with `-----BEGIN RSA PRIVATE KEY-----`; if you paste in something else, `npm run dev` now tells you clearly instead of failing in a confusing way). Move the downloaded file into this project folder and rename it to `private-key.pem`. **3. Fill in `.env` by hand:** ``` APP_ID= WEBHOOK_SECRET= PRIVATE_KEY_PATH=./private-key.pem ``` Leave `WEBHOOK_PROXY_URL` **empty** — setting it at all triggers the crash this whole section exists to work around, even outside setup mode. **4. Run it.** Two terminals, both from inside this project folder: Terminal 1: ```bash npm run dev ``` Terminal 2 (the smee relay, running independently): ```bash npx smee-client@5.0.0 -u https://smee.io/ -t http://localhost:3000/api/github/webhooks ``` **5. Install the App.** From the App's settings page, click **Install App** in the left sidebar, pick your account, and choose a couple of test repos. GitHub redirects you to a generic "Installation complete" confirmation page when you're done — that's just GitHub's own page, not part of Convoy, nothing to do there. Open `http://localhost:3000` and you should see your repos. **Don't want to deal with a tunnel at all yet?** You don't have to. Put any syntactically valid URL in the Webhook URL field (even one that goes nowhere) and skip terminal 2 entirely. Every webhook delivery will show as failed in GitHub's UI, but Convoy still updates on its own every few minutes via reconciliation — you just won't see changes the instant they happen. Good enough for "does this work at all", set up the smee tunnel later if you want it live.
## Run it — just for you Once you're past trying it out, stop relying on smee — it's a free, ephemeral, third-party relay, exactly right for "let me see this work for twenty minutes," and exactly wrong for anything you plan to depend on. Two ways to do this instead, pick whichever matches how you actually work: **A. A small always-on VPS.** No Kubernetes needed. A cheap box (a $5-6/mo DigitalOcean/Hetzner droplet, a home server, even a Raspberry Pi) running Docker is enough to keep it up permanently: ```bash docker run -d --restart=always -p 3000:3000 \ -e APP_ID=... \ -e WEBHOOK_SECRET=... \ -e PRIVATE_KEY_PATH=/app/private-key.pem \ -v $(pwd)/private-key.pem:/app/private-key.pem:ro \ -v $(pwd)/convoy.yaml:/app/convoy.yaml:ro \ ghcr.io/juaniartal/convoy:latest ``` `--restart=always` means it survives the box rebooting. Point your GitHub App's webhook URL at `http://:3000/api/github/webhooks` — put a real domain and TLS in front if you want it cleaner (a reverse proxy like Caddy makes that close to a one-liner, see the walkthrough below). The box itself has a public IP, so GitHub reaches it directly — no relay of any kind needed here. **B. Local Kubernetes, only running when you're actually looking.** If you already run Docker Desktop / kind / minikube for other things, you don't need a VPS at all — install the Helm chart locally and bring it up only when you want to check on something: ```bash helm install convoy ./helm/convoy \ --set-file github.privateKey=./private-key.pem \ --set github.appId= \ --set github.webhookSecret= kubectl port-forward svc/convoy-convoy 3000:80 # whenever you want to look ``` Unlike option A, a port-forward is only reachable from your own machine — GitHub can't deliver anything to it directly. Two ways to handle that: - **Don't bother.** Leave the webhook URL pointed at nothing reachable. Every delivery shows as failed in GitHub's UI (harmless), and reconciliation still catches everything up automatically within a few minutes of you bringing Convoy back up. This is genuinely how I ran my own instance for most of building this. - **Want it truly live instead of "catches up in a few minutes"?** Use a tunnel with a **static/persistent domain** — a free [ngrok](https://ngrok.com) account gives you one for exactly this (not smee — this needs a stable address you control, not an ephemeral test channel). Point the tunnel at `localhost:3000` whenever Convoy is up, and set your GitHub App's webhook URL to that ngrok domain *once* — since the domain doesn't change between runs, you never have to touch the App's settings again, just start/stop the tunnel alongside Convoy itself. Either way, once it's reachable by anyone but you, set `CONVOY_API_KEY` — one env var gets you a real login page instead of a wide-open dashboard. See [Access control](#access-control).
New to Docker? Full walkthrough for option A, from zero Every step needed to get a real domain serving Convoy over HTTPS, permanently, on a plain VPS — not assuming you've done any of this before. 1. **Get a VPS.** Any provider works (DigitalOcean, Hetzner, a spare machine at home with port forwarding). You need: a public IP address, and root/sudo access. $5-6/mo is plenty — Convoy is light. 2. **Install Docker on it.** SSH in, then: ```bash curl -fsSL https://get.docker.com | sh ``` That's Docker's own official install script, works on basically every Linux distro. (On your own laptop instead of a VPS, install Docker Desktop from docker.com — but a laptop that sleeps isn't "always on", which is the whole point of this case.) 3. **Point a domain at it.** Buy a domain anywhere (Namecheap, Google Domains, whatever), or use a subdomain of one you already own. In your DNS provider's dashboard, add an **A record**: name `convoy` (or whatever subdomain), value = your VPS's public IP. DNS changes can take a few minutes to a few hours to propagate; `dig convoy.yourdomain.com` should eventually show your VPS's IP. 4. **Create the GitHub App** — see [Create your GitHub App](#create-your-github-app) above, same steps regardless of where Convoy ends up running. Save the App ID, the downloaded private key, and the webhook secret. 5. **Run Convoy and a reverse proxy together**, so you get real HTTPS without touching certificates by hand — Caddy gets one automatically from Let's Encrypt the moment it sees a real domain pointed at it: ```bash docker network create convoy-net docker run -d --name convoy --restart=always --network convoy-net \ -e APP_ID= \ -e WEBHOOK_SECRET= \ -e PRIVATE_KEY_PATH=/app/private-key.pem \ -v $(pwd)/private-key.pem:/app/private-key.pem:ro \ ghcr.io/juaniartal/convoy:latest cat > Caddyfile <<'EOF' convoy.yourdomain.com { reverse_proxy convoy:3000 } EOF docker run -d --name caddy --restart=always --network convoy-net \ -p 80:80 -p 443:443 \ -v caddy_data:/data \ -v $(pwd)/Caddyfile:/etc/caddy/Caddyfile \ caddy:2 ``` Replace `convoy.yourdomain.com` with your real domain in the Caddyfile. Give it a minute — Caddy needs to request and validate the certificate the first time. 6. **Point the GitHub App's webhook URL** at `https://convoy.yourdomain.com/api/github/webhooks` (in the App's settings page). 7. **Check it's alive**: `curl https://convoy.yourdomain.com/api/healthz` should return `{"status":"ok",...}`. Open the domain in a browser — that's your dashboard, permanently up, with real HTTPS, no relay of any kind between GitHub and your server.
## Run it — for your team This is what the Helm chart is actually built for: ```bash helm install convoy ./helm/convoy \ --set-file github.privateKey=./private-key.pem \ --set github.appId= \ --set github.webhookSecret= \ --set ingress.host=convoy.your-internal-domain.com ``` See `helm/convoy/values.yaml` for the full set of configurable values. Pulling from a private registry? You'll want `imagePullSecrets` too. Before putting this behind a real Ingress host, turn on a login — set `apiKey` for a single shared password, or the `oidc.*` values for a real "Log in with \" button backed by Azure AD/Google/Okta/etc., whatever your team already uses: ```bash helm install convoy ./helm/convoy \ --set-file github.privateKey=./private-key.pem \ --set github.appId= \ --set github.webhookSecret= \ --set ingress.host=convoy.your-internal-domain.com \ --set oidc.issuerUrl=https://login.microsoftonline.com//v2.0 \ --set oidc.clientId= \ --set oidc.clientSecret= \ --set oidc.redirectUri=https://convoy.your-internal-domain.com/api/auth/oidc/callback ``` Both `apiKey` and `oidc.*` can be set together — see [Access control](#access-control) for the full picture on either one, including how to actually get those 4 OIDC values from your provider. Already manage credentials through External Secrets Operator, Vault, or a cloud secret manager instead of plain `--set`/`--set-file`? Set `secret.create=false` and point Convoy at a Secret you provide yourself — there are working examples for both **Azure Key Vault** and **AWS Secrets Manager** under `helm/convoy/examples/` (same shape works for GCP Secret Manager or Vault too, just swap the `secretStoreRef`). Either way, the last step is the same: in your GitHub App's settings, set the webhook URL to your deployed instance's `/api/github/webhooks` endpoint.
New to Kubernetes? Full walkthrough, from zero Covers both a cluster you manage yourself and Azure AKS — everything between "I have nothing" and a real HTTPS domain serving Convoy inside your cluster, permanently. 1. **Get a cluster.** - **Azure AKS:** ```bash az group create --name convoy-rg --location eastus az aks create --resource-group convoy-rg --name convoy-aks \ --node-count 1 --generate-ssh-keys az aks get-credentials --resource-group convoy-rg --name convoy-aks ``` (One node is enough for Convoy itself; bump `--node-count` if this cluster runs other things too.) - **Your own cluster, nothing set up yet:** [k3s](https://k3s.io) is the fastest way to get a real, working cluster on any Linux box: `curl -sfL https://get.k3s.io | sh -`. Already have a cluster and `kubectl` pointed at it? Skip to step 2. 2. **Install `kubectl` and Helm**, if you don't have them — [kubernetes.io/docs/tasks/tools](https://kubernetes.io/docs/tasks/tools/) and: ```bash curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash ``` 3. **Install an Ingress controller** — this is what actually routes `https://convoy.yourdomain.com` to the right pod inside the cluster; a fresh cluster doesn't have one yet: ```bash helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx helm repo update helm install ingress-nginx ingress-nginx/ingress-nginx \ --namespace ingress-nginx --create-namespace ``` Get its external IP (on AKS or any cloud, this appears within a minute or two; on bare-metal k3s with no cloud load balancer, it can stay `` — that needs something like [MetalLB](https://metallb.io/), a bare-metal-specific extra step outside what this guide covers): ```bash kubectl get svc -n ingress-nginx ingress-nginx-controller ``` 4. **Point a domain at that IP** — same as the Docker walkthrough above: an A record in your DNS provider, name = your subdomain, value = the IP from step 3. 5. **Install cert-manager**, so HTTPS certificates get issued and renewed automatically instead of by hand: ```bash helm repo add jetstack https://charts.jetstack.io helm repo update helm install cert-manager jetstack/cert-manager \ --namespace cert-manager --create-namespace --set crds.enabled=true ``` Then tell it how to issue certificates (swap in a real email — Let's Encrypt uses it for expiry notices, nothing else): ```bash cat <<'EOF' | kubectl apply -f - apiVersion: cert-manager.io/v1 kind: ClusterIssuer metadata: name: letsencrypt spec: acme: server: https://acme-v02.api.letsencrypt.org/directory email: you@example.com privateKeySecretRef: name: letsencrypt-key solvers: - http01: ingress: ingressClassName: nginx EOF ``` 6. **Create the GitHub App** — see [Create your GitHub App](#create-your-github-app) above, same steps regardless of where Convoy runs. 7. **Install Convoy itself.** Put this in `convoy-values.yaml`: ```yaml github: appId: '' webhookSecret: '' ingress: enabled: true className: nginx host: convoy.yourdomain.com annotations: cert-manager.io/cluster-issuer: letsencrypt tls: enabled: true secretName: convoy-tls ``` then: ```bash helm install convoy ./helm/convoy \ -f convoy-values.yaml \ --set-file github.privateKey=./private-key.pem ``` 8. **Point the GitHub App's webhook URL** at `https://convoy.yourdomain.com/api/github/webhooks`. 9. **Check it's alive**: `curl https://convoy.yourdomain.com/api/healthz` should return `{"status":"ok",...}` within a minute or so of the cert-manager certificate finishing issuance. Open the domain in a browser — that's your team's dashboard, live, inside your own cluster.
## Using the dashboard - **Deploys / Pipelines / Overview** are tabs, not filters — switch between them at the top. Overview is a health summary (one donut chart per view: how many repos are healthy vs. down right now); the other two list repos directly. - **The Deploys tab defaults to "the current release"** — only deploys that started within an hour of the most recent one, the same scoped view a hand-pasted list used to give you, derived automatically instead. Click **Show all deploys** to see the full history instead. - **Search** filters by repo name or tag as you type. - **★ favorites a repo** (stored in your own browser, not shared between people looking at the same instance) — turn on **Favorites only** to see just those. - **Hide inactive (48h+)** hides repos with nothing running or recently finished. Even without it checked, a repo quiet for 48h+ just looks dimmer, not hidden — it still belongs on the board with its last known state; disappearing entirely would read as "broken," not "idle." - A repo's status dot reflects its **worst** pipeline, not just its most recent one — if one workflow is down and another just succeeded, the card shows down. - Each card shows a short preview (1 deploy, or 3 pipelines) with a "+N more" link. **Clicking a card** opens the full detail view for that repo (up to 5 most recent runs), with a link to see everything older on GitHub itself. ## Is it actually real-time? Convoy always runs three things at once — there's no setting that picks between them: - **Webhooks** push an update the instant something happens on GitHub. This is what makes the board feel instant, and how it stays that way without polling hundreds of repos on a timer. - **The active-run watcher** re-checks whatever Convoy currently thinks is still running (queued/in-progress), every ~30 seconds, but *only* those — reading "what's active" costs nothing (it's already in memory), and checking one specific run by id is a single cheap request. This exists because GitHub does drop webhook deliveries under bursty load — confirmed directly, not theoretical: pushing to 20 repos at once dropped roughly half the "completed" events one run, and every run that got stuck showing "rolling" corrected itself within the next ~30-second tick, not 5 minutes later. - **Reconciliation** polls every repo directly every 5 minutes, regardless, whether webhooks are working or not. It's the last-resort net under both of the above — a run that was never even seen as "active" (its very first webhook was the one that got dropped) still gets caught here. Which one you're actually getting at any moment comes down entirely to whether GitHub can reach Convoy's webhook endpoint. Three things have to be true: 1. Your GitHub App's webhook URL is set to something real (not blank, not pointing at a tunnel that isn't running). 2. That URL is reachable from the public internet. 3. **It points at wherever Convoy is *actually* running right now — same host, same port.** This is the one people get bitten by: a tunnel or Ingress keeps forwarding traffic to an address nothing is listening on anymore (an old process, a different port after a restart), and nothing anywhere throws an error — you just quietly start getting 5-minute-stale updates instead of instant ones. **Does it get slower with more people watching?** No -- that's a deliberate property, not an accident. The board is identical for everyone, so it's built once per change and handed to every viewer: one walk of the state, one JSON serialization, one gzip, one subscription to state fanned out to all connected tabs. A viewer whose copy is already current gets a 304 with no body at all, which is what most of those 30-second safety-net polls are. Measured on a 100-repo org: 2.00 MB of board becomes 49 KB on the wire, an extra viewer costs ~0.6 ms, and an unchanged poll costs ~0.3 ms and zero bytes. Ten people watching a release is the same work as one. **How to check which one you're actually getting:** hit `/api/healthz`: ```json { "status": "ok", "installationCount": 3, "lastReconciledAt": "...", "lastWebhookReceivedAt": "..." } ``` Trigger a run on GitHub and watch `lastWebhookReceivedAt`. If it updates within a second or two, webhooks are live. If it stays stuck (or `null`) while `lastReconciledAt` keeps advancing every 5 minutes on its own, GitHub isn't reaching you — check point 3 above first, it's the usual culprit. **To make sure it's always real-time, not just right after setup:** - **Just for you**: the always-on VPS option is the simplest way to guarantee this — one process, one public IP, nothing in between that can drift out of sync. The local-cluster-plus-tunnel option works too, but you're responsible for keeping the tunnel and Convoy pointed at the same port every time you bring it back up. - **For your team**: a real Ingress on a domain that's always up. Once it's wired correctly, Kubernetes keeps the Service pointed at whichever pod is actually alive automatically — nothing to keep in sync by hand. ## Design principles A few decisions here were deliberate, so I'm writing down the reasoning instead of leaving people to guess: - **Self-hosted, never a hosted service.** Convoy ships as a Docker image and a Helm chart. Your CI data — repo names, workflow status, run metadata — never leaves your own infrastructure. There's no Convoy-operated backend anywhere. - **Webhooks first, polling only as a safety net.** A GitHub App installed on your org gets `workflow_run`/`workflow_job` events directly. A fast watcher re-checks only currently-active runs every ~30s, and a slower full reconciliation pass runs every 5 minutes regardless — both exist to catch webhook deliveries GitHub failed to send, not how Convoy stays up to date under normal operation. See [Is it actually real-time?](#is-it-actually-real-time) for why this matters more than it sounds like it should. - **One GitHub credential, separate from who can view the dashboard.** The GitHub App is the only credential Convoy ever uses to talk to GitHub — it's not tied to any individual's account. Who can *view* the dashboard is a different question, answered by an optional shared access key or OIDC login (see Access control below), or left to your network entirely. - **Minimal permissions.** The App requests `actions: read` and `metadata: read`. Nothing else. It can't read file contents, can't write anything, can't touch your workflows. - **No database in v1.** State lives in memory, rebuilt from webhooks plus periodic reconciliation. There's no history or trend analytics yet — this is a live status board, not an analytics platform. That might change someday, but v1 is meant to stay small enough that I can actually finish it. - **Single instance.** Because state is just in-process memory, Convoy runs as exactly one replica (the Helm chart pins this). More than one wouldn't share state, and each replica would see a different, incomplete slice of GitHub's webhook deliveries. ## How classification works A run counts as a **deploy** if: - it was triggered by publishing a GitHub Release, or - it was a `push` whose ref looks like a version tag (`v1.2.3`, `1.2.3-beta`, that kind of thing) Everything else is a **pipeline** — including pushes to long-lived environment branches like `develop` or `qa`, even if that push technically triggers a real deployment somewhere. That's intentional: "Deploy" is meant to answer "did we ship a real release", not "did a file get copied somewhere". A feature branch merged into `qa` isn't a release just because the job that runs is *named* deploy. The default (tag-shaped ref = deploy) already covers the common setup — tag `v1.2.3`, deploy, done — with zero config. You only need an override in `convoy.yaml` when there's genuinely no other signal for "this is a real release": - **You tag releases, but also deploy on every push to some branches** (`develop`/`qa`/`main` as environments, prod cut from a tag) — no override needed, the default already gets this right; branch pushes to those environments correctly stay as pipelines. - **You have no tags at all, and production *is* a long-lived branch** — e.g. merging to `main` deploys straight to prod, nothing ever gets tagged. Here the branch itself is the only signal, so add: ```yaml overrides: - repo: your-org/your-repo strategy: branch deployBranches: [main] ``` See `convoy.yaml.example` for the three override strategies (branch-based, tag-pattern, workflow-name), plus an `excludeRepos` list for archived or irrelevant repos. ## Access control > **⚠️ By default, Convoy has no login of its own — don't expose it > directly to the public internet without turning one on.** Neither > `CONVOY_API_KEY` nor `CONVOY_OIDC_*` set means anyone who reaches the URL > sees the dashboard, no questions asked. Turning one of them on takes one > env var. Publishing Convoy's source code doesn't expose anything about your own running instance. Your GitHub App's private key and webhook secret live only in your own `.env`/Kubernetes Secret, never in git. The one thing you do need to think about: the webhook endpoint (`/api/github/webhooks`) has to be public so GitHub can reach it, and that part's fine to leave open — it verifies GitHub's signature and rejects anything else. The **dashboard itself** needs one of the following if it's reachable from anywhere you don't fully trust: 1. **Set `CONVOY_API_KEY`.** Gates the dashboard behind Convoy's own login page with this as the shared access key. Scripts and reverse proxies can still send `Authorization: Bearer ` directly instead of going through the page. One password for the whole team, no accounts to manage — the simplest option, and enough for most self-hosters. 2. **Set `CONVOY_OIDC_*` for real SSO.** Adds a "Log in with \" button to the same login page, backed by any standard OIDC provider — Azure AD, Google, Okta, whatever your org already uses. Needs an app registration at your provider first: ```bash CONVOY_OIDC_ISSUER_URL=https://login.microsoftonline.com//v2.0 CONVOY_OIDC_CLIENT_ID= CONVOY_OIDC_CLIENT_SECRET= CONVOY_OIDC_REDIRECT_URI=https://convoy.your-domain.com/api/auth/oidc/callback ``` That last value has to exactly match the redirect URI registered at the provider. Both `CONVOY_API_KEY` and `CONVOY_OIDC_*` can be set together — the login page shows the SSO button and the access-key field, either one gets you in.
New to OIDC? Full walkthrough for getting those 4 values, from zero Convoy doesn't implement its own login — it delegates to whatever identity provider your org already uses. You register Convoy as an "application" at that provider once, and it hands you the 4 values above. Pick the provider you use: **Azure AD / Microsoft Entra ID** 1. Go to [entra.microsoft.com](https://entra.microsoft.com) → **App registrations** → **New registration**. 2. Name it anything (e.g. `Convoy`). **Supported account types**: single tenant, unless you specifically want people outside your org to be able to log in. **Redirect URI**: platform **Web**, value `https://convoy.your-domain.com/api/auth/oidc/callback` — exact match required, including that path. 3. On the app's overview page, copy **Application (client) ID** → that's `CONVOY_OIDC_CLIENT_ID`. 4. Copy **Directory (tenant) ID** from the same page, and build: `CONVOY_OIDC_ISSUER_URL=https://login.microsoftonline.com//v2.0` 5. **Certificates & secrets** → **New client secret** → copy the **Value** column immediately (it's hidden forever after you leave the page) → that's `CONVOY_OIDC_CLIENT_SECRET`. 6. Restrict who can log in (recommended — Convoy itself doesn't filter by group): go to **Enterprise applications** → find your app → **Properties** → set **Assignment required** to **Yes** → then **Users and groups** → add only the people/teams who should have access. Azure rejects anyone else's login before Convoy ever sees it. **Google Workspace** 1. [Google Cloud Console](https://console.cloud.google.com) → **APIs & Services** → **Credentials** → **Create credentials** → **OAuth client ID** → application type **Web application**. 2. **Authorized redirect URIs** → `https://convoy.your-domain.com/api/auth/oidc/callback`. 3. `CONVOY_OIDC_ISSUER_URL=https://accounts.google.com`, and the **Client ID**/**Client secret** Google shows you after creating it map straight to `CONVOY_OIDC_CLIENT_ID`/`CONVOY_OIDC_CLIENT_SECRET`. 4. To restrict to your workspace domain instead of any Google account, set the OAuth consent screen's **User type** to **Internal**. **Okta** 1. Okta admin panel → **Applications** → **Create App Integration** → **OIDC - OpenID Connect** → **Web Application**. 2. **Sign-in redirect URI** → `https://convoy.your-domain.com/api/auth/oidc/callback`. 3. `CONVOY_OIDC_ISSUER_URL=https:///oauth2/default`, plus the **Client ID**/**Client secret** from the app you just created. 4. Under **Assignments**, pick only the users/groups who should have access. **Any other provider** works the same way as long as it speaks standard OIDC: you always end up with an issuer URL, a client ID, a client secret, and a redirect URI you register up front. Once you have the 4 values, set them locally in `.env` or on a running deployment via Helm: ```bash helm upgrade convoy ./helm/convoy \ --set oidc.issuerUrl= \ --set oidc.clientId= \ --set-string oidc.clientSecret= \ --set oidc.redirectUri=https://convoy.your-domain.com/api/auth/oidc/callback ``` Restart (Docker) or let the rollout finish (Helm), open the dashboard, and a "Log in with SSO" button appears.
3. **Or skip both and put it behind your existing SSO/VPN/authenticating ingress instead.** Convoy doesn't need to know who you are, it just needs to not be reachable by people who shouldn't see it — if your network already handles that, there's nothing else to configure. ## Configuration reference **GitHub App** | Env var | Required | Description | |---|---|---| | `APP_ID` | yes | GitHub App ID | | `PRIVATE_KEY_PATH` or `PRIVATE_KEY` | yes (one of the two) | Path to the App's private key `.pem`, or its raw contents (what the Helm chart uses, via a Secret) | | `WEBHOOK_SECRET` | yes | GitHub App webhook secret | | `PORT` | no (default `3000`) | HTTP port | | `CONVOY_CONFIG_PATH` | no (default `./convoy.yaml`) | Path to classification overrides | **Access control** — see [Access control](#access-control) for the full picture | Env var | Required | Description | |---|---|---| | `CONVOY_API_KEY` | no | Shared access key. Gates the dashboard behind Convoy's own login page; scripts can also send `Authorization: Bearer ` directly | | `CONVOY_OIDC_ISSUER_URL` | no (all four together, or none) | Your OIDC provider's issuer URL (Azure AD, Google, Okta, ...) | | `CONVOY_OIDC_CLIENT_ID` | no | Client ID from your provider's app registration | | `CONVOY_OIDC_CLIENT_SECRET` | no | Client secret from the same app registration | | `CONVOY_OIDC_REDIRECT_URI` | no | Must exactly match what's registered at the provider, ending in `/api/auth/oidc/callback` | | `CONVOY_OIDC_BUTTON_LABEL` | no (default `Log in with SSO`) | Text on the login page's SSO button | | `CONVOY_OIDC_ALLOW_INSECURE` | no | **Dev/test only.** Lets `CONVOY_OIDC_ISSUER_URL` point at a plain-HTTP IdP for local testing. Never set this against a real deployment — every real provider is HTTPS | ## Status Early days. v1 is focused on getting the core loop right — webhooks, classification, live dashboard, real-time reliability, and login (shared key or OIDC/SSO) — self-hosted. A single GitHub App can already be installed across multiple orgs or accounts at once — every repo shows up together on one board, there's just no per-org filter in the UI yet. No history/analytics yet, no per-user accounts or audit trail (one shared login or SSO, not individual identities). That's on purpose, not an oversight; see open issues for what's actually planned. ## License MIT — see `LICENSE`. ## Maintainer This is my first real open-source project. Built and maintained by [Juan Ignacio](https://www.linkedin.com/in/juanignaciodev/), a DevOps/SRE engineer — I built Convoy to solve a real problem on my own team, and I still run it myself day to day. Issues and PRs are welcome, see `CONTRIBUTING.md`.