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 per-commit virtual machines on T Cloud Public in the eu-de region (Biere/Magdeburg). A VM is provisioned from a custom debian image with Docker pre-installed, runs jobs from one commit of one repository, and is destroyed once that commit’s jobs are done. Nothing persists between commits.
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 per-commit VM 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
Jobs run on dedicated virtual machines with their own kernel. The platform watches the Forgejo Actions queue and starts a fresh VM for a pending job. A cold start takes about 40 seconds before the job’s first step; there is no pool of pre-started VMs.
One commit, one trust domain. A VM only ever runs jobs from the commit and repository it was started for. When a job finishes, the VM waits up to 60 seconds for another job from the same commit, in any workflow, and runs it without a new boot (about 5 seconds to start). After that it powers off and is deleted. No VM runs jobs from two commits, two repositories or two organisations, so treat all jobs of one commit as one trust domain. Credentials the platform knows of (Docker and npm logins, the usual credential files) are reset between jobs, but tool caches and pulled images stay on the VM. See Hosted runner model for the full design.
Sizing jobs for hosted runners
Every fresh VM costs about 40 seconds of boot before your first step, and often the 60-second wait after your last one. A job that does 15 seconds of work therefore holds a VM for about two minutes. So:
- Give a job its own VM only when its work takes about 2 minutes or more. Shorter checks (lint, a small test suite, a guard script) are cheaper and faster as extra steps in an existing job.
- Parallelise long, independent work (test shards, image builds, end-to-end suites) across jobs. That is what concurrency is for.
- Parallelise short work inside one job instead of across jobs, for example several
tofu planruns in background processes of one step. They mostly wait on APIs, not CPU. - Chains reuse VMs automatically. A
needs:job that becomes ready while its predecessor’s VM is still waiting (within 60 seconds) runs on that VM with no boot. A wide fan-out of jobs that all start at once cannot reuse anything: each needs its own VM. - Keep a run inside your concurrency. A matrix wider than your organisation’s guaranteed share runs in waves, and the last wave waits for a whole job to finish. Size shards so a run fits in one wave.
Memory
A hosted runner VM has 4 GiB of memory and no swap (2 vCPU). There is no larger hosted runner size today.
A job that runs out of memory fails within about a minute. When available memory drops below about 4% (roughly 130 MiB), the runner stops the job’s largest process, preferring build tools such as node, tsc, java and vitest workers. It sends SIGTERM first and SIGKILL if memory keeps falling. The step fails with exit code 143 or 137, and the step log says why:
earlyoom: killed node (pid 4242) because the runner VM ran out of memory. ...The runner itself is never the process that is stopped, so the job’s result and logs are still reported. Before this change, a job that outgrew the VM could stall silently until its timeout-minutes.
If you see this line, reduce the job’s peak memory:
- Split the work across jobs. Running a large build next to its integration tests, or a
docker buildnext to test containers, adds the two peaks together on one VM. - Bound the tool’s memory. For example
NODE_OPTIONS=--max-old-space-size=2048for Node,--checkers 1andGOMEMLIMITfor TypeScript 7 (tscwritten in Go),-Xmxfor Java, fewer test workers (--maxWorkers/--pool-options). - Stream instead of loading. For large image scans, see Scanning large images with Trivy.
If a job cannot fit in 4 GiB, contact support@frem.sh, or run it on a BYO runner sized for it.
Included minutes and concurrency
| Detail | Value |
|---|---|
| Included minutes | 1,000 runner minutes/seat/month, pooled across the org |
| Concurrency | 2 concurrent jobs per seat guaranteed (at least 10, at most 60); up to 60 while the platform has spare capacity. Trial: 2. |
| 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 |
Jobs above your organisation’s limit wait in the queue rather than fail. Contact support@frem.sh if you need a higher guaranteed share.
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.
Before a rebuilt image goes live on the runner fleet, it is checked again against the known-exploited vulnerability lists (ENISA EUVD and CISA KEV). An image with a known-exploited CVE that has a fix available is not rolled out, at any severity, unless a reviewed exception of at most 30 days covers that CVE. Runners keep using the previous image until a patched one is built.
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.8 (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 | 26.x, 24.x, 22.x (LTS) |
| Go | 1.27.x, 1.26.x |
| Python | 3.14, 3.13, 3.12 |
| .NET SDK | 10.0, 8.0 |
| Java (Temurin) | 25, 21, 17 |
| Ruby | 4.0.x, 3.4.x, 3.3.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:
- Several versions per language — 3 recent series for Node, Python, Java and Ruby; 2 for Go and .NET (~3 GB total). The table above is the authoritative set.
- 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; a
docker pullfetches them from Docker Hub, or through your organisation’s dependency proxy (see package caches). - 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/<owner>/<repo> — 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 commits and between customers (each commit runs in a fresh ephemeral ECS VM; jobs of the same commit may share it). 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
A step fails with earlyoom: killed <process> ... ran out of memory.
The job used more than the runner VM’s 4 GiB of memory, so the runner stopped its largest process instead of letting the VM stall. See Memory for how to reduce the job’s peak.
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 per-commit VM 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