Two findings, both from reading the path rather than the code. THE "RECORD IT PROPERLY" BLOCK WAS CONFINED-SHAPED. It ran install.sh - which does not run on a privileged host, deliberately - and then invoked dejima-ci-upgrade bare, which only works because install.sh had just put it there. A privileged operator following it got exactly the error that started this: the tool reporting it could not find itself. There is now a block per tier. Confined keeps install.sh. Privileged gets the three install commands bootstrap uses, because bootstrap is the only thing that installs the tool on that tier and re-running it on a built machine also redoes runner setup - a real cost for a machine that is already serving. Those three duplicate bootstrap's lines and can drift from them, so a test pins both ends to the same destinations. A tool installed to the wrong path is a tool that is not there, reported three sections later as "not installed here", which is how this whole thread started. THE UPGRADE DOCS WERE NOT LINKED FROM THE README AT ALL. Only docs/README.md mentioned them, so an operator with an existing runner had no path from the front page to the flow. There is now an "Updating an existing runner" section with the command to check a version, the instruction to walk the notes in order, and the two things a runner operator needs before starting: run from a checkout, and recording is itself a guest-side change. The runbook and the docs index point at it, and so does the intro, where someone skimming will be. `bats test/` 136 passing, 1 new.
29 KiB
Provisioning a runner VM on Proxmox
The node-side half: the VM, the firewall applied to it, and its snapshots. The in-VM half is docs/building-a-runner.md, the same on any hypervisor.
The whole job
Already have a runner? This page builds one. To bring an existing VM forward, start at Updating — the upgrade notes name the rules to change rather than having you re-read the table below.
Four steps, and the two pages interleave — follow this order rather than either page top to bottom.
| Do this | Where | Roughly | |
|---|---|---|---|
| 1 | Turn the datacenter firewall on | §1 below | one setting |
| 2 | Create the VM | §2 below | one form |
| 3 | Install Debian | guest §1 | the usual |
| 4 | Build the rules, apply them, verify | §3 below | the substance of this page |
| 5 | Finish the guest setup | guest §2 onward | it sends you back to §4 twice, once per snapshot |
Everything except step 4 is short. Step 4 is ten firewall rules entered into a form, three policy settings, and two verification passes. If you have done it before, the rule table is the whole of it.
Two VMs if you are following the tiering — one privileged, one for the confined runners. Every step here is identical for both; the tiers differ only in a flag, and that flag is in the guest runbook.
The rules go on after the OS is installed, not before. The Debian installer takes its address by DHCP whatever you pin it to afterwards, and a lease needs a reply back in. Apply the group first, without the DHCP pair from §3, and the installer has no network at all — which presents as a broken mirror or a dead link rather than as a firewall you switched on ten minutes earlier. Ticking the Firewall checkbox in §2 is safe on its own.
What you are building
A machine to hand to an attacker.
The privileged runner gives job containers the Docker socket, which is root-equivalent on their host. So the working assumption is not if but when: a compromised job has root on this VM.
The firewall goes on the node, not in the guest. Root in the VM can flush
any rule set it can see. It cannot reach one applied to its own tap device.
Everything dejima-ci-bootstrap installs inside the VM is defence in depth —
real against a job that has not escaped its container, worth nothing against one
that has.
1. Check the datacenter firewall is on
Do this first. A per-VM firewall with the datacenter switch off is read and enforced nowhere, and nothing about that is visible from the VM — it looks exactly like a working setup.
Datacenter → Firewall → Options → Firewall: Yes
If you have to change it, check that nothing else on the node depended on
having no firewall. The datacenter default policy is ACCEPT in and out, so
turning it on does not by itself filter anything — but it is worth knowing
before you flip it rather than after.
2. Create the VM
Create VM, and the values that matter:
| Tab | Setting | Value |
|---|---|---|
| General | Name | oubliette, or whatever you prefer |
| OS | ISO | Debian netinst, current stable |
| System | Qemu Agent | ✓ — and see the note below, the tick alone does nothing |
| Disks | Size | 64 GB is comfortable for image builds |
| CPU | Cores | 4 |
| Memory | 8192 MB | |
| Network | Bridge | your normal bridge |
| Network | Firewall | ✓ — this is the checkbox everything else depends on |
The Firewall checkbox is easy to miss and easy to uncheck later without noticing. If it is off, the per-VM rules do not apply to the interface and the VM is simply on your LAN.
Qemu Agent is two halves, and this tick is one of them. It opens a
virtio-serial channel on the host side; answering on it takes the
qemu-guest-agent package inside the guest, which dejima-ci-bootstrap
installs and which tells you if this tick is missing. Leave it on: without the
channel the agent has nothing to attach to, the Summary pane shows no address,
qm shutdown falls back to hoping ACPI is acted on, and a snapshot of a
running VM stays crash-consistent.
What a moving address does not break. Not the firewall. Every rule in the next section keys on a destination, except the SSH rule, which keys on your management address — and Proxmox applies the whole set to this VM's tap device, identified by VMID rather than by IP. Nothing anywhere names this VM by its own address, so a rebuilt VM that comes up on a different one is still fully firewalled.
What it breaks is you finding the machine: ssh <you>@<vm-ip>, and every later
visit. That is reason enough to pin it.
Now install the OS, then come back
The VM exists and has no operating system. Go to docs/building-a-runner.md §1, install Debian, and return here for §3.
Nothing else from the guest runbook is needed yet. Coming back with a booted VM is what makes the rest of this page checkable rather than assumed.
3. The firewall rules
Every runner VM gets the same ten rules, whichever tier it is, so build them once as a security group and apply that group to each. Two VMs with two hand-entered copies is two chances to get the ordering wrong and one of them to drift out of step later.
Both tiers get these, for different reasons
Apply them to the confined tier as well as the privileged one. The rules are identical; what differs is the job they are doing.
On the privileged tier this is the boundary. Its jobs hold the Docker socket and run in the host's network namespace, so root on that VM is the working assumption, and anything installed inside it can be removed by whatever holds root. These rules are on the hypervisor, applied to the tap device, and the guest cannot reach them.
On the confined tier the primary control is inside the VM: the job network, its allowlisting resolver, and the DOCKER-USER rules. Those are real against a job precisely because a job cannot reach them, and they decide job egress by name and by address. The node firewall sits behind that as defence in depth. It changes nothing the allowlist has already decided; it covers the case the allowlist cannot, which is a job that escapes its container and reaches root on the host. At that point what is left is this, and it is doing exactly what it does on the other tier.
So: primary in the guest and defence in depth on the node for confined, nothing in the guest and everything on the node for privileged.
One thing to weigh before applying them to a confined host. Rules 5–9 drop
every private destination, including ones a job might legitimately want. If a
served repository declares an internal name in .dejima/allowed-domains — a
package mirror, an internal registry — the allowlist will resolve it and the
node firewall will drop the connection, which reads as the allowlist not
working. An internal NTP server is the same story. Give anything in that
position its own pinhole above the DROPs, or keep it off the allowlist so the
failure is at least honest about where it comes from.
Rule 10 has the same shape and one extra wrinkle: it also drops IPv6 egress to
the internet, not only to your network, because the family goes whole. In
practice that changes nothing for a confined job, since dejima-ci-firewall
rejects IPv6 on the job bridge for the same reason. It could matter to a
privileged job that fetches from a v6-only host. If you hit that, the honest fix
is a pinhole for that destination rather than dropping the rule, because
dropping it reopens the whole LAN.
And one value that has to agree: the resolver in rules 3–4 must be the same
address as upstream in /etc/dejima-ci/config, since that is where the
confined tier's dnsmasq forwards allowed queries. Point them at different
servers and every job fails to resolve while the host itself is fine.
Build the group once
Datacenter → Firewall → Security Group → Create, named
dejima-ci-hardener. Then Add these to it, in this order:
| # | Direction | Action | Protocol | Dest. port | Source / Dest | Why |
|---|---|---|---|---|---|---|
| 1 | in | ACCEPT | tcp | 22 | Source: your management IP | The only way in |
| 2 | out | ACCEPT | tcp | 443 | Dest: forge IP | Fetch jobs, report results |
| 3 | out | ACCEPT | udp | 53 | Dest: nameserver IP | The forge's name is internal |
| 4 | out | ACCEPT | tcp | 53 | Dest: nameserver IP | Same, over TCP |
| 5 | out | DROP | — | — | Dest: 10.0.0.0/8 |
|
| 6 | out | DROP | — | — | Dest: 172.16.0.0/12 |
|
| 7 | out | DROP | — | — | Dest: 192.168.0.0/16 |
The three that matter |
| 8 | out | DROP | — | — | Dest: 169.254.0.0/16 |
Link-local, incl. metadata services |
| 9 | out | DROP | — | — | Dest: 100.64.0.0/10 |
Carrier-grade NAT, where a tailnet lives |
| 10 | out | DROP | — | — | Dest: ::/0 |
IPv6, the whole family |
Rule 9 is for a tailnet. Tailscale numbers its hosts out of 100.64.0.0/10,
which is none of the three RFC 1918 ranges, so without this rule a runner on
your tailnet reaches every machine you own anywhere — a wider network than the
one rules 5 to 7 were drawn around, not a narrower one. Add it whether or not
you run Tailscale today: it costs nothing on a VM with no overlay, and the day
someone installs one is not a day anyone revisits the firewall.
Rule 10, and why IPv6 is dropped whole
IPv6 has no RFC 1918, so there is no list of private ranges to mirror — the
family goes whole instead. Worth having even on a network that routes no IPv6,
because every Linux host answers on fe80:: regardless. The argument is in
the threat model
and again above OUT DROP -dest ::/0 in
firewall.fw.template.
dejima-ci-firewall blocks IPv6 too, one layer in, and it is not this. Its
rules cover a confined job leaving its bridge. They are not in front of the VM's
own traffic, and on the privileged tier not in front of the jobs either, since
those run in the host's network namespace. Rule 10 covers root on the VM — the
privileged tier's ordinary case, not its worst one.
Do it here, not with disable_ipv6 in the guest, which is a setting root on
a machine this page tells you to treat as already lost can put back.
If the console rejects ::/0, enter four rules in rule 10's position
instead: fe80::/10, fc00::/7, ff00::/8, 2000::/3.
Two more, if the VM gets its address by DHCP
Skip both if you pinned the address in the guest with dejima-ci-static-ip.
Add both if you did not, including when the address is reserved for you on the
router: reserved or not, the guest still speaks DHCP.
| # | Direction | Action | Protocol | Dest. port | Source / Dest | Why |
|---|---|---|---|---|---|---|
| 1a | in | ACCEPT | udp | 68 | — | Let the lease reply back in |
| 4a | out | ACCEPT | udp | 67 | — | Let the lease request out |
Place 1a with the other inbound rule and 4a above the DROPs, as the
numbering suggests. Both name a port and no host, which makes them narrower
than an exception naming your DHCP server: they add no reachable host to the
list above.
Both or neither. Rule 4a alone gets the request out and the VM still
never renews, because the reply arrives from the server's address to the VM's,
which is not the tuple the request created. Connection tracking is unlikely to
match that against Input Policy: DROP, so 1a is what actually lets the
lease complete.
Do not count on rebinding to save you. At T2 the client stops unicasting and
broadcasts to 255.255.255.255, which none of the DROPs match, so the request
does leave without rule 4a — but the reply still needs 1a. Half a working
path renews nothing.
This failure is slow and does not look like a firewall. Nothing breaks at reboot or at first boot. The VM simply stops renewing, and somewhere up to a full lease time later it drops off the network.
Rule 1a lets anything on the segment send to the client port, which is a
spoofed-reply surface. It is close to free: DHCP on a LAN is trust-on-first-use
for every host on that segment whatever the firewall does, so permitting the
reply does not create that exposure. Not using DHCP is still narrower, and
dejima-ci-static-ip exists for it.
Then apply it, per VM
A security group carries rules and nothing else. The policies are not part of it, so each VM still needs its own:
VM → Firewall → Options
| Firewall | Yes |
| Input Policy | DROP |
| Output Policy | ACCEPT |
Inbound denies by default; outbound accepts by default and the LAN is denied
explicitly by the group. That asymmetry is deliberate: deny-by-default earns its
keep where the protected set is enumerable, which the LAN is and the internet is
not. The argument against inverting it is above policy_out in
firewall.fw.template.
Then VM → Firewall → Add → Security Group, and select
dejima-ci-hardener. That single entry is the whole rule set, in the order the
group defines, and it is the only firewall rule the VM needs.
Adding another runner later is that one step. The ordering below was got right once, in one place, and cannot drift between hosts.
Order matters within the group, and the console works against you here.
Rules are evaluated top to bottom and the first match wins. Your forge and your resolver are inside the ranges rules 5–8 drop, so if either pinhole ends up below them it is dead and the runner cannot reach the forge or resolve anything.
The console inserts each new rule at the TOP of the list, not the bottom. So entering rules 1 to 10 in the order they are written above leaves you with them in reverse: the six DROPs on top, every pinhole beneath them, and a runner that cannot reach its forge. Following the table without watching the list is enough to produce exactly the failure this section is warning about.
Two ways through it. Drag each rule to the bottom as you add it, which keeps the list matching the table at every step and is the easier one to check. Or enter them in reverse, from 8 up to 1, and let the insertions stack into the right order — quicker, and you are reading the table backwards while doing it, which is its own way to get one wrong.
Either way, read the finished list top to bottom and compare it against the table before you trust it. That is the only step here that catches the mistake, because nothing else will: a dead pinhole looks exactly like a working firewall until something tries to reach the forge.
Rules 2–4 are the entire remaining LAN exposure, and they are why "no LAN access" is not literally true. Both destinations are pinned to one host and one port.
Nothing follows rule 10. The public internet is reached by the default policy.
Values that must agree
Three pairs here are entered in two places each, and each pair is silent when the halves disagree. Read the real value off the machine rather than recalling it — that is the whole of the advice, and every one of these is easy to get wrong from memory.
| This | Must equal this | Read the truth from |
|---|---|---|
| Rules 3–4's destination | The VM's nameserver | grep nameserver /etc/resolv.conf on the VM |
| Rule 2's destination | The forge's address | getent hosts <forge-hostname> |
| Rule 1's source | The host you SSH from | wherever you are sitting |
Rules 3–4 want the nameserver, not the gateway. They are the same address
on plenty of networks, which is exactly what makes this worth saying: using
the gateway works for some people and, for everyone else, blocks DNS while
leaving every other check passing. ip route gives you the gateway;
/etc/resolv.conf gives you the nameserver, and it is the second one that
belongs in these rules.
There is a fourth pair inside the VM, once install.sh has run: the
resolver's listen-address and the runner's --dns=. Same shape, same
silence. install.sh derives both from one answer for that reason.
Check it, rather than assuming it
Two passes. The first reads configuration, the second tests behaviour, and only the second is proof: a rule that is loaded and a rule that does what you meant are different claims.
Pass one, on the node, with the VM running
Proxmox builds these chains around the tap device, so a stopped VM has none and their absence would tell you nothing.
# what is actually loaded right now
sudo iptables -S | grep "tap<vmid>i0"
# the same, for the other address family. Rule 10 lands here and nowhere else
sudo ip6tables -S | grep "tap<vmid>i0"
# what Proxmox would generate from the current config
sudo pve-firewall compile | grep -B2 -A20 "tap<vmid>i0"
Both tables, not just the first. An IPv6 rule compiles into ip6tables and
is absent from iptables -S, which shows rule 10 missing whether it is there or
not.
What you are looking for. Chains named tap<vmid>i0-IN and -OUT, holding
your rules in the order you added them. The v4 -OUT chain has the three
pinhole ACCEPTs, then the five LAN DROPs, then whatever Proxmox appends for the
policy. The v6 -OUT chain has rule 10 and no pinholes, since every pinhole
names an IPv4 address.
| What you see | What it means |
|---|---|
No tap<vmid>i0 chains at all |
The firewall is not applied to this VM. The NIC's Firewall checkbox, the VM's Firewall option, or the datacenter switch — check all three, in that order |
| Chains exist but hold none of your rules | The rules are on a different VM id, or were added at datacenter or node level rather than to this VM |
| A LAN DROP above a pinhole ACCEPT | The ordering trap. The runner will not reach the forge or resolve anything; drag the pinholes above the DROPs |
| Rules present and correct, DNS still failing | The address in rules 3–4 is not the VM's nameserver. The gateway is the usual wrong answer — see "Values that must agree" |
v4 chain right, ip6tables -S empty |
Rule 10 was not entered, or the console rejected ::/0 and the error was missed. Nothing about the v4 half will look wrong |
pve-firewall status reports the datacenter switch, not these rules.
Pass two, from inside the VM
Three must fail and two must succeed. That is what makes the set diagnostic rather than reassuring.
Nothing needs installing: bash opens TCP connections itself, getent is part
of libc, and ping ships with Debian — which matters on a machine you have not
finished setting up yet.
try() { timeout 5 bash -c "exec 3<>/dev/tcp/$1/$2" 2>/dev/null \
&& echo " CONNECTED $1:$2" || echo " blocked $1:$2"; }
gw=$(ip -4 route show default | awk '{print $3; exit}')
dev=$(ip -4 route show default | awk '{for (i=1;i<NF;i++) if ($i=="dev") print $(i+1); exit}')
try "$gw" 443 # must be blocked - your own first hop
try <forge-ip> 443 # must connect
try deb.debian.org 80 # must connect - the public internet
getent hosts deb.debian.org && echo " resolved" || echo " DNS FAILED"
# must find nobody but this VM - rule 10
sudo ping -6 -n -c 2 -W 1 -I "$dev" ff02::1
The last one asks the whole LAN who is there, over IPv6. Any reply from an address that is not this VM's own means rule 10 is not in force, and that the segment is reachable over a family none of the checks above it touch.
This one is a by-hand check and stays one. Everything else this repository
verifies, it verifies from inside a job, in test/ci-runner.bats — and a job is
the wrong vantage point for this question, because rule 10 is about the VM's own
reach and a confined job's traffic is stopped by dejima-ci-firewall before it
ever gets to the tap. The sister project checks the equivalent automatically
because it has an in-VM audit to hang it on; this one does not, so run it when
you build the group and after any change to it.
Both targets are chosen so that naming them writes nothing down. The
gateway is already in the routing table and ff02::1 is the same on every
network there has ever been, so neither describes your topology to anyone
reading a machine this page tells you to treat as already lost. Both are also
guaranteed to answer when they are reachable, which an arbitrary host is not.
Read them together, because the pattern says which thing is wrong:
- First one connects → the firewall is not in force at all. Check the Network device's Firewall checkbox and the datacenter switch.
- Second or third fails while the fourth works → a pinhole is below the LAN DROPs. Check the rule order.
- Only the last one finds neighbours → the v4 half is right and rule 10 is missing. This is the failure that looks like success from every other angle, which is why it has its own probe.
- Everything fails → the VM has no working route or DNS at all, which is a networking problem rather than a firewall one. Note that this also makes the first probe's "blocked" meaningless: silence proves nothing when nothing is getting through, so re-read it only once the other three pass.
The node side is done. Go back to the guest.
Everything above is what a hypervisor does and a guest cannot. The rest is inside the VM: addressing, packages, the allowlist, the runners.
Continue at docs/building-a-runner.md §2.
That runbook sends you back here twice, to §4 below, once for each snapshot. It says where each falls; this page says how to take them.
4. Snapshots and rollback
Three moments, and docs/building-a-runner.md says when each falls. The mechanics are the same for all of them: shut the VM down, then VM → Snapshots → Take Snapshot, with Include RAM unticked, then start it again.
| Name | When | What it is for |
|---|---|---|
preflight |
after the OS is installed and its network verified, before anything is run on it | a known base to return to while iterating |
clean |
after the runner registers and the VM has been restarted once, before its first job | the state the rollback cadence returns to |
Snapshot with it shut down, unless the guest agent is answering. Proxmox will snapshot a running VM, and without the agent what you get is crash-consistent: the disk as it was at an instant, mid-writes included, recovered by journal replay on the next boot. That is what pulling the plug gives you, and it is a poor thing to restore unattended every week indefinitely.
qemu-guest-agent removes the question — Proxmox asks it to freeze the guest's
filesystems for the duration, so a live snapshot is consistent.
dejima-ci-bootstrap installs it, and §2's Qemu Agent tick is what gives it a
channel to answer on. With both, snapshot the VM where it stands. With either
missing, shut it down first.
And clean has an order it depends on, which is easy to get wrong because
nothing complains at the time. It must be taken of a state that has already
booted successfully at least once since the last change to the machine's
network configuration. Rolling back is a cold boot - the disk is restored, the
VM is started - so every rollback re-applies whatever is in
/etc/network/interfaces and re-runs every unit. If that configuration is
wrong, the rollback is when it takes effect, and every rollback after it does
the same thing. Restart once and check the machine comes back before taking
clean, and the cadence cannot surprise you later.
Leave Include RAM unticked. A snapshot with RAM restores the guest's clock along with everything else, so a VM rolled back to last month believes it is last month: TLS verification fails, apt rejects Release files as not yet valid, and every timestamp a job writes is wrong. Nothing in this VM's memory is worth resuming, and a rollback without RAM is smaller and faster besides.
Roll back from the same tab whenever you want it clean again. The snapshot
survives being rolled back to, so clean stays usable indefinitely —
qm listsnapshot <vmid> on the node shows it still there afterwards.
Two triggers:
- After anything suspicious. Not optional, and the reason to keep
cleancurrent enough that rolling back costs you nothing you mind losing. - On a cadence. This is what bounds how long a foothold can live without your having noticed it — which is the case the first trigger cannot cover, and the reason the rest of this section exists.
Automate the cadence
The reactive trigger depends on you noticing. The scheduled one does not, which makes it the more valuable of the two — and the one that quietly stops happening after the third week of remembering to do it by hand. Automate it now or accept that it will not happen.
Proxmox schedules backups, not rollbacks, so there is no checkbox for this. It is a cron entry on the node — which is also the only place it can live. Nothing inside the VM can be relied on to revert the VM; that is the same reason the firewall is on the node.
Create /etc/cron.d/dejima-ci-rollback on the node, substituting your VMID:
# Roll the runner VM back to its clean snapshot. Sundays, 04:00.
SHELL=/bin/sh
PATH=/usr/sbin:/usr/bin:/sbin:/bin
0 4 * * 0 root qm shutdown <vmid> --timeout 180 --forceStop 1 && qm rollback <vmid> clean && qm start <vmid>
Three things in that line are deliberate:
qm shutdownfirst, notqm rollbackalone. A snapshot without RAM cannot be rolled back into a running VM — Proxmox will not swap the disk out from under a live QEMU — and after the rollback the VM is left stopped, so theqm startis required rather than tidy.--forceStop 1pulls the plug if the guest has not gone down within the timeout, which is the correct outcome for a machine whose state you are about to discard anyway.&&, not;. If the rollback fails, the VM stays off. That is a CI outage, and it is the right one: the alternative is a VM that keeps taking untrusted-code jobs from a state you had decided to throw away. Fail closed and go look.- The
PATHline.qmlives in/usr/sbin, which is not on cron's default path. Without it the job fails every week withqm: not found.
Pick an hour when nothing is running. A rollback mid-job kills the job, which shows up as a failed run and is re-runnable — annoying, not harmful, but there is no reason to schedule it into the working day.
Check that it is actually running
Belief that this is happening is worth nothing; the whole point is that it happens while you are not watching.
- The node's Tasks pane at the bottom of the web console lists a
qmrollbackentry after each run. That is the check, and it needs no shell. qm listsnapshot <vmid>still showsclean— rollback does not consume it.- If a run was interrupted the VM can be left locked, and every subsequent run
fails on the lock.
qm unlock <vmid>clears it. - A rollback failing with the VM still running means
qm shutdownreturned before the guest was down. Put a&& sleep 30after it.
Do not do this with a hookscript. qm set --hookscript looks like the
right tool and is a trap: the pre-start and post-stop phases already hold the
VM's configuration lock, so a rollback from inside one deadlocks against
itself and times out. Cron, outside the VM's lifecycle, is the way.
Refresh the baseline every month or two
A rollback reverts everything since the snapshot, including security updates.
unattended-upgrades catches up within a day of each boot, so the VM does not
stay unpatched — but the baseline it starts from is a week further behind every
week, and each rollback reopens whatever has been fixed since, until the timer
next runs.
So periodically: let it roll back, let it patch, reboot, confirm the runner is
Online, then VM → Snapshots, remove clean and take a fresh one under
the same name. That is also the moment to re-run dejima-ci-bootstrap if this
repository has moved on. (If a window with no snapshot at all bothers you, name
them by date instead and update the cron line — one more thing to keep in step,
which is why it is not the default suggestion here.)
Re-running dejima-ci-bootstrap after any rollback is safe — it is idempotent
— but should not be necessary, since the snapshot is taken after it ran. On a
ci-role host the allowlist is the same story from the other direction: it
reverts too, and dejima-ci-allowlist.timer pulls it current again on its own.
Whatever else reverts is cheap: Docker images have to be pulled again, so the first job after a rollback is slower than the rest.
Why there is no script for this page
A script that runs qm create and writes into /etc/pve/firewall/ is exactly
the kind of artifact people paste into a root shell without reading, and the
blast radius is the whole node — including every VM on it that has nothing to do
with CI. The steps above are short enough to follow and specific enough to
check, and you can see what you are doing while you do it.
The in-VM half is a script:
bin/dejima-ci-bootstrap. Running that blind
costs you a VM built to be lost, which is the difference.
One step here is not console work, because it cannot be: Proxmox schedules backups but not rollbacks, so the cadence in §4 is a three-command cron entry, printed in full and explained part by part.