CI runners
fremforge provides hosted runners included in every seat plan, and supports BYO runners for teams that need on-prem, custom hardware, or specific network placement. Both runner types execute Forgejo Actions workflows using the same GitHub Actions YAML syntax.
Hosted runners
Hosted runners are single-use virtual machines on T Cloud Public in the eu-de region (Biere/Magdeburg). Each job gets a fresh VM provisioned from a custom debian image with Docker pre-installed; the VM is destroyed when the job ends. Nothing persists between runs.
Available labels
| Label | OS / arch | Notes |
|---|---|---|
fremforge | debian 13 (“trixie”, glibc), x86_64 | Default hosted-runner label. Use this for nearly every workflow. See Runner image contents for what’s pre-installed. |
linux/amd64 | debian 13 (“trixie”, glibc), x86_64 | Synonym for fremforge. Targets the same image. |
dind-privileged | debian 13, x86_64, KVM nesting | Add alongside fremforge (runs-on: [fremforge, dind-privileged]) for workflows that need KVM-nested guests — kind clusters, testcontainers features with namespace-creation caps. Scheduled onto a dedicated VM flavor with nested-virt enabled. Same single-use-VM-per-job isolation. |
Reference a label in your workflow:
jobs:
build:
runs-on: fremforgeContainer builds work two ways out of the box — see Runner image contents § Container builds below for the full split:
- Native Docker (default) —
docker build,docker compose,docker pulljust work. The Docker daemon runs natively on the VM (no Docker-in-Docker sidecar), sodockerbehaves the same as on a GitHub-hosted runner. Suits 90% of workflows. - kaniko — daemonless, faster cold-start, better remote-registry layer cache. Use when you don’t need BuildKit-specific features.
jobs:
build-image:
runs-on: fremforge
steps:
- uses: actions/checkout@v4
- run: docker build -t registry.example.com/$GITHUB_REPOSITORY:$GITHUB_SHA .
- run: docker push registry.example.com/$GITHUB_REPOSITORY:$GITHUB_SHA 2>&1 | stdbuf -oL tr '\r' '\n' | grep -vE '^(Waiting|)$'ARM64 hosted runners and additional OS variants (Ubuntu, Fedora, …) are on the roadmap (see changelog) — fremforge today targets x86_64 Linux only. BYO runners (see below) are the path for ARM64 or non-Linux workflows in the meantime.
Isolation model
Each job runs on a dedicated virtual machine with its own kernel. The platform watches the Forgejo Actions queue, provisions a fresh VM per pending job (cold-boot ~25 s, or ~5 s when a warm-pool VM was already provisioned), registers an ephemeral runner on it so it picks up exactly one task, and destroys the VM when the job exits. There is no shared kernel between jobs in the same org, different orgs, or between concurrent runs of the same workflow — every VM is single-use.
A small warm-VM pool (idle instances pre-baked with the runner image) keeps the median cold-start at ~5 s. Every VM is time-bounded: an absolute max age and a stricter idle window are enforced so abandoned or long-running VMs never drift.
Included minutes and concurrency
| Detail | Value |
|---|---|
| Included minutes | 1,000 runner minutes/seat/month, pooled across the org |
| Concurrency (hard cap) | 30 concurrent jobs per tenant by default (RUNNER_PER_TENANT_CONCURRENCY_CAP, configurable per deploy / paid override). |
| Per-job runtime ceiling | 60 min default (via --timeout); absolute 6 h ceiling enforced on the VM. Job-level timeout-minutes: in your workflow YAML caps shorter values; step-level timeout-minutes: already honored. |
| Per-minute charge within pool | None, included in the flat €30/seat plan |
| When pool is exhausted | New jobs are refused with a clear fremforge/runner-minutes-cap commit-status check; in-flight jobs finish naturally. Overage billing is opt-in, see below. |
| Minutes reset | 1st of each month; unused minutes do not roll over |
The concurrency limit is a soft limit. Contact support@frem.sh to raise it for your org. During platform-wide peak, a hard cap applies; jobs queue rather than fail.
Overage billing (opt-in): by default, new jobs queue when the pool is exhausted. Org owners can enable metered overage at Org admin → Billing → Runner-minute usage. When enabled, minutes above the pool are charged at €0,01/min on the next invoice. Overage can be disabled at any time from the same page.
Usage dashboard
Track runner minute consumption at Org admin → Billing → Runner-minute usage.
The usage dashboard shows:
| View | What it shows |
|---|---|
| Monthly summary | Total minutes used this billing period vs the included pool (1,000 min × seat count) |
| Per-repository breakdown | Minutes consumed by each repository, sortable by usage |
| Per-workflow breakdown | Minutes consumed by each workflow file within a repository |
| Timeline | Daily usage chart for the current and previous billing periods |
Minutes are counted from job start to job end, rounded up to the nearest second. Queue time (waiting for a runner) is not counted.
The monthly period resets on the 1st of each month UTC. Unused minutes do not roll over.
BYO runner minutes are tracked separately in the same dashboard and do not count against the hosted pool.
Customer-tunable usage-alert thresholds aren’t surfaced yet — fremforge sends a default warning email when the org reaches 80% of the monthly pool. Contact support@frem.sh to adjust the threshold.
Runner image contents
The hosted runner image is rebuilt weekly (Mon 06:00 UTC) from upstream tool versions tracked by Renovate. Every build-time download is sha256-pinned (build fails on drift). A Trivy CVE gate runs at build time — any image with HIGH or CRITICAL findings is blocked from shipping until patched.
Two-layer model, both EU-resident:
- System layer (~300 MB) — debian base + single-version system tools (Node 24 LTS, build essentials, scanners, IaC + cloud CLIs, browsers, db clients). Part of the VM boot disk; no per-job download.
- hostedtoolcache layer (~7 GB) — multi-version language toolchains (Node, Go, Python, .NET, Java, Ruby) + Android SDK + Miniconda3 + R (since 2026-06-06). Baked into the same VM boot disk at
/opt/hostedtoolcache.setup-*actions resolve from here with no per-job download.
How we deliver full coverage in ~7 GB total when GitHub’s ubuntu-latest is ~50 GB: see the size story below.
Pre-installed in the system layer:
| Category | What |
|---|---|
| Base | debian 13 (“trixie”) slim (glibc — required for ubuntu-parity binaries from setup-* actions) |
| Shell + GNU coreutils | bash, coreutils, util-linux, findutils, grep, sed, gawk, diffutils, tzdata, locale |
| Network + crypto | ca-certificates, curl, wget, openssl, openssh-client, gnupg |
| VCS | git, git-lfs (initialised system-wide), tea (Forgejo CLI) |
| Archives | tar, gzip, xz, bzip2, unzip, zip, zstd |
| Build essentials | build-essential (gcc + make), clang, lld, ninja, cmake, pkg-config, autoconf, automake, libtool |
| JS runtime (system) | Node 24 LTS + npm + corepack-managed pnpm & yarn |
| Other languages (system) | Rust (rustup stable), PowerShell 7, PHP 8.3 + Composer |
| JVM build | Maven 3.9, Gradle 9.5 (Java SDKs come from the hostedtoolcache) |
| IaC | OpenTofu, Bicep, Pulumi, Ansible (via pipx), AWS SAM CLI |
| Cloud CLIs | AWS CLI v2, Azure CLI, Google Cloud CLI (gcloud + gsutil + bq) |
| Containers | Docker CLI + native Docker daemon (no DinD sidecar — see Container builds), kaniko, skopeo, kind |
| K8s | kubectl, helm, kustomize |
| Security + lint | gitleaks, trivy, osv-scanner, cosign, syft, opengrep, scorecard, checkov, cfn-lint, actionlint, hadolint, shellcheck, shfmt, yamllint |
| Data shape | jq, yq |
| DB clients | postgresql-client, default-mysql-client, sqlite3 |
| Browsers + e2e | Chromium + chromedriver, Firefox ESR + geckodriver, Selenium Server |
| Init | tini as PID 1 (clean signal handling + zombie reaping) |
| Executor | forgejo-runner (upstream act_runner, pinned) drives Forgejo Actions steps natively on the VM |
Pre-installed in the hostedtoolcache (resolved by setup-* actions, no per-job download):
| Tool | Versions / contents |
|---|---|
| Node.js | 24.x, 22.x (LTS) |
| Go | 1.26.x, 1.25.x |
| Python | 3.13, 3.12 |
| .NET SDK | 10.0, 8.0 |
| Java (Temurin) | 21, 17 |
| Ruby | 3.3.x, 3.2.x |
| Android SDK | cmdline-tools + platform 34 + build-tools 34.0.0 + platform-tools; ANDROID_HOME=/usr/local/lib/android/sdk; extra platforms/NDK per-job via setup-android@v3 |
| Miniconda3 | conda + python 3.11; CONDA=/opt/hostedtoolcache/Miniconda3/latest/x64; envs created per-job via setup-miniconda@v3 |
| R (CRAN) | r-base + r-base-dev 4.3.x; resolved by r-lib/actions/setup-r@v2; CRAN packages installed per-job |
The canonical version list with exact resolved versions + image digest + Trivy scan summary + CycloneDX SBOM is published with each weekly rebuild — see the runner image reference for the current toolstack.
Container builds: native Docker or kaniko
Two paths, pick by workload:
Native Docker (default) — the VM ships with the Docker daemon installed and running on the host. Your docker CLI talks to it over the standard local socket — no DOCKER_HOST indirection, no sidecar. Works for docker build, docker compose, docker pull, multi-stage builds, BuildKit features. Drop-in parity with GitHub-hosted runner docker.
- run: docker build -t myapp:${{ github.sha }} .
- run: docker push myapp:${{ github.sha }} 2>&1 | stdbuf -oL tr '\r' '\n' | grep -vE '^(Waiting|)$'Scanning large images with Trivy
trivy image <ref> (daemon-mediated) holds layer metadata for the full image in memory while it walks the filesystem — multi-GB images can spike well past the runner VM’s memory limit and trigger an OOM-kill mid-scan. The reliable pattern for any image size is to stream the image via docker save:
- run: docker build -t myapp:${{ github.sha }} .
- run: |
docker save myapp:${{ github.sha }} | trivy image \
--severity HIGH,CRITICAL --ignore-unfixed \
--scanners vuln --pkg-types os \
--offline-scan --no-progress --quiet \
--input -Why it works: docker save reads layers from disk and streams the tarball into trivy’s stdin one layer at a time. Trivy parses each layer for the OS package metadata (~tens of KB per layer) and discards it before the next. Combined peak RSS stays around 500 MB regardless of total image size, so the scan fits comfortably within the runner VM’s memory budget. GOGC=10 in front of trivy (e.g. GOGC=10 trivy ...) tightens the heap further if you ever push close to the limit.
The tr '\r' '\n' filter on docker push is recommended: Docker writes layer-upload progress with carriage-return overwrites that the Forgejo Actions step-log treats as a single ever-growing line and silently truncates. Without the filter, a push that stalls or errors mid-upload shows only <layer>: Preparing in the log with no failure detail — by the time the step times out, the actual error has been overwritten. Piping through tr turns each progress tick into its own line that gets captured intact, and grep -vE '^(Waiting|)$' drops empty/idle padding. Layer-push throughput is unaffected.
Customer registries (ghcr.io, dockerhub, quay.io, your own) are reachable via the runner egress; the proxy carries a 30 min per-request timeout so multi-GB layer PUTs complete cleanly. Image-size budget: 50 GB per build (the VM scratch-disk cap) — file a ticket if you need more.
kaniko — daemonless, rootless OCI builder. Faster cold-start than the full Docker daemon, better layer-cache behaviour against remote registries. Use when the build doesn’t need BuildKit-specific features.
- run: |
/usr/local/bin/kaniko \
--context=$GITHUB_WORKSPACE \
--dockerfile=Dockerfile \
--destination=registry.example.com/$GITHUB_REPOSITORY:$GITHUB_SHAFor kind / testcontainers workloads that need KVM-nested guests, add the dind-privileged label to runs-on. fremforge routes these to a VM flavor with nested virtualisation enabled.
Size story vs GitHub-hosted
GitHub’s ubuntu-latest is ~50 GB because it bakes:
- 4-6 versions per language (Node 16/18/20/22, Python 3.7-3.12, Go 1.20-1.22, Java 8/11/17/21, Ruby 3.1-3.3) — we bake 2 recent versions each (~3 GB total).
- Android SDK + NDK (~10 GB on GitHub-hosted) — we bake the core SDK (platform 34 + build-tools + platform-tools, ~3 GB); extra API levels + NDK + emulators install per-job via
setup-android@v3(no re-download of what’s baked). - Pre-pulled Docker service-container images (postgres, mysql, mongo, redis, mssql ~5 GB) — not baked; the in-region Docker Hub mirror at package-caches makes
docker pull~2 s cold. - Microsoft Edge browser — not baked (Chromium is the same engine for headless CI testing).
- Mono / Julia / Swift / Haskell / Lua / Crystal / Nim / Elixir — not baked (niche, install per-job via
setup-*orapt). - Multiple GCC versions, old/legacy toolchains kept for compatibility — we ship one current set.
We don’t miss tools the typical workflow uses — every common language, every major cloud CLI, every common scanner, both major browsers, Docker (native, root via dind-privileged label), Android (core SDK), and data-science (Miniconda3 + R). iOS / Xcode is Linux-incompatible and tracked separately; niche languages (Julia/Swift/Haskell/Mono) install per-job. Let us know if your team needs something we don’t bake and we’ll evaluate for the next weekly image rebuild.
For full per-tool details see docs.frem.sh/build/actions/runner-image/.
BYO runners
Register your own runners against any fremforge org (on-prem servers, cloud VMs, Mac minis, Raspberry Pis). BYO runner minutes do not count against the hosted pool.
Registration
- Go to Org admin → Settings → Runners → New runner.
- Copy the registration token.
- Download the Forgejo runner binary. The current stable release is published at code.forgejo.org/forgejo/runner/releases, pin to a recent tag when you install:
# Linux x86_64 — replace <version> with the tag from the releases page (e.g. v6.4.0)
curl -sSL https://code.forgejo.org/forgejo/runner/releases/download/<version>/forgejo-runner-<version>-linux-amd64 \
-o forgejo-runner && chmod +x forgejo-runner
# macOS arm64
curl -sSL https://code.forgejo.org/forgejo/runner/releases/download/<version>/forgejo-runner-<version>-darwin-arm64 \
-o forgejo-runner && chmod +x forgejo-runnerfremforge tracks current Forgejo releases; runners on older binaries continue to work but should be upgraded within a release cycle to pick up CVE patches.
- Register the runner:
./forgejo-runner register \
--instance https://frem.sh \
--token <registration-token> \
--name my-runner \
--labels linux,self-hosted,my-custom-label- Start the runner:
./forgejo-runner daemonSupported platforms
| Platform | Architectures |
|---|---|
| Linux | x86_64, ARM64, ARMv7 |
| macOS | x86_64, Apple Silicon (ARM64) |
| Windows | x86_64 |
Custom labels
Labels assigned at registration are what you reference in runs-on::
jobs:
deploy:
runs-on: [self-hosted, my-custom-label]Multiple labels in a list mean “find a runner that has ALL of these labels”. The self-hosted label is conventional. Include it to avoid accidentally routing a BYO job to a hosted runner.
Don’t use reserved labels. The labels fremforge and linux/amd64 route to our hosted-runner pool (the legacy fremforge-cci and cci aliases are deprecated and route the same way). Pick distinct labels for your BYO runners (e.g. self-hosted, on-prem, my-team-runner) so workflows can address them unambiguously.
Network requirements for the runner host
The runner binary opens long-poll HTTPS connections to https://frem.sh and clones repositories over HTTPS. From the host’s perspective the connectivity requirement is:
| Destination | Port | Purpose |
|---|---|---|
frem.sh | 443 | Connect-RPC runner protocol (/api/actions/runner.v1.RunnerService/*), Actions runtime APIs, git clone over HTTPS |
*.frem.sh | 443 | Reserved for tenant subdomains, attachment + raw-file downloads |
| Action mirror (transitive) | 443 | When a workflow uses uses: actions/<name>@<ref>, the runner fetches the action from frem.sh/mirrors/... — same host as above |
| Customer workflow targets | varies | Whatever destinations your workflows hit (deploy targets, package registries, etc) |
No inbound connectivity is needed — the runner initiates all connections outbound. A runner behind NAT or a firewall works fine as long as it can reach frem.sh:443.
Security and isolation responsibility
For hosted runners, fremforge provides kernel-level isolation between jobs (each job runs in a fresh single-use ECS VM). For BYO runners, isolation is your responsibility. A self-hosted runner that handles workflows from multiple repositories shares state across jobs unless you scope each runner to a single repository or org.
If you run workflows from multiple teams or trust-domains, treat your BYO runner the same way GitHub Actions self-hosted runners are treated:
- One runner per repository (or per trust-boundary) when secrets are sensitive.
- Don’t register a BYO runner against a public repository — anyone with merge access could land a workflow change that executes arbitrary code on your runner host.
- Treat the runner host as production: harden the OS, keep the runner binary patched, and route its egress through your own SSRF / egress controls.
The fremforge SSRF outbound proxy does NOT apply to BYO runners — only to hosted runners. Your runner’s network policy is whatever you wire on the host.
Egress and network
| Rule | Detail |
|---|---|
| Outbound HTTP/HTTPS | Allowed (ports 80 and 443) |
| Outbound other ports | Blocked by default |
| Inbound connections | Blocked, runners do not accept inbound connections |
| T Cloud Public metadata endpoint (169.254.x.x) | Blocked by SSRF outbound proxy |
| RFC-1918 private ranges | Blocked by SSRF outbound proxy |
Org-level egress allowlist: to allow outbound access to additional CIDRs or ports from runner jobs, contact support@frem.sh. Egress rules are configured at the platform level.
The SSRF proxy is a platform floor. It cannot be disabled. It prevents runner jobs from reaching T Cloud Public instance metadata or internal infrastructure.
Secrets in jobs
Secrets are injected as environment variables and masked in job logs. Any log line containing a secret value is replaced with ***.
Reference syntax:
steps:
- name: Deploy
env:
API_KEY: ${{ secrets.MY_API_KEY }}
run: ./deploy.shSecret scopes
| Scope | Where to set | Available to |
|---|---|---|
| Org secrets | Org admin → Settings → Secrets | All repositories in the org |
| Repo secrets | Repository → Settings → Secrets | That repository only |
| Environment secrets | Repository → Settings → Environments → <env> → Secrets | Jobs that reference that named environment |
Environment secrets require the job to declare an environment:
jobs:
deploy:
runs-on: fremforge
environment: production
steps:
- run: echo ${{ secrets.PROD_API_KEY }}For cloud provider credentials, prefer OIDC token federation over long-lived secrets. No secret to store, no secret to rotate.
Troubleshooting
Job is stuck in queue.
The most common cause is hitting the per-tenant concurrency cap (default 30, controlled by RUNNER_PER_TENANT_CONCURRENCY_CAP). Check Org admin → Billing → Runner-minute usage for current active job count. If you are at the cap, either wait for a running job to finish or contact support@frem.sh for an Enterprise-on-Demand uplift.
docker command works but a privileged operation fails (e.g. kind cluster create with KVM nesting).
The default runs-on: fremforge VM runs Docker natively with rootless user defaults — docker build / docker compose / docker pull work out of the box, but operations that need extra capabilities (KVM nesting, raw /dev access for a nested guest, certain kind cluster network modes) will not. Add the dind-privileged label to runs-on (runs-on: [fremforge, dind-privileged]) — fremforge routes onto a VM flavor with nested-virt enabled. Same single-use-VM-per-job isolation; same image; just elevated capabilities.
For pure image copy/retag between registries (no build), the pre-installed skopeo works without needing the full Docker daemon at all.
Secret is masked in logs but the step behaves as if it is empty.
The secret exists in the runtime (masking confirms this), but the value at the correct scope is not set. Check:
- Is the secret set at org level, repo level, or environment level?
- If it is an environment secret, does the job declare
environment: <name>matching the secret’s environment? - Is the secret name spelled exactly right (case-sensitive)?
The Secrets UI at the relevant scope shows the secret name and last-updated timestamp. Use that to confirm the secret exists before looking at the workflow.
Cross-references
- OIDC token federation, keyless cloud auth from CI jobs
- Secrets, managing org, repo, and environment secrets
- Marketplace compatibility, which GitHub Marketplace actions work on fremforge runners
- AI posture, what AI tooling is and is not present in the runner environment