Forgejo Actions runner isolation, the secure way
A CI runner is a machine whose whole job is to run code that strangers send it in pull requests. Said like that, it deserves more isolation than the default install and a hopeful label called "docker".
The short answer
Run each Forgejo runner on its own VM per trust level, with rootless or user-namespaced Docker. Use only docker:// labels, never host. Keep privileged off, docker_host at "-" and valid_volumes empty. Drop capabilities, set no-new-privileges and memory and process limits, run one job at a time, and firewall egress and cloud metadata.
On this page
What goes wrong
A Forgejo runner takes jobs from the forge and runs their steps. Anyone who can push a branch, or open a pull request where fork workflows are allowed, chooses what those steps do. The runner's isolation is the only thing between that code and:
- the runner host, and every other job running on it,
- the runner's registration secret and the forge,
- the network behind the runner, including cloud metadata endpoints.
Three settings undo that isolation completely: a host label (steps run
directly on the machine), privileged: true, and a Docker socket mounted
into jobs. Several popular setups turn on one of them to make Docker builds
work.
What the docs say
Forgejo Runner performs remote code execution. That poses significant security threats for the host and network that it operates upon.
Source: Forgejo docs, Forgejo Actions administrator guide
There is no isolation at all and a single job can permanently destroy the host.
Source: Forgejo docs, Runner configuration
If
container.privilegedis configured totrueand our attacker Mallory is able to mutate actions workflows that are executed, Mallory will be able to operate asrooton the Forgejo Runner machine; all confidential data can be compromised, all data integrity can be compromised, and availability of the service can be disrupted.
Source: Forgejo docs, Securing Forgejo Actions deployments
Depending on the criticality and confidentiality of other services and data that are shared on the Forgejo Runner's host, you may wish to completely isolate Forgejo Runner in its own virtual machine.
Source: Forgejo docs, Docker access
The Forgejo docs are unusually clear about the risks. What they leave to you
is the combination: the daemon defaults are safe, but a local test with
forgejo-runner exec behaves differently (see below), and container
defaults still give a job root with fourteen capabilities and no limits.
The secure configuration
The host
- One VM per trust level: public or fork pull requests on one runner, trusted branches on another, release and deploy jobs on a third. Never share.
- Docker in rootless mode for the runner user, or with user namespace remapping, so root in a job is not root on the VM.
- Replace VMs regularly (or per job for untrusted work); do not let state build up.
- Firewall egress: allow the forge, your registry mirror and package
mirrors; block everything else, including
169.254.169.254(cloud metadata).
config.yml
log:
level: info
job_level: info
runner:
file: .runner # holds the registration secret: mode 0600, owned by the runner user
capacity: 1 # one job at a time on this VM
timeout: 1h
labels:
# docker:// only. Never "host", "self-hosted:host" or "lxc" for untrusted code.
- "docker:docker://registry.example.com/ci/node@sha256:<digest>"
container:
network: "" # a new network for each job
privileged: false
# Resource limits and hardening for every job container.
options: "--memory=2g --cpus=2 --pids-limit=512 --cap-drop=ALL --security-opt=no-new-privileges"
valid_volumes: [] # jobs cannot mount host paths or named volumes
docker_host: "-" # no Docker socket inside jobs
force_pull: true # do not trust a cached image a previous job may have replacedThe test below set these job options with forgejo-runner exec flags. After
you deploy the daemon config, run the probe workflow from this page once on
each runner and check the output matches.
Jobs that install packages as root (apt-get, apk add) need a few
capabilities back. Add them for that runner label only:
--cap-add=CHOWN --cap-add=DAC_OVERRIDE --cap-add=FOWNER --cap-add=SETUID --cap-add=SETGID.
Image builds use kaniko in a normal job, not a Docker socket.
On the forge
- Register runners per organization or repository, not instance-wide, so a runner only takes jobs from the code it is meant for.
- Review workflow changes in pull requests from forks before they run on any runner, and route fork pull requests to the untrusted runner only.
- Secrets are available only to jobs on trusted branches, never to fork pull requests.
Prove it
A probe workflow that prints what a job can see:
on: [push]
jobs:
probe:
runs-on: docker
steps:
- name: what can a job see
shell: sh
run: |
id -u
grep -E '^(CapEff|NoNewPrivs):' /proc/self/status
ls -l /var/run/docker.sock 2>&1 || true
echo "memory.max: $(cat /sys/fs/cgroup/memory.max)"
echo "pids.max: $(cat /sys/fs/cgroup/pids.max)"The daemon's generated defaults are safe:
forgejo-runner generate-config | grep -E "privileged|docker_host|valid_volumes| network" network: ""
privileged: false
valid_volumes: []
docker_host: "-"forgejo-runner exec, which people use to try workflows locally, takes its
job settings from command-line flags. With its defaults, the job gets the
Docker socket, fourteen capabilities and no limits:
forgejo-runner exec -i alpine:3.22| 0
| CapEff: 00000000a80425fb
| NoNewPrivs: 0
| srw-rw---- 1 root 118 0 Sep 24 17:16 /var/run/docker.sock
| memory.max: max
| pids.max: 7098
Job succeededWith the hardened options:
forgejo-runner exec -i alpine:3.22 --container-daemon-socket - --container-cap-drop ALL \
--container-opts "--memory=256m --pids-limit=128 --security-opt=no-new-privileges"| 0
| CapEff: 0000000000000000
| NoNewPrivs: 1
| ls: /var/run/docker.sock: No such file or directory
| memory.max: 268435456
| pids.max: 128
Job succeededStill uid 0 inside the container, which is why the host runs Docker rootless or with user namespaces: that root maps to an unprivileged user on the VM.
Mistakes people make
The host label "just for this one job"
A host or self-hosted:host label runs steps as the runner user on the
VM, with access to the runner's registration file. One such label on a
shared runner makes every other protection on it irrelevant.
Mounting the socket to build images
docker_host: automount or a socket in valid_volumes gives every job the
Docker daemon, which is root on the VM. Build images with kaniko or another
daemonless builder.
Testing with exec and trusting the result
forgejo-runner exec mounts the socket by default, unlike the daemon. A
workflow that works under exec may rely on access the real runner will not
give, and the reverse. Test on a real runner with the probe.
One runner for everything
A runner that takes fork pull requests and release jobs lets a pull request leave something behind (a poisoned cache, a modified image) for the release. Separate runners per trust level, on separate VMs.
Open egress and metadata
A job that can reach 169.254.169.254 on a cloud VM can often read cloud
credentials. Block it on the VM firewall, along with every destination the
jobs do not need.
Checklist
- Each runner VM serves one trust level only.
- Docker on the runner VM is rootless or uses user namespace remapping.
- Labels are
docker://only, with images pinned by digest; nohostlabels. privileged: false,docker_host: "-",valid_volumes: [].container.optionssets memory, CPU and process limits, drops capabilities and sets no-new-privileges.capacity: 1on runners that take untrusted jobs.- The probe workflow was run on each runner after deployment and the output checked.
- Egress is limited to the forge and mirrors; cloud metadata is blocked.
- Fork pull request workflows run only on the untrusted runner and never receive secrets.
- Runner VMs are replaced on a schedule.
The runner will run whatever it is given; that is its job. Your job is to decide how small the room is where it does that.
H2-CSDE
Learn it on a live range
Self-hosting the forge, in DevSecOps and Supply Chain: a real host in your browser, and every objective checked on the machine.
Start freeH2 Scanner
Want this caught before it merges?
The H2 Scanner runs in your CI and flags the weaknesses pages like this one warn about, on every pull request.
Talk to us