CI manager that provisions Hetzner, Vultr, CherryServers ephemeral VM boxes for running Forgejo Action jobs
  • Rust 96.8%
  • Shell 3.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Viktor Shchelochkov a5b59579f8
All checks were successful
CI / check (push) Successful in 31s
ci-orchestrator / provision running on cx43 in nbg1
Fix CI
2026-08-13 15:50:57 +02:00
.forgejo/workflows Fix CI 2026-08-13 15:50:57 +02:00
.vscode Initial commit 2026-08-11 15:31:24 +02:00
deploy Fix CI 2026-08-13 15:25:03 +02:00
fixtures Enforce max mcahine life time 2026-08-12 16:58:33 +02:00
src Enforce max mcahine life time 2026-08-12 16:58:33 +02:00
.editorconfig Initial commit 2026-08-11 15:31:24 +02:00
.gitignore Pin zig version and check clang during build 2026-08-11 16:32:31 +02:00
Cargo.lock Initial commit 2026-08-11 15:31:24 +02:00
Cargo.toml Initial commit 2026-08-11 15:31:24 +02:00
config.example.toml Enforce max mcahine life time 2026-08-12 16:58:33 +02:00
CONTRIBUTING.md Add CI release workflow, state reproducibility is not possinle when cross-compiling 2026-08-11 17:37:17 +02:00
lefthook.yml Initial commit 2026-08-11 15:31:24 +02:00
LICENSE Initial commit 2026-08-11 15:31:24 +02:00
README.md Add per-instance drop-ins for custom provider sets 2026-08-11 19:30:22 +02:00
rust-toolchain.toml Initial commit 2026-08-11 15:31:24 +02:00
rustfmt.toml Initial commit 2026-08-11 15:31:24 +02:00

Forgejo Actions Orchestrator

A daemon watching an allowlist of Forgejo repositories and automatically renting an ephemeral single-use machine from a cloud provider for secure and hermetic CI execution. No RCE on your orchestrator host, no risks to your infrastructure, no DIND limitations or kernel vulnerabilities exploits.

Provider Image field Snapshots
Hetzner Cloud image name or snapshot id Yes
Vultr numeric os_id No
Cherry Servers OS slug No

