Hosting Mergepace on Fly.io
This puts Mergepace online at https://<name>.fly.dev. People sign in with
GitHub, and agents ask it for work. It takes about 30 minutes.
Cost: about US$4 a month, for one small always-on machine (shared CPU,
512 MB: $3.69 a month in Fly's iad region, October 2026 prices) and
Fly's free shared IPv4 address. Other regions cost a little more. There is
no paid database (step 4b uses Neon's free plan) and no paid add-on.
What you need:
- a GitHub account that owns the
mergepaceorganization; - a credit card for Fly.io;
- a terminal with
gitand the GitHub CLI (gh).
Run every command from step 2 on inside the mergepace folder.
---
1. Get the code and tag the first version
The repository is private, so sign in to GitHub first:
gh auth login # once; choose GitHub.com and HTTPS
gh repo clone mergepace/mergepace
cd mergepace
The CI guide pins @v0.1.0, so that tag must exist. If git asks who you
are, run git config --global user.name "Your Name" and
git config --global user.email you@example.com first.
git tag -a v0.1.0 -m "v0.1.0" 958e964dc714e1c80a859949bccb31fd291afb67
git push origin v0.1.0
2. Install Fly and pick a name
On a Mac: brew install flyctl. Elsewhere:
curl -L https://fly.io/install.sh | sh, then open a new terminal so
the fly command is found, and cd back into mergepace.
fly auth signup # or: fly auth login
fly launch --no-deploy --copy-config --name <name>
- Pick a free name, for example
mergepace-app. Mergepace will then be athttps://<name>.fly.dev. - If it asks whether to tweak the settings, answer No. Keep the region
iad, which the cost above assumes. - If it offers a database, Redis or a GitHub Actions workflow, answer No.
- It writes your app name into
fly.toml. You can leave that change uncommitted; Fly reads the file from this folder.
3. Create the GitHub App
Open github.com/organizations/mergepace/settings/apps/new and fill in:
| Field | Value |
| --- | --- |
| GitHub App name | Mergepace (or another free name) |
| Homepage URL | https://<name>.fly.dev |
| Callback URL | https://<name>.fly.dev/auth/callback |
| Expire user authorization tokens | on (the default) |
| Webhook: Active | on |
| Webhook URL | https://<name>.fly.dev/webhooks/github |
| Webhook secret | run openssl rand -hex 32 and paste the result; keep it for step 4 |
Repository permissions (use exactly these, the same as github-app.md section 1; everything else stays "No access"):
- Actions, Contents, Issues, Metadata and Pull requests: Read-only.
- Dependabot alerts and Code scanning alerts: Read-only. Without them the merge rules can't read alerts, treat them as open, and ask a person to approve every pull request.
- Checks: Read and write. This posts the "Mergepace merge rules" check, the only thing Mergepace writes on its own.
- Commit statuses: Read-only (optional: lets Mergepace see the results of CI that reports commit statuses, such as Jenkins or Buildkite, and re-checks as soon as one changes; without it, only checks and GitHub Actions count).
- Administration: Read and write (optional: lets the "Require this check" button add the ruleset; Mergepace changes nothing else with it).
Subscribe to events: Pull request, Push, Workflow run, Issues, Check run, Check suite, Pull request review, Issue comment, Dependabot alert and Code scanning alert (shown once those permissions are given), and Status (shown once Commit statuses is given). Without Pull request review and Issue comment, reviews only count on the next check, up to 15 minutes later.
Where can this app be installed: Only on this account.
Click Create GitHub App. GitHub may try a test delivery right away; it fails because nothing is online yet, which is harmless. On the next page:
- note the App ID and the Client ID;
- click Generate a new client secret and copy it;
- scroll down and click Generate a private key; a
.pemfile downloads.
4. Give Fly the secrets
Run openssl rand -hex 32 once more for the token key: it signs the access
keys you'll make in step 8.
Put the values in a file instead of typing them on the command line, so
they don't end up in your shell history. Create mergepace.env with any
editor:
MERGEPACE_APP_ID=<App ID>
MERGEPACE_APP_CLIENT_ID=<Client ID>
MERGEPACE_APP_CLIENT_SECRET=<client secret>
MERGEPACE_WEBHOOK_SECRET=<webhook secret from step 3>
MERGEPACE_PUBLIC_URL=https://<name>.fly.dev
MERGEPACE_TOKEN_KEY=<token key>
Then:
fly secrets import < mergepace.env
fly secrets set MERGEPACE_APP_PRIVATE_KEY="$(cat ~/Downloads/<the-file>.pem)"
rm mergepace.env ~/Downloads/<the-file>.pem
Fly keeps the secrets encrypted; they never go in the repository.
4b. Database (recommended)
Without a database, a restart forgets agents' claims and past replays. With one, Mergepace saves them every 10 minutes and when it stops, and reads them back when it starts.
- Sign up at neon.com (free, no card), create a project,
region AWS US East 1 (N. Virginia), next to Fly's
iad. - Copy the connection string without
-poolerin its host name (the direct one). For full certificate checks, changesslmode=requireat its end tosslmode=verify-full. fly secrets set DATABASE_URL='postgresql://…'
Neon's free plan sleeps the database when idle. Mergepace writes only when something changed, at most every 10 minutes, so the database sleeps whenever agents are quiet (while they work it wakes about every 10 minutes). If the database is unavailable, Mergepace keeps working from memory and retries every minute. Any PostgreSQL 14+ works instead of Neon.
5. Deploy
fly deploy --ha=false --build-arg VERSION=$(git rev-parse HEAD) # one machine only (sign-in lives in its memory)
fly status # should list exactly one machine
The --build-arg tells Mergepace which commit it was built from. Without
it the commit shows as unknown.
Open https://<name>.fly.dev/healthz; it should say ok <commit> (for
example ok 0123456789abcdef…). Every page also ends with "Running
<short commit>", linked to that commit on GitHub, so you can tell whether a
merged fix is live yet. With Docker directly, use
docker build --build-arg VERSION=$(git rev-parse HEAD) -t mergepace ..
While two versions run side by side during a deploy, the older one saves claims back without their holder (the token that made them), so those claims fall back to the old owner rule until they expire (see connector.md).
5b. Deploy automatically (optional)
.github/workflows/deploy.yml deploys main after the ci workflow's
test job passes on it. It deploys only the newest commit on main, one
deploy at a time, and comments on the merged pull request it deploys.
To turn it on:
Before you start:
- Environments need a public repository, or GitHub Team or Enterprise for a private one (Pro for a personal account). On GitHub Free they can't be turned on while the repository is private: make it public first, or move the organization to Team.
- Protect
mainfirst: require a pull request and thetestcheck, block direct and force pushes, and don't allow bypassing. Whatever reachesmaingets deployed, including its ownci.yml, so changes under.github/need review. - Only this workflow should name
environment: production; any other workflow onmainthat names it would get the token too. - Don't allow a tag named
main(a tag ruleset can block it), so a tag can never pass for the branch. - Never store the token as a repository secret instead: every workflow on every branch could read it.
fly tokens create deploy -a <name> -x 8760hand copy the whole token (it starts withFlyV1). It is limited to this one app.- On GitHub: Settings → Environments → New environment
production. Under Deployment branches and tags, pick Selected branches and tags and add the branch (not tag)main. Add the environment secretFLY_API_TOKENwith the token. - Settings → Secrets and variables → Actions → Variables: add the
repository variable
FLY_APPwith your app's name.
Without FLY_APP the workflow does nothing. Do the first deploy by hand
(step 5): the workflow only deploys an app that already runs exactly one
machine, so it always has a version to go back to. Create the environment
before setting FLY_APP, or GitHub creates an unprotected one on the first
run. With one machine, a deploy
replaces the running version in place, so if the new version fails its
/healthz check the workflow puts the previous image and settings back and
the pull request says so. If that fails too, the comment says the site may
be down and gives the command to restore the previous image. The token above expires after a year; make a
new one and replace the secret before then. If main's newest commit fails
its tests, nothing is deployed until a later commit passes.
6. Install the app and sign in
- In the app's settings on GitHub, click Install App, choose the
mergepaceorganization, and pick the repositories. Only repositories in that organization can be added. - Open
https://<name>.fly.dev. It sends you to GitHub to sign in; approve, and you're back on Mergepace. - You land on your repository's page (pick another from the menu at the top): who is working on what, what comes next, and the last 7 days, read automatically.
7. Connect Claude
The easiest way for agents to use Mergepace: in Claude, Settings →
Connectors → Add custom connector, paste
https://<name>.fly.dev/mcp, click Connect, sign in with GitHub and
click Allow. Every Claude session can then ask Mergepace for its next
task. See connector.md.
8. Or point a command-line agent at it
Open the repository's Agents tab on the site. Under Command-line agents and scripts, click Make an access key. Copy it: it is shown once, and it works on that repository only, for 90 days.
On the machine where an agent runs, build the command once. While the
repository is private, go install can't fetch it. This needs Go 1.27 or
newer.
gh repo clone mergepace/mergepace && cd mergepace
go build -o mergepace ./cmd/mergepace
Then, from that folder:
export MERGEPACE_URL=https://<name>.fly.dev
export MERGEPACE_TOKEN=<the access key>
./mergepace next --repo owner/name --agent builder-1
Give each agent its own name (--agent, or MERGEPACE_AGENT):
builder-1, builder-2 and so on. Two agents with the same name count
as one, so they would be given the same task.
9. CI sign-in (for the start event launcher)
When Mergepace starts an agent in your CI with a start event launcher (launchers/start-event.md), the job can sign in with its CI identity token instead of an access key saved as a CI secret. GitHub Actions works today; GitLab CI comes later.
The job sends its identity token to POST /api/v1/ci-login:
curl -X POST https://<name>.fly.dev/api/v1/ci-login -d '{"token":"<the identity token>"}'
and gets a start key for its one start:
{"token": "mpt_…", "start_id": "s-…", "repo": "owner/name", "role": "builder", "tier": "strong", "expires": "…"}.
It uses that key as MERGEPACE_TOKEN for the rest of the run. The job's
workflow needs permissions: id-token: write, and asks for the token with
this server's address as the audience (MERGEPACE_PUBLIC_URL, exactly).
What the server needs: MERGEPACE_TOKEN_KEY, the database (DATABASE_URL),
MERGEPACE_PUBLIC_URL and the GitHub App. Without one of them the address
answers 503 "CI sign-in is off on this server".
Which issuers count is yours to set, not the token's: Mergepace reads keys
only from the issuers configured here, never from the address in a token.
By default that is GitHub's own issuer for github.com
(https://token.actions.githubusercontent.com), or
https://<host>/_services/token for GitHub Enterprise Server. If your
enterprise uses its own issuer, set it (several, comma separated):
fly secrets set MERGEPACE_GITHUB_OIDC_ISSUERS=https://token.actions.githubusercontent.com/<enterprise>
The issuer's keys are read over the public internet with the integrations' shared client, which refuses private addresses: an Enterprise Server only reachable on a private network can't be used for CI sign-in yet.
Mergepace checks each token offline, and gives a key only when all of these hold:
- it is signed with one of the configured issuers' keys (kept for an hour; a key ID not seen yet makes Mergepace read the keys again, at most once a minute; while the issuer can't be read, the keys last read keep working for up to 24 hours, then sign-ins wait until it answers again);
- its audience is this server's address, and it hasn't expired;
- its repository and the repository's owner are the ones of the start, by ID, not by name;
- it belongs to the run Mergepace started: the run ID saved on the start record, started by the Mergepace App's own account, first attempt (a run someone started or ran again by hand gets no key), from the launcher's workflow file on the default branch;
- the start was made in the last 24 hours, by a start event launcher that still exists.
The key's role and tier come from the start record, never from the request. Each identity token can be exchanged once, and each start gets one key: used tokens are kept in the database until they and their start can no longer be used, so several server copies refuse them alike. Each repository can make 120 sign-ins an hour. The key works like a worker's start key: one start, at most 3 hours, ending when its run is over (the worker, or the CI job, ends it).
What it answers when it refuses: 401 for a token that isn't valid
(signature, audience, expiry), 403 for a run Mergepace didn't start or
another repository, 409 for a token or start used already, 429 over the
hourly limit (with Retry-After) or when the launcher already has 10 runs
going on the repository (no Retry-After: that start gets no key, the
next start will), 503 (with Retry-After) when the issuer's keys or the
repository couldn't be read just now. The answers never contain the token.
What a CI start key can do: what a worker's start key can, for its one start. Its approvals count as its launcher's: all the starts of one launcher are one reviewer. A CI start key has no maker (nobody made a key for it), so:
- with sign-in with GitHub on, its approvals are refused ("Approvals through a CI start key don't count yet with sign-in on"): nobody's write access stands behind them yet. A reviewer run in CI can still review and comment on the pull request, but its approval doesn't count for a merge rule that needs a reviewer agent; have a reviewer agent with an access key made on the repository's Agents tab approve, or a person;
- without sign-in, its approvals count, as whoever runs the server added the launcher.
Removing a launcher cancels the start keys CI jobs already got through it.
---
Good to know
- Restarts (each deploy, secrets changes, Fly host maintenance) sign everyone out. With the database, claims and replays come back (a normal stop saves them first); without it they're forgotten, and agents simply ask for their next task again. Claims made in the last 10 minutes before a crash can be lost.
- Never run a second machine. If
fly statusshows two, runfly scale count 1. - Background jobs that run on one copy at a time. With the database, a second server copy (during a deploy, or one started by mistake) doesn't double these up: each runs on whichever copy holds its lock in the database, and moves to another copy within about a minute when that one stops: the Setup checks of every repository (each result is saved, so every copy shows the same Setup rows; Check again works on any copy), re-checking open agent pull requests read from GitHub, removing deleted projects, and the sweep of access keys whose maker lost admin. Starting agents and refreshing the numbers take a lock per repository, and each pull request's merge rules check one per pull request. Every copy does the rest itself: expiring claims (each one is noted once), reading the access keys the other copies made or cancelled (every few minutes), and re-checking the pull requests it posted a check on. Without the database, everything runs on the one copy.
- Logs:
fly logs. They never contain tokens or codes. - Who sees server settings (
MERGEPACE_SERVER_ADMINS). Names likeMERGEPACE_TOKEN_KEYandDATABASE_URL, and links to the GitHub App's own settings, show only to whoever runs the server: by default the person who owns the GitHub App (nothing to set). If an organization owns it, or others run the server too, set their GitHub logins, comma-separated:fly secrets set MERGEPACE_SERVER_ADMINS=octo,hubot. Everyone else reads "Ask whoever runs this server to …". Without sign-in with GitHub, everyone sees them. The list matches logins, so if a listed login is renamed, update the list: whoever takes the old name would see these settings (only the text; it gives them no access). MERGEPACE_HOSTED=1(set infly.toml) hides server settings from everyone: it's for the public hosted Mergepace. Remove it fromfly.tomlon your own server.- Rate limits. The API (
/api/v1/*) and the connector (/mcp) allow 120 calls a minute per access key (or the server-wide access key) or per signed-in person, and 20 failed sign-ins (missing or wrong token) a minute per address. Over a limit, the API answers429withRetry-After("Too many calls from this key. Wait a minute, then try again."), and the connector answers tool calls with a tool error saying the same; nothing is read from GitHub for a refused call. An address with too many failed sign-ins is refused until its minute is over, even with a right key. Addresses are counted as for the web password: behind a proxy, setMERGEPACE_CLIENT_IP_HEADER(e.g.Fly-Client-IP), or every caller shares the proxy's address. GitHub's own signed webhooks aren't limited. The limits are constants ininternal/web/ratelimit.go. - Docs links (
MERGEPACE_DOCS_URL). Some pages link to the docs, for example the CI page from a Setup check. By default those links go to the project's own docs on GitHub. If you run a fork or a copy of the docs, setMERGEPACE_DOCS_URLto the folder that holds the pages, with no trailing page name, and the links go there instead. Example:fly secrets set MERGEPACE_DOCS_URL=https://docs.example.com/mergepace(the CI page is thenhttps://docs.example.com/mergepace/ci.md). - Webhooks and how fresh the lists are. Mergepace reads a repository's open pull requests at most once a minute and reuses that list for the pages, agents and the part that decides whom to start. A GitHub webhook makes it read again at once. Without webhooks (or if they stop arriving), that list can be up to a minute old, so a reviewer may start one run later than it could have. Nothing is lost; it only waits.
- Who sees what: see github-app.md.
- An access key leaked, or someone who made access keys left? On the
repository's Agents tab, under Working access keys, press
Cancel, then Cancel the key; the other keys keep working. To stop
every access key at once, change
MERGEPACE_TOKEN_KEY(openssl rand -hex 32, thenfly secrets set MERGEPACE_TOKEN_KEY=...) and make new ones on the repository's Agents tab. - The server-wide access key (
MERGEPACE_API_TOKEN) is for a server on your own computer or for one repository only. It works on every repository the server reads, so with the GitHub App Mergepace won't start while it's set: unset it and give each agent its own access key (made on the repository's Agents tab). If you run the server for yourself only and mean to share one key, also setMERGEPACE_ALLOW_SHARED_TOKEN=1; even then the server-wide access key can't approve pull requests, and listing claims through it needs?repo=owner/name. - CI step: the
@v0.1.0pin in ci.md works once the repository is public (or withGOPRIVATEand a token in CI). - Turning it off:
fly scale count 0stops the machine's billing;fly apps destroy <name>removes everything.