Skip to main content

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

ComponentImplementation
ComputeT Cloud Public ECS (Elastic Cloud Server) virtual machines, region eu-de, three availability zones
LifecycleOne 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 placementEach 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 runtimeOne 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 backstopA 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 binaryUpstream 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 imageThe 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’s 100.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:

TrafficInjected as
npm, pnpm, pip, uvthe 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 requeststhe 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-url overrides the injected PIP_INDEX_URL), or set it in the workflow step’s env:, 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 = true and 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’s env: 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

SurfaceEnforcement
ProcessWorkflow 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
WorkspaceVM-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
IdentityThe VM has no IAM agency attached. Metadata-service queries return only the network identity needed to boot; no platform-API access
Resource boundsThe 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 runtimeDocker (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
HypervisorT 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.

MechanismHow
Per-commit VMThe 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 completionAfter 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 SGNetwork 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 lifecycleEach 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 tokensEach 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 validate and apply jobs), 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

LimitDefaultOverride
Concurrent jobs per tenant2 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 500Enterprise-on-Demand: a per-tenant override replaces the guaranteed share
Global concurrent jobs across all tenantsBound by the platform’s compute capacity in eu-de—
Job rate cap7 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 limitPer-tenant override
Wall-clock per job3600 s (60 min)Configurable per workflow step but capped at 6 h ceiling
Runner-minute poolseat_cap × 1000 minutes/month (or an explicit per-tenant cap)Operator config; matches the public pricing
VM flavorc9.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@v4

Use 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

ThreatMitigation
Customer workflow contains a credentialPush protection (Gitleaks) blocks the commit before it lands in history
Customer workflow tries to read fremforge platformPer-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 VMPer-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 DNSDNS is allowed (workflows legitimately need it) but the egress proxy logs every CONNECT with full hostname for audit
Compromised act_runner upstreamUpstream-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 IPsExplicit deny at SSRF guard: 169.254.169.254 (AWS-style) and 100.100.100.200 (T Cloud Public)
Customer monopolises capacityPer-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 taskPer-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:

  1. runner_jobs table — tenant_id, repo, forgejo_task_id, started_at, finished_at, duration_seconds, vm_id. Source for billing aggregation and the operator console’s Infrastructure tab
  2. metering_events table — code=runner_minute rows derived from runner_jobs.duration_seconds. Drives invoice line items and the tenant’s “Runner minutes used” panel
  3. 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.