Install

  1. Download the pre-built binary from Releases (recommended):
    wget https://git.hloth.dev/hloth/forgejo-actions-orchestrator/releases/download/v1.0.0/forgejo-actions-orchestrator-linux-x86_64
    install -Dm755 ./forgejo-actions-orchestrator-linux-x86_64 /usr/local/bin/forgejo-actions-orchestrator
    
  • or build it from source:
    git clone https://git.hloth.dev/hloth/forgejo-actions-orchestrator
    cd forgejo-actions-orchestrator
    # Needs the toolchain named in rust-toolchain.toml; rustup installs it on first use.
    cargo build --release --locked
    install -Dm755 target/release/forgejo-actions-orchestrator /usr/local/bin/forgejo-actions-orchestrator
    
  1. Configure for systemd:

    # Replace MYSITE with your custom name, e.g. forgejo or git.example.org
    
    # 1. Create necessary files
    #  If using downloaded binary:
    wget https://git.hloth.dev/hloth/forgejo-actions-orchestrator/raw/branch/main/deploy/forgejo-actions-orchestrator@.service
    install -Dm644 forgejo-actions-orchestrator@.service /etc/systemd/system/forgejo-actions-orchestrator@.service
    wget https://git.hloth.dev/hloth/forgejo-actions-orchestrator/raw/branch/main/deploy/providers.example.conf
    install -Dm644 providers.example.conf /etc/systemd/system/forgejo-actions-orchestrator@MYSITE.service.d/providers.conf
    wget https://git.hloth.dev/hloth/forgejo-actions-orchestrator/raw/branch/main/config.example.toml
    
    #  If building from source:
    install -Dm644 deploy/forgejo-actions-orchestrator@.service /etc/systemd/system/forgejo-actions-orchestrator@.service
    install -Dm644 deploy/providers.example.conf /etc/systemd/system/forgejo-actions-orchestrator@MYSITE.service.d/providers.conf
    
    # 2. Edit /etc/systemd/system/forgejo-actions-orchestrator@MYSITE.service.d/providers.conf
    #  Keep only providers you use, otherwise you'd be getting 243/CREDENTIALS errors
    
    # 3. Configure the daemon
    install -Dm644 config.example.toml /etc/forgejo-actions-orchestrator/MYSITE.toml
    # Edit /etc/forgejo-actions-orchestrator/MYSITE.toml
    
    • forgejo.url — your instance
    • [[repo]] — list only repositories with Actions enabled; every poll of a repo without it 404s
    • each label's image — confirm the snapshot id still exists; nothing pre-flights it

    Every job names a label set, matched exactly against one [[label]] block. That block decides the provider, the plans to try, the locations, and the image. Either a pre-baked snapshot, which boots fastest but versions the toolchain outside the repository and only works on Hetzner, or a stock OS image plus a setup step, which is slower but versions the environment with the code.

  2. Set credentials

    One file per secret, 0400 root:root, passed by systemd LoadCredential=.

    install -d -m700 /etc/forgejo-actions-orchestrator/credentials/my-site
    
    umask 077
    cd /etc/forgejo-actions-orchestrator/credentials/my-site
    printf %s 'TOKEN' > forgejo-runner-token
    printf %s 'TOKEN' > forgejo-status-token
    
    # To enable Hetzner:
    printf %s 'TOKEN' > hetzner-token
    
    # To enable Vultr:
    printf %s 'TOKEN' > vultr-token
    
    # To enable CherryServers:
    printf %s 'TOKEN' > cherry-token
    printf %s 'ID'    > cherry-project-id
    
    chmod 400 ./*
    
    Credential Scope
    forgejo-runner-token Owner-level. Registers and deletes runners, reads the job queue
    forgejo-status-token write:repository, publishes the provisioning status
    hetzner-token Read & Write, scoped to a project used only by CI
    vultr-token Vultr API, make sure host's IP is in the allowlist for token
    cherry-token Cherry Servers API key
    cherry-project-id Cherry Servers project ID

    Issue Forgejo tokens in Settings → Applications → New token, repository set to Read and write. The runner token needs the org's Owners team, the status token only needs Write on the repo and is kept on a separate bot account because the runner token is owner-equivalent.

Usage

systemctl daemon-reload
systemctl enable --now forgejo-actions-orchestrator@my-site
journalctl -u forgejo-actions-orchestrator@my-site -f

Healthy service only logs once on launch and doesn't repeat:

INFO watching repos=["owner/repo"] labels=["check", "release", …] interval=15s
Symptom Cause
243/CREDENTIALS a LoadCredential= source file is missing — create it, or drop the line from the instance's providers.conf
Permission denied reading config, restarting every 5s config is not 0644
poll_failed with HTTP 404 repo not in the allowlist, or Actions disabled on it
poll_failed with HTTP 403 runner token is not org-Owner

Do not disable a provider by deleting its [[label]] while machines are live — that removes it from the survey and they are never destroyed. Stop the daemon and delete them by hand instead.

How a job gets a machine

  1. Daemon checks every provider for machines it owns, matched by the ci-orc- name prefix
  2. Poll each allowlisted repository for queued jobs
  3. Destroy machines past lifetime_minutes, and machines whose job has left the queue
  4. Delete runner registrations whose machine has been absent from two consecutive surveys
  5. Provision a machine for each newly queued job, up to the label's max_vms

Cloud-init writes the runner config and boot.sh to the new machine. boot.sh downloads the Forgejo runner, verifies it against the SHA-256 you pinned, and starts it under systemd-run as one-job --wait.

The daemon is what reclaims the machine: once the job leaves the queue, two consecutive polls confirm it and step 3 destroys it. Cloud-init also arms a poweroff at lifetime_minutes, but that is only a backstop for a machine the daemon never reached — a powered-off machine still bills, so the delete is what stops the meter.

Teardown is running based on step 1 survey, a lingering VM is destroyed even after the daemon restarts. If a provider's API cannot be read, that provider is marked blind and its machines are left alone rather than being destroyed on incomplete information.

Known gaps

  • A stock image must provide bash, sha256sum, systemd-run and apt; boot.sh covers the rest
  • One repository failing to poll suppresses teardown for every repository until it recovers
  • A job that never gets picked up keeps its machine until lifetime_minutes, and blocks its label

License

MIT

Donate

hloth.dev/donate