"Roll back on a cadence" was the whole of the advice, which is close to giving none: a weekly chore performed by hand is one that stops after the third week, and the scheduled rollback is the more valuable of the two triggers precisely because it does not depend on noticing anything. Proxmox schedules backups but not rollbacks, so there is no console step to point at. proxmox/README.md gains a section 6 with the cron entry - on the node, because nothing inside the VM can be trusted to revert the VM - and accounts for each part of the line: the guest is brought down first because a snapshot without RAM cannot be rolled back into a live QEMU and leaves the VM stopped afterwards besides; `&&` rather than `;` so a failed rollback leaves CI down rather than running jobs from a state we had decided to discard; and the PATH line because qm lives in /usr/sbin and cron's default path does not include it. Plus how to confirm it is still running from the Tasks pane, that qm unlock is the fix for a stale lock, and that `qm set --hookscript` deadlocks on the VM's own configuration lock - which is otherwise the first thing anyone reaches for. Two things the snapshot section did not say, both of which only surface on the first rollback: Register the runner BEFORE snapshotting. The registration is a file on disk, so a snapshot taken earlier drops the runner off the forge every time, or has it re-register as a new entry and accumulate a dead one per rollback. Take it without RAM. A snapshot with RAM restores the guest clock, so a VM rolled back to last month cannot verify a certificate and apt rejects Release files as not yet valid. And a rollback reverts security updates along with everything else, so the baseline needs replacing every month or two rather than being taken once and left. Also corrects section 4, which still said the bootstrapper runs install.sh on both roles.
dejima-ci-hardener
Hardens a CI runner that executes agent-authored code on every pull request,
and gives it as little as possible to execute it with: no Docker socket in job
containers, and deny-by-default egress whose allowlist comes from
.dejima/allowed-domains on each served repository's main.
This is the only thing you clone onto a CI box. It is self-contained - there is no step that copies files out of a dejima checkout, and dejima itself never needs to be present here.
For why any of this is necessary - what a CI run adds over a sandbox, and
why no approval gate is offered - see dejima's docs/ci.md. You do not need
it to follow this.
The runner host itself
install.sh hardens the runners on a machine you already have. For the
privileged tier that is the wrong starting point - its jobs get the Docker
socket, so the assumption has to be that a compromised job gets root on that
host, and handing an existing machine to that assumption is not a decision
many people would make deliberately.
proxmox/README.md builds one instead: a runbook for the Proxmox web console, then one script inside the VM.
# inside the new VM, once the console steps are done
sudo ./bin/dejima-ci-bootstrap --role runtime --mgmt <your-ip>
On --role runtime that deliberately stops short of running install.sh. The
job network, its resolver and its DOCKER-USER rules all work by sitting in
front of job traffic on a bridge, and this tier's jobs are in the host's
network namespace where no bridge is. Installing them would cost an evening
and leave a host that looks like it has an egress allowlist.
The node half is a runbook rather than a script on purpose. Something that
runs qm create and writes into /etc/pve/firewall/ is exactly what gets
pasted into a root shell unread, and the blast radius is every VM on the node.
The in-VM half is a script, because running that one blind costs you a machine
built to be lost.
The boundary is on the node, not in the VM
The privileged tier hands job containers the Docker socket, which is root-equivalent on their host. So the working assumption is that a compromised job has root in that VM — and root can flush any firewall it can see.
Everything installed inside the VM is therefore defence in depth: real against a compromised job that has not escaped its container, which is the ordinary case, and worth nothing against one that has.
What holds either way is the Proxmox firewall, applied to the VM's tap device and unreachable from the guest. It denies the VM every RFC1918 destination except two pinholes — the forge on 443 and a resolver on 53 — and those two are the entire remaining LAN exposure.
Public egress is deliberately not restricted there - not by address and not by port. An address allowlist on the node would break on every CDN rotation, and a port allowlist buys nothing: an earlier version permitted only tcp/80, tcp/443 and udp/123, which broke apt on a first install, and anyone who owns that box tunnels over 443 like everybody else. Neither would hold against VM root in any case.
Note what that leaves. On this tier there is no egress allowlist in front of job containers either - they are in the host's network namespace, so nothing is in front of them. A privileged job reaches the whole public internet by design. What it does not reach is your LAN.
The guarantee is narrower and honest, and it is the one worth making: an attacker who owns that VM reaches the internet, and not your network.
One thing to check rather than assume, because its absence looks exactly like
it working: the datacenter firewall must be enabled (Datacenter → Firewall
→ Options → Firewall: Yes), and the VM's network device must have its own
Firewall checkbox ticked. Without either, the per-VM rules apply to
nothing. pve-firewall compile on the node is the check, and
proxmox/README.md has a from-inside-the-VM check that
does not rely on reading configuration at all.
Snapshot, and roll back on a cadence
The VM is worth losing, which is only true if losing it is cheap. Register the
runner, then snapshot — VM → Snapshots → Take Snapshot, named clean,
before the first job runs and with Include RAM unticked. One taken later
preserves whatever that job left; one taken before registration drops the
runner off the forge on every rollback; one with RAM restores the guest's clock
along with it, and a VM that thinks it is last month cannot verify a
certificate.
Rolling back after anything suspicious is not optional. Rolling back on a schedule is the more valuable of the two, because it does not depend on your having noticed — and it is the one that stops happening once doing it by hand gets old. Proxmox schedules backups, not rollbacks, so it is a cron entry on the node; proxmox/README.md §6 has the line, why each part of it is what it is, how to confirm it is still running, and why the baseline needs refreshing every month or two.
Do not install this next to dejima
This runs on a machine whose job is to execute untrusted code. dejima runs on a machine you care about. Putting both on one host defeats the separation that makes either worth anything, and this program holds a forge token and rewrites a resolver's configuration - neither of which belongs within reach of a sandbox session.
Install
sudo apt-get install ipset iptables dnsmasq
git clone https://gitbox.branderson.io/branderson/dejima-ci-hardener.git
cd dejima-ci-hardener
sudo ./install.sh
install.sh prompts for each value, offering whatever is already configured
as the default, and does the whole job: creates the Docker network with a
fixed bridge name, writes the resolver config, installs
dejima-ci-allowlist and dejima-ci-firewall, installs and enables the
systemd units, and renders the first allowlist.
Idempotent - re-running and pressing Enter at every prompt changes
nothing, so it is also how to change one setting later.
--non-interactive re-installs from the existing configuration.
What it asks for:
| Job network | name, bridge, gateway and subnet - a private range |
| Forge URL | https://gitea.example.com - the API root is derived |
| Upstream resolver | see below; it offers what this host already uses |
| Forge token | read access only, never echoed, kept if one is already installed |
| Repositories | owner/repo per line - adding one trusts that repository's main |
| Base domains | allowed regardless of any project declaration |
The upstream resolver
The one setting that is genuinely ambiguous, so: it is the resolver dnsmasq forwards allowed queries to - the one that actually knows the answers.
It is not the dnsmasq this configures (that would loop), and not the job bridge gateway. It is whatever this host already resolves with:
grep nameserver /etc/resolv.conf
On a host running systemd-resolved that gives you 127.0.0.53, the local
stub, which is not an answer - resolvectl status | grep 'DNS Servers' is,
and names the servers behind it. Anywhere else resolvectl is simply absent,
including on a VM built by the runbook above. Do not install it to find this
out; /etc/resolv.conf is the answer on that machine.
It has to resolve everything on the allowlist, including your forge - so
if that name is internal, this has to be your internal DNS. no-resolv in the
dnsmasq config means it will not read /etc/resolv.conf itself, which is why
it must be named explicitly.
The runners
install.sh does not register runners - that needs a token from your forge
and is the one step it cannot do for you. examples/ has the configuration
they need:
| File | Install as |
|---|---|
examples/act-runner-ci.yaml |
the ordinary runner's config.yaml |
examples/act-runner-runtime.yaml |
the privileged runner's, on its own host |
examples/docker-compose.yml |
several ordinary runners on one host |
examples/docker-compose-runtime.yml |
the privileged one |
examples/docker-daemon.json |
/etc/docker/daemon.json - merge, do not overwrite |
Set --dns=<your gateway> in the ordinary runner's container.options, and
register at repository or organization scope. That last part is not
housekeeping — see below.
Register scope is the boundary; the label is not
A dedicated label routes honest jobs to the runner you prepared. It stops
nothing else, because runs-on: lives in the workflow file and the workflow
file lives in the branch. A branch can name any label it likes, so a session
that changes runs-on: dejima-ci to runs-on: ubuntu-latest and opens a pull
request has stepped outside everything this tool installs — provided some
runner answering that label will take the job.
Whether one will depends on where your runners are registered:
| Registered at | Which repositories it serves |
|---|---|
| Instance | every repository on the instance |
| Organization | that organization's repositories |
| Repository | that repository |
Gitea has no per-repository runner ACL; its FAQ points at registration scope as the control instead, and gitea.com registers its own runners for a single organization so they will not run other repositories' jobs.
A repository's CI posture is that of the weakest runner it can reach, not the one its workflow names. So if the instance hosting these repositories also offers general-purpose instance-level runners, hardening this one buys less than it appears to. In rough order of preference:
- Do not register general-purpose runners at instance level on an instance that hosts repositories receiving agent-authored branches.
- Harden every runner that repository can reach, so landing elsewhere changes nothing that matters.
- Move the repository to an instance or organization whose reachable runners are all hardened.
Verify
Two failures matter here and both look exactly like success from inside a job: a job container that was handed the Docker socket, and a job that can reach an off-list address. Neither shows up as a red run.
dejima ships test/ci-runner.bats for exactly this, run by its own CI as
runner-env (sandbox) and runner-env (host-docker). Point it at your runner
and it will tell you which half is wrong - if the DNS checks pass while the
egress checks fail, the resolver is fine and the packet filter is not.
Use
dejima-ci-allowlist check # what would change, without changing it
dejima-ci-allowlist render # fetch, validate, install, reload if changed
dejima-ci-allowlist show # what is installed right now
dejima-ci-firewall # reapply the egress rules
Exit codes are distinguishable on purpose, because a timer's alerting has to tell "the allowlist moved" from "the forge is unreachable":
0 |
nothing to do |
1 |
check only - there is a pending change |
2 |
a fault: bad configuration, an unreachable forge, a malformed entry |
A configuration problem lists every problem at once, each naming the setting, the path it points at, and what belongs there.
How it fails
Every failure leaves the previous allowlist in place. That direction is deliberate, and it is the thing to preserve in any change:
- A forge that does not answer, or answers with anything other than 200 or 404, aborts the run. A 404 is a repository with no declaration, which is ordinary and skipped.
- A collection that comes out empty is refused rather than installed. An allowlist of nothing looks exactly like a working one until a job needs something.
- An entry that is not a plain domain name aborts the run rather than being skipped. A silently dropped entry reads as "that domain was never declared" and sends whoever debugs it to the wrong repository.
- A rendered file that fails
dnsmasq --testis not installed. A resolver that will not start is a job network with no DNS at all. - An unchanged allowlist is not reinstalled, so the log stays useful for spotting when it actually changed.
The firewall refuses to run without ipset, iptables and ip6tables, and
says what their absence costs. Without that check its first command fails and
the state it leaves behind resembles a working one: DNS still refuses unlisted
names, because that is the resolver's doing rather than the firewall's, while
every job has unrestricted egress.
Tests
bats test/
Runs against a stubbed curl and dnsmasq.
Reference
Everything below is background. You do not need it to complete the setup.
The two settings named docker.sock
The compose bind and container.docker_host name the same path and do
opposite jobs. This is the easiest thing here to conflate:
| Effect | Needed? | |
|---|---|---|
| The compose bind | The runner can reach the daemon | Yes — without it nothing runs |
docker_host: "-" |
Job containers cannot | Yes — without it every job is root on this host |
Setting the second does not remove the first, and removing the first does not make jobs safe — it just stops the runner working.
Whether a job gets the socket is a property of the runner, not of the job: a job asking for nothing unusual still receives whatever its runner grants everything it runs.
CONFIG_FILE, and why an edit can appear to do nothing
Without CONFIG_FILE=/config.yaml in the environment, act_runner does not
read the config file at all — it starts happily on built-in defaults,
which means the socket goes into every job container and nothing in any log
says so. If a config change appears to have no effect, check this first.
Mount the config read-only: a runner has no reason to rewrite the file that decides what its jobs are granted.
The /data volume
It holds .runner, written at registration: the runner's identity and its
credential for your forge (runner.file: .runner, resolved against the
container's working directory, which is /data in the official image).
- Keep it and
--force-recreate, an image bump or a reboot brings back the same registered runner. Without it the container comes up with no identity — with a token still in the environment it silently registers a new runner, leaving a dead entry in your forge's runner list; without one it fails to start. - Delete it deliberately to re-register: a runner moved to another instance, or a label set you would rather establish fresh.
- It is the only state. Nothing else in the container is worth preserving, which is what lets the rest of this treat a runner as disposable.
Labels come from runner.labels on every start rather than being frozen at
registration, so changing one is an edit and a restart.
Compose networks and the job network are different worlds
Running several runners from one compose file makes them share a network — and that is not the network worth worrying about.
Every service in that file is an act_runner process. The containers that
execute agent-authored code are created by the daemon, on the network named
by container.network, and compose knows nothing about them. Attaching a
service to a compose network does not put its jobs there.
So the shared network holds runner processes and nothing else, and those are
mutually trusted — each already holds the Docker socket, so separating them
protects nothing. The example names one runners network explicitly rather
than relying on the implicit default, so nothing joins it later by accident.
The compose file deliberately does not attach a runner to the job network (a process holding the socket has no business sharing a network with untrusted code), and does not put the runner behind the egress allowlist (those rules filter the job bridge only — the runner is trusted, the jobs are not).
Several runners, and whether they should share a job network
What is shared, with one compose file or five, is the job network: every
runner whose config names dejima-ci puts its jobs on that bridge. With N
runners at capacity: 1 you get up to N jobs there at once, able to reach each
other and each other's service containers.
-
One shared job network — the default, and why several runners can share one config file. Every job on it has the same egress allowlist and none can reach the host or the runners, so the added exposure is strictly job-to-job. If the concern is a prompt-injected session reaching outward, this already covers it.
-
One per runner — worth it if concurrent jobs come from repositories that should not interfere, or if service containers hold anything a neighbour should not see. Costs a config file and a
docker network createeach, and the firewall rules looped over each bridge.The
ci-allowipset is shared either way — it holds destinations, not per-bridge state, so one set and one dnsmasq serve every bridge. The wrinkle is DNS:daemon.json'sdnsis a single daemon-wide address that would sit on one bridge and be unreachable from the others. Give dnsmasq a stable address on a dummy interface, pointdaemon.jsonthere, and let each bridge'sINPUTrule allow port 53 to it.
Why the privileged runner is separate
It is handed the Docker daemon and the host's network namespace, because one job cannot work without them and neither grant can be reduced without breaking it. A job on it can take the machine it runs on — so that machine should exist for nothing else. A separate compose file makes a separate host the default, rather than something to disentangle later.
Where the two tiers do share a machine, be clear about what that is: label tiering, not host separation. A job denied the socket still shares a kernel with a runner that has it. A real reduction over granting everything to everything; not isolation between the tiers.
It needs no bind for /tmp/dejima-ci — the job container gets that path
because the workflow asks for it and container.valid_volumes permits it, and
the host daemon resolves the source host-side.
Why the resolver is set per-runner, not daemon-wide
daemon.json has a dns key, and pointing it at the allowlisting resolver is
the obvious way to make job containers use it. It is also wrong here, and the
failure is not obvious.
That setting applies to every container the daemon starts. On a host that also runs the privileged tier, that includes containers created by its jobs over the Docker socket — which are not job containers and were never meant to be behind the CI resolver.
A dejima sandbox is exactly such a container, and it has a firewall of its own. It allowlists precisely one host address: its own bridge gateway, taken from its default route. A resolver on a different bridge is therefore rejected by the sandbox's own rules, so nothing inside it resolves — and the symptom is not "DNS is misconfigured" but the sandbox's startup self-check reporting that a host it allows is unreachable.
--dns in container.options scopes the resolver to the containers that
runner starts, which is the set that should have it.
The cache server
act_runner enables it by default, and it binds on the host specifically so
job containers can reach it — an inbound service exposed to untrusted code. The
firewall here blocks exactly that. Leave it off, or add a rule knowing what you
are exposing.