The controller created every builder without a DigitalOcean account key, so DigitalOcean set a root password, emailed it to the token's owner, and turned password logins on: one email and one password-reachable box per builder, 146 of them on Oct 8. DO_SSH_KEYS lists account key IDs attached at creation (Ryan, DHH and Emir in the template). It needs ssh_key:read on the controller's token; without it every create is refused with 403, so it stays empty until the token has it. Builders also turn password SSH off in cloud-init, which holds even while no account key is attached, and Emir joins the operators whose GitHub keys reach root.
The x86_64 builder pool
x86_64 package builds run on DigitalOcean droplets that exist for one job
each. aarch64 builds run on GitHub's ubuntu-24.04-arm runners and need
nothing here.
How it works
- Jobs ask for a runner labelled
omarchy-builder.build-pr.ymlbuilds x86_64 packages there, and so does therebuildjob inpublish.yml. - A controller creates one droplet per queued job.
controller.shruns from a systemd timer every minute on a small always-on droplet (tagomarchy-controller). It polls GitHub for queued jobs with the label and creates droplets up toMAX_DROPLETS. It has no inbound endpoint. - A droplet runs one job and powers off.
runner-cloud-init.yamlinstalls Docker and the GitHub runner, registers it--ephemeralwith a registration token that expires in an hour, runs the job, and powers off, also when the runner download or registration fails. The controller removes any other failed droplet atMAX_AGE_MINUTES. - The controller deletes droplets that are powered off, older than
MAX_AGE_MINUTES, or still provisioning afterMAX_BOOT_MINUTES, and replaces a stuck one in the same tick. - Sizes and regions fall back. Each tick tries every size in
SIZES, in every region DigitalOcean lists it in stock.REGIONSonly sets which regions go first. A refused create is logged with DigitalOcean's message.
No secret reaches a builder. Signing and upload happen in the publish job on
a GitHub-hosted runner.
Operate it
SSH to the controller droplet as root. It is the droplet tagged
omarchy-controller in the DigitalOcean account that pays for the builders;
the operators' keys were installed when it was created.
journalctl -u omarchy-controller -n 50 --no-pager # What it is doing
journalctl -u omarchy-controller -f # Follow it
systemctl list-timers omarchy-controller.timer # Is it ticking
| Log line | Meaning |
|---|---|
creating <name> (<size> in <region>) |
A queued job is getting a droplet |
<size> in <region> refused: <message> |
DigitalOcean refused; the next region or size is tried |
no size in '<sizes>' can be created in any region |
Every create was refused. Read the refused: lines above it for why: out of stock (add a size to SIZES), or the account's droplet limit |
at cap (<live>/<max>, <busy> busy) with <queued> queued |
MAX_DROPLETS is reached. Raise it if the queue is long |
deleting droplet <id> (status=<status> age=<n>m) |
Reaping a finished, old or stuck droplet |
Check the pool from anywhere:
gh api repos/omacom/omarchy-pkgs/actions/runners --jq \
'"online \([.runners[]|select(.status=="online")]|length), busy \([.runners[]|select(.busy)]|length)"'
Jobs queued with no runner online for several minutes means the controller is not creating droplets: read its journal. See what is waiting:
gh run list -R omacom/omarchy-pkgs --workflow build-pr.yml --status queued
Only x86_64 jobs use this pool. A queued aarch64 job is waiting on GitHub's own runners.
Change settings
Edit /etc/omarchy-controller.env on the box. The next tick reads it.
| Setting | Default in controller-box/controller.env.example |
|
|---|---|---|
MAX_DROPLETS |
6 | Builders alive at once. The DigitalOcean account's droplet limit also applies |
SIZES |
g5-32vcpu-64gb-50gb g5-32vcpu-128gb-50gb |
Tried in order |
REGIONS |
empty | Regions to try first; empty means any |
MAX_AGE_MINUTES |
200 | Delete a droplet older than this |
MAX_BOOT_MINUTES |
10 | Delete a droplet still provisioning after this |
DO_SSH_KEYS |
Ryan, DHH and Emir's account key IDs | Attached to every builder. Without one, DigitalOcean emails a root password per builder |
Change the controller
Merge the change to master. The unit pulls /opt/omarchy-pkgs before every
tick, so it is live within a minute. The unit and timer files are copies made
when the box was created; after changing them, on the box:
cp /opt/omarchy-pkgs/ci/controller-box/omarchy-controller.{service,timer} /etc/systemd/system/
systemctl daemon-reload
tests/controller.sh exercises every controller decision against canned API
responses. Run it before merging a change.
Reach a builder
Operators can SSH to a builder as root while it lives. It powers off after its one job.
Clean up by hand
The controller reaps on its own. To do it manually, delete droplets tagged
omarchy-builder in the DigitalOcean account.
Stand up a controller
DIGITALOCEAN_TOKEN=<account that pays for builders> GITHUB_TOKEN=<fine-grained PAT> \
REPO=omacom/omarchy-pkgs ci/controller-box/create.sh
- The GitHub PAT is fine-grained and scoped to this repository: Actions read, Administration read and write (for runner registration tokens).
- The DigitalOcean token is written into the box's env file, so it is the
account that pays. It needs ssh_key:read to attach
DO_SSH_KEYS; without it every create is refused with 403. ADMIN_GITHUB_USERSnames whose GitHub SSH keys get root on the box and the builders. Set it; the default is a fixed list increate.sh.- The script refuses to create a second controller.