Skip to main content
CI runners

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

LabelOS / archNotes
fremforgedebian 13 (“trixie”, glibc), x86_64Default hosted-runner label. Use this for nearly every workflow. See Runner image contents for what’s pre-installed.
linux/amd64debian 13 (“trixie”, glibc), x86_64Synonym for fremforge. Targets the same image.
dind-privilegeddebian 13, x86_64, KVM nestingAdd 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: fremforge

Container 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 pull just work. The Docker daemon runs natively on the VM (no Docker-in-Docker sidecar), so docker behaves 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

DetailValue
Included minutes1,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 ceiling60 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 poolNone, included in the flat €30/seat plan
When pool is exhaustedNew 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 reset1st 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:

ViewWhat it shows
Monthly summaryTotal minutes used this billing period vs the included pool (1,000 min × seat count)
Per-repository breakdownMinutes consumed by each repository, sortable by usage
Per-workflow breakdownMinutes consumed by each workflow file within a repository
TimelineDaily 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:

CategoryWhat
Basedebian 13 (“trixie”) slim (glibc — required for ubuntu-parity binaries from setup-* actions)
Shell + GNU coreutilsbash, coreutils, util-linux, findutils, grep, sed, gawk, diffutils, tzdata, locale
Network + cryptoca-certificates, curl, wget, openssl, openssh-client, gnupg
VCSgit, git-lfs (initialised system-wide), tea (Forgejo CLI)
Archivestar, gzip, xz, bzip2, unzip, zip, zstd
Build essentialsbuild-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 buildMaven 3.9, Gradle 9.5 (Java SDKs come from the hostedtoolcache)
IaCOpenTofu, Bicep, Pulumi, Ansible (via pipx), AWS SAM CLI
Cloud CLIsAWS CLI v2, Azure CLI, Google Cloud CLI (gcloud + gsutil + bq)
ContainersDocker CLI + native Docker daemon (no DinD sidecar — see Container builds), kaniko, skopeo, kind
K8skubectl, helm, kustomize
Security + lintgitleaks, trivy, osv-scanner, cosign, syft, opengrep, scorecard, checkov, cfn-lint, actionlint, hadolint, shellcheck, shfmt, yamllint
Data shapejq, yq
DB clientspostgresql-client, default-mysql-client, sqlite3
Browsers + e2eChromium + chromedriver, Firefox ESR + geckodriver, Selenium Server
Inittini as PID 1 (clean signal handling + zombie reaping)
Executorforgejo-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):

ToolVersions / contents
Node.js24.x, 22.x (LTS)
Go1.26.x, 1.25.x
Python3.13, 3.12
.NET SDK10.0, 8.0
Java (Temurin)21, 17
Ruby3.3.x, 3.2.x
Android SDKcmdline-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
Miniconda3conda + 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_SHA

For 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-* or apt).
  • 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

  1. Go to Org admin → Settings → Runners → New runner.
  2. Copy the registration token.
  3. 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-runner

fremforge tracks current Forgejo releases; runners on older binaries continue to work but should be upgraded within a release cycle to pick up CVE patches.

  1. Register the runner:
./forgejo-runner register \
  --instance https://frem.sh \
  --token <registration-token> \
  --name my-runner \
  --labels linux,self-hosted,my-custom-label
  1. Start the runner:
./forgejo-runner daemon

Supported platforms

PlatformArchitectures
Linuxx86_64, ARM64, ARMv7
macOSx86_64, Apple Silicon (ARM64)
Windowsx86_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:

DestinationPortPurpose
frem.sh443Connect-RPC runner protocol (/api/actions/runner.v1.RunnerService/*), Actions runtime APIs, git clone over HTTPS
*.frem.sh443Reserved for tenant subdomains, attachment + raw-file downloads
Action mirror (transitive)443When a workflow uses uses: actions/<name>@<ref>, the runner fetches the action from frem.sh/mirrors/... — same host as above
Customer workflow targetsvariesWhatever 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

RuleDetail
Outbound HTTP/HTTPSAllowed (ports 80 and 443)
Outbound other portsBlocked by default
Inbound connectionsBlocked, runners do not accept inbound connections
T Cloud Public metadata endpoint (169.254.x.x)Blocked by SSRF outbound proxy
RFC-1918 private rangesBlocked 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.sh

Secret scopes

ScopeWhere to setAvailable to
Org secretsOrg admin → Settings → SecretsAll repositories in the org
Repo secretsRepository → Settings → SecretsThat repository only
Environment secretsRepository → Settings → Environments → <env> → SecretsJobs 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:

  1. Is the secret set at org level, repo level, or environment level?
  2. If it is an environment secret, does the job declare environment: <name> matching the secret’s environment?
  3. 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