Hosted runner security model
fremforge hosted runners execute customer Forgejo Actions workflows on per-commit T Cloud Public ECS virtual machines in the EU-DE region. A queued job with no idle VM for its commit provisions a fresh VM with embedded Docker. That VM runs only jobs from the same commit of the same repository, one at a time, waits up to 60 seconds for another, then powers off and is destroyed. There is no persistent runner pool, and no VM ever runs code from two commits, two repositories or two customers.
This page documents the isolation guarantees fremforge makes, the per-tenant limits that apply, and the threat model: what customer code can do, what it can’t, and the design choices a workflow author should make to keep their secrets safe.
Where workflows run
| Component | Implementation |
|---|---|
| Compute | T Cloud Public ECS (Elastic Cloud Server) virtual machines, region eu-de, three availability zones |
| Lifecycle | One ephemeral VM per commit. The platform spawns a fresh ECS VM when a commit’s first job is queued and destroys it once that commit’s jobs finish. No two customers, and no two commits, ever share a kernel — comparable to GitHub-hosted and GitLab-hosted runners for cross-tenant purposes. Jobs of the same commit may share the VM (for example build then deploy), which is why a pipeline should be treated as one trust domain — see Threat model. Customer workflows never share a VM with platform workloads. There is no warm pool (target 0, by cost design), so every commit pays the ~25 s ECS boot. |
| Network placement | Each VM gets its own ENI in a dedicated runner subnet, with a per-VM Security Group enforcing egress. VMs sit in a dedicated VPC subnet split across three availability zones |
| VM runtime | One VM per commit; provisioned when that commit’s first job is queued, destroyed when its jobs complete. Per-job wall-clock cap (60 min default, 360 min hard ceiling) is enforced on the runner |
| Reaper backstop | A separate, out-of-band reaper deletes orphan VMs — runner never registered, runner exited but the delete was missed, VM age beyond its reap deadline, or an absolute 6h10m backstop. |
| Runner binary | Upstream forgejo-runner (act_runner) v13.2.0, no fork. Registers ephemerally with --ephemeral, so a registration is consumed by one task; the agent re-registers for each further task of the same commit, and the platform destroys the VM once that commit’s jobs finish |
| VM image | The hosted runner image — debian 13 (trixie) base, baked with toolchains from EU-cached binaries (no direct upstream pulls during boot). Image is the VM’s boot disk; Docker daemon is pre-installed and runs natively on the VM (no separate sidecar). Cold start is ~25 s on every commit — the warm-pool target is 0 by cost design, so no VM is pre-provisioned |
Isolation guarantees
Network isolation (per VM)
Every runner VM gets its own ENI bound to a per-VM Security Group. The SG denies, by default, all traffic except:
- DNS (53/UDP, 53/TCP) to the platform’s internal DNS resolver
- Egress through the runner egress proxy — the only outbound HTTP path
Customer code cannot reach:
- The fremforge api or its private database
- Forgejo’s internal endpoint (runner VMs talk to Forgejo through the public Bunny-fronted apex with a runner-specific scope; the internal endpoint is unreachable)
- T Cloud Public platform services (RDS, DCS, OBS, SFS-Turbo, IAM, KMS)
- Other tenants’ runner VMs
- The cluster’s metadata IP (
169.254.169.254) — explicitly blocked at the SSRF guard - RFC1918 private ranges or loopback
The Security Group is applied at VM provisioning and cannot be modified from inside the VM — the VM has no IAM agency attached, so the network-config APIs are unreachable through the metadata-derived identity.
Outbound egress
Customer workflow steps that need the public internet (cloning a public repo, downloading a tool, calling a SaaS API) route through the runner egress proxy via HTTPS_PROXY injected into the VM. This proxy:
- Accepts any public unicast IP including Carrier-Grade NAT (matches GitHub-hosted runner behaviour — needed for T Cloud Public’s own services like SWR which sit in CGNAT)
- Blocks RFC1918 private, link-local, loopback, multicast, reserved, and explicit cloud-metadata IPs (
169.254.169.254, T Cloud Public’s100.100.100.200) - Logs every CONNECT tunnel attempt with hostname + resolved IP for audit retention
The proxy is deliberately permissive in the GitHub-hosted-runner sense: customer workflows can reach any public host they need. The boundary is at SSRF + internal-platform, not at vendor allowlists.
Package caches (sovereign, EU-resident)
Before a workflow reaches the public internet, package installs and image pulls are routed through your organisation’s own dependency proxy, EU-resident in the same region as the runner VMs. The shared Docker Hub mirror and the in-VPC package caches were retired in October 2026. Each organisation’s proxy applies its own package policy, upstream credentials, access log and storage cap; a public file several organisations pull is stored once, and content fetched with an organisation’s own credentials is never served to another. With runner pull-through on (the default for every organisation that has not saved its own dependency proxy settings), each job is pointed at the proxy when it starts:
| Traffic | Injected as |
|---|---|
| npm, pnpm, pip, uv | the job’s npmrc, PIP_INDEX_URL, UV_DEFAULT_INDEX |
Go, Maven, NuGet, Trivy vulnerability DB (each as listed in runner_pull_through_ecosystems, all by default) | GOPROXY, a Maven mirror of Central, a machine-wide NuGet.Config, TRIVY_DB_REPOSITORY and TRIVY_JAVA_DB_REPOSITORY |
Container images from ghcr.io, quay.io, mcr.microsoft.com, registry.k8s.io, public.ecr.aws, registry.access.redhat.com; and docker.io, registry.redhat.io, registry.connect.redhat.com only with the organisation’s own credential, never for fork pull requests | the VM’s Docker daemon registry configuration |
The job authenticates to the proxy with credentials scoped to its own organisation and run, so package installs pass the organisation’s package policy and malware checks and appear in its access log. For container images on this path the proxy is a cache, not a gate: a pull it refuses falls through to the registry. The proxy is checked at the start of every job; if it cannot be reached, the job installs and pulls from the public registries through the egress proxy, and the fallback is logged. See Hosted runners.
For the common case (public dependencies) this is transparent, so nothing in your workflow changes.
If you use a private index or registry, note the injected value is the default, so it takes precedence over a default configured in a committed config file (pip.conf, .npmrc, go env). Point your private source explicitly so it wins:
Use one index per install: the proxy, or one private index that itself mirrors the public registry. Never add a second index next to the first (--extra-index-url, PIP_EXTRA_INDEX_URL, UV_EXTRA_INDEX_URL): the installer then takes a package name from whichever index offers it, which is how dependency-confusion attacks work. See the dependency proxy’s PyPI notes.
- pip:
pip install --index-url <your-private> ...(a CLI--index-urloverrides the injectedPIP_INDEX_URL), or set it in the workflow step’senv:, when that private index mirrors the public packages you need. To install your own packages from your organisation’s registry while public ones come through the proxy, use two separate installs, each with its own single index: public dependencies first, then your packages with--index-url https://frem.sh/api/packages/<org>/pypi/simple/ --no-deps. - uv: the same rule. Pin your own packages to your registry by name, with an index marked
explicit = trueand a[tool.uv.sources]entry per package, so no name can resolve from both. - npm: set
NPM_CONFIG_REGISTRY(and, for pnpm 11,PNPM_CONFIG_REGISTRY) in the step’senv:to one registry that mirrors the public one, or keep the proxy and route only your scope to your registry (@<your-scope>:registry=…), which pins every name in that scope to one registry. See Package caches. - Go: set
GOPRIVATE(e.g.GOPRIVATE=git.example.com/*) so the toolchain bypasses the proxy + checksum DB for your private module paths.
VM-level isolation
| Surface | Enforcement |
|---|---|
| Process | Workflow steps run as the VM’s runner user by default; sudo is available so workflows can apt install, run docker, configure system tools, etc. (matches GitHub-hosted-runner expectations). The boundary is the hypervisor below the VM, not user namespaces |
| Workspace | VM-local scratch at /workspace (and /tmp) — wiped between jobs and destroyed with the VM. See Jobs that share a VM for what can carry over between jobs of one commit |
| Identity | The VM has no IAM agency attached. Metadata-service queries return only the network identity needed to boot; no platform-API access |
| Resource bounds | The VM’s ECS flavor sets a hard CPU + memory ceiling. A runaway customer can’t exhaust capacity beyond the VM’s own bounds. Global runaway is bounded by the per-tenant concurrent cap + the platform’s compute capacity |
| Container runtime | Docker (native) — the daemon runs on the VM host, not as a sidecar. docker build, docker compose, kind, testcontainers work directly with no DinD complexity. For workflows that need extra container-runtime capabilities, the dind-privileged label routes to a flavor with KVM nesting enabled |
| Hypervisor | T Cloud Public’s KVM-based ECS hypervisor — the same multi-tenant VM boundary used for every commercial T Cloud Public customer running ECS in the region |
Cross-tenant + intra-tenant kernel isolation
This is the load-bearing claim of the hosted-runner model. Every VM is bound to one commit of one repository — a guest Linux kernel running on T Cloud Public’s hypervisor. It runs that commit’s jobs one at a time, and the platform destroys it once they are done. A job from another commit, another repository or another organisation always gets a different VM with a different kernel.
| Mechanism | How |
|---|---|
| Per-commit VM | The platform spawns a VM for a pending job when no VM of that commit is free. A kernel-level escape (e.g. CVE-2024-1086 nf_tables) compromises only THAT throwaway VM and the jobs of the same commit, not co-located tenants |
| Fast-down on completion | After its last job the VM waits up to 60 seconds for another job of the same commit, then powers off and is deleted. A separate out-of-band reaper catches orphans (runner-never-registered, missed-delete) within a 6h10m absolute backstop |
| Per-VM ENI + per-VM SG | Network isolation independent of the kernel boundary. Tenant A’s VM cannot reach Tenant B’s VM over the network — separate ENIs, separate Security Groups |
| Single-commit lifecycle | Each job runs in a fresh job container. A VM is never reused for another commit, repository or tenant, so it never holds a previous tenant’s secrets |
| OIDC tokens | Each VM gets a fresh OIDC token signed by the runner-OIDC-issuer with aud=fremforge and sub=repo:<owner>/<repo>:ref:<refname>. Customer cloud trust policies key off this audience + sub for assume_role_with_web_identity |
Jobs that share a VM
A VM is reused only for jobs of the same commit in the same repository (and so the same organisation): other workflows, runs or re-runs of that commit, or a deploy after its build. Between those jobs, fremforge resets everything it knows can hold a credential: the job container, /workspace and /tmp, the Docker login and npm configuration, and the usual credential files. If that reset cannot be confirmed, the VM takes no further job, and the next job gets a fresh VM.
The reset is not a fresh VM. Jobs can use the VM’s Docker daemon, which is root on the VM, so a job can leave something behind for a later job of the same commit. Images pulled with credentials, the tool cache and the Go build cache also stay on the VM. Treat all jobs of one commit as one trust domain:
- Don’t run untrusted code (contributor-supplied fixtures, unreviewed dependency updates) in the same commit’s pipeline as a job that holds production secrets.
- Gate deploys behind an environment approval (see Split
validateandapplyjobs), and prefer short-lived OIDC credentials to stored keys. - If a deploy must never share a machine with a build, run it on a BYO runner.
Per-tenant limits
| Limit | Default | Override |
|---|---|---|
| Concurrent jobs per tenant | 2 per seat guaranteed, at least 10 and at most 60 (trial: 2). Above that, a paying tenant may use idle capacity up to 60, but only while 20 platform slots stay free, so no tenant’s burst delays another’s guaranteed jobs. For comparison, GitHub-hosted: Free 20, Pro 40, Team 60, Enterprise 500 | Enterprise-on-Demand: a per-tenant override replaces the guaranteed share |
| Global concurrent jobs across all tenants | Bound by the platform’s compute capacity in eu-de | — |
| Job rate cap | 7 job starts per 5-minute rolling window per slot of the tenant’s burst ceiling: 420 for a paying tenant, 14 for a trial. It scales with a raised concurrency cap, so it is an abuse backstop and never the limit a normal pipeline meets first. GitHub publishes no job-start rate limit | Per-tenant override |
| Wall-clock per job | 3600 s (60 min) | Configurable per workflow step but capped at 6 h ceiling |
| Runner-minute pool | seat_cap × 1000 minutes/month (or an explicit per-tenant cap) | Operator config; matches the public pricing |
| VM flavor | c9.large.2 (2 vCPU / 4 GiB RAM) default; c9.xlarge.4 (4 vCPU / 16 GiB) for runs-on: [fremforge, large] | Workflow step runs-on: label routes to alternate flavors |
When a tenant exceeds the runner-minute pool and overage isn’t enabled (or is frozen), the platform refuses dispatch: the workflow’s queued job transitions to Cancelled in Forgejo. Tenants with overage enabled keep running and are invoiced for the excess.
When a tenant hits the concurrent cap, additional queued jobs wait and are dispatched as soon as a slot frees.
What customer code can NOT do
- Reach the fremforge api, Forgejo’s internal endpoint, or any other platform service (the api is only available through the public Bunny apex with proper authentication, just like for any external caller)
- Read another tenant’s runner VM (separate ENI; cross-VM traffic explicitly denied at the Security Group)
- Read another tenant’s secrets (workflow secrets are passed only to the runner that’s running the workflow that owns them; VM destruction wipes the in-memory credentials)
- Persist state beyond its own commit (the VM is destroyed once that commit’s jobs are done, and no disk is shared between VMs)
- Reach the OTC API directly with privileged scope (no IAM agency attached; metadata-derived identity has no roles)
- Reach cloud metadata endpoints (
169.254.169.254,100.100.100.200— both explicitly denied at the SSRF guard) - Exhaust the runner pool (per-tenant concurrent cap + the platform’s global compute ceiling)
- Cause queue starvation across tenants (per-tenant concurrent cap)
- Escape the hypervisor (the VM is the boundary; the host below it is T Cloud Public’s, isolated by the same VMM that separates every commercial ECS tenant in the region)
Recommended workflow shapes
Split validate and apply jobs
Run untrusted code (PR builds, dependency scans, contributor-supplied test fixtures) in a validate job with no production credentials, and gate the apply job behind an environment approval. The validate job never sees the apply job’s secrets. Both jobs belong to the same commit, though, so they can run on the same VM one after the other: see Jobs that share a VM.
Pin uses: references by SHA when supply-chain matters
uses: actions/checkout@v4 resolves to frem.sh/actions/checkout (fremforge’s Forgejo native pull-mirror of github.com/actions/checkout, 8-hour sync). The mirror tracks upstream tags including force-pushes, so pinning by SHA is the only way to guarantee bytewise stability:
# Bytewise-stable across maintainer force-pushes
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.0
# Picks up upstream's current HEAD at last mirror sync — including
# any maintainer-credential-compromise force-push
- uses: actions/checkout@v4Use OIDC for cloud auth, not long-lived secrets
Every runner VM gets a fresh OIDC token with aud=fremforge. Configure your cloud provider’s trust policy to accept this token and exchange it for short-lived credentials. Avoid storing long-lived AWS access keys / GCP service-account JSONs in workflow secrets where they end up in process env.
Cap workflow step deadlines
Set timeout-minutes: on individual steps to bound how long a hung step can run. The VM-level wall-clock cap (activeDeadlineSeconds=3600) is the platform floor; step-level timeouts give faster feedback.
Threat-model summary
| Threat | Mitigation |
|---|---|
| Customer workflow contains a credential | Push protection (Gitleaks) blocks the commit before it lands in history |
| Customer workflow tries to read fremforge platform | Per-VM ENI + SG denies; no IAM agency attached to the VM; api only reachable via public-auth path |
| One tenant’s job tries to read another’s VM | Per-VM ENI on separate IPs; SG default-deny across the runner subnet; and they’re on different VMs — different kernels |
| Kernel-level escape (root inside the VM → host kernel via CVE) | Compromises only the throwaway VM running that commit’s jobs. Co-located tenants ARE NOT — they’re on their own VM. The VM is destroyed about a minute after that commit’s last job regardless |
| Customer workflow tries to exfiltrate via DNS | DNS is allowed (workflows legitimately need it) but the egress proxy logs every CONNECT with full hostname for audit |
Compromised act_runner upstream | Upstream-tested binary (we don’t fork). Mirrored through the EU cache with Trivy gates on the runner image. CVE fixes flow via weekly rebuild |
| Customer workflow tries to reach metadata IPs | Explicit deny at SSRF guard: 169.254.169.254 (AWS-style) and 100.100.100.200 (T Cloud Public) |
| Customer monopolises capacity | Per-tenant guaranteed share (2 per seat, 10 to 60) with bursting only into idle capacity that leaves 20 slots free, job rate cap (7 starts per burst slot per 5 min), pool-minute cap |
| Stale runner VM survives past task | Per-job wall-clock cap hard-kills at the deadline; the platform deletes the VM on runner exit; a separate out-of-band reaper catches orphans with a 6h10m absolute backstop |
Auditability
Every dispatch is logged in three places:
runner_jobstable — tenant_id, repo, forgejo_task_id, started_at, finished_at, duration_seconds, vm_id. Source for billing aggregation and the operator console’s Infrastructure tabmetering_eventstable —code=runner_minuterows derived fromrunner_jobs.duration_seconds. Drives invoice line items and the tenant’s “Runner minutes used” panel- Operator log stream — structured spawn logs for the operator, with continuous alerting on dispatch refusals, stuck VMs, overrun deadlines, and capacity exhaustion
Tenants see their own usage at Org admin → CI runners → Usage, including a per-month minute count and a per-job durations table.