What this builds
A CI runner attached to your own forge, which checks out a repository, builds a container image without a daemon and without root, and pushes the result to a private registry running on the same instance. No privileged socket is mounted anywhere, and nothing in the pipeline runs as uid zero.
Most self-hosted CI setups solve image building by mounting the host container daemon into the job. That works and it hands every pipeline, including the one somebody opened a pull request against, complete control of the machine. Buildah in rootless mode does the same job with none of that, and on dedicated cores it is not slower.
Before you start
- An R-8. Image builds are the most single-core-bound thing most teams run, and Zen 5 is the fastest per core we sell. Four hundred gigabytes of NVMe holds a great many layers.
- A forge you already run that speaks the Actions protocol, and permission to create a runner registration token in it.
- Hostnames:
ci.example.comfor the runner andregistry.example.comfor the registry.
1. An unprivileged user with a namespace range
apt update && apt install -y podman buildah skopeo fuse-overlayfs uidmap slirp4netns git nodejs nginx apache2-utils
useradd -m -s /bin/bash runner
echo "runner:200000:65536" >> /etc/subuid
echo "runner:200000:65536" >> /etc/subgid
loginctl enable-linger runnerThose two ranges are what make rootless containers possible: the runner account owns sixty-five thousand subordinate ids, so a process that believes it is root inside a container is mapped to an unprivileged id outside it. Lingering keeps the user session alive so systemd units under that account survive logout.
Node is installed because most reusable actions are JavaScript and the runner executes them on the host in this configuration. Discovering that at the checkout step is a common ten-minute detour.
2. Rootless storage on the NVMe
sudo -u runner mkdir -p /home/runner/.config/containers
sudo -u runner tee /home/runner/.config/containers/storage.conf <<EOF
[storage]
driver = "overlay"
graphroot = "/home/runner/.local/share/containers/storage"
[storage.options.overlay]
mount_program = "/usr/bin/fuse-overlayfs"
EOF
sudo -u runner podman info --format "{{.Store.GraphDriverName}} {{.Host.Security.Rootless}}"That last command should answer overlay true. A vfs driver instead means fuse-overlayfs is missing, and vfs copies every layer in full on every build, which turns a ninety-second pipeline into a six-minute one.
3. A private registry
apt install -y docker-registry
htpasswd -c /etc/docker/registry/htpasswd ci
certbot certonly --standalone -d registry.example.comBind the registry to loopback and let nginx own everything facing outwards, authentication included. In /etc/docker/registry/config.yml:
version: 0.1
storage:
filesystem:
rootdirectory: /srv/registry
delete:
enabled: true
http:
addr: 127.0.0.1:5000The registry itself carries no credentials because it never receives a request that has not already been through the proxy. One place to check a password is better than two places that can disagree.
server {
listen 443 ssl;
server_name registry.example.com;
ssl_certificate /etc/letsencrypt/live/registry.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/registry.example.com/privkey.pem;
client_max_body_size 0;
chunked_transfer_encoding on;
location /v2/ {
auth_basic "restricted";
auth_basic_user_file /etc/docker/registry/htpasswd;
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 900s;
}
}client_max_body_size 0 removes the upload limit. Leave the nginx default in place and every layer above one megabyte fails with a 413 about two thirds of the way through a push, which is a memorable afternoon.
systemctl enable --now docker-registry nginx4. The runner
cd /usr/local/bin
wget -O act_runner https://code.forgejo.org/forgejo/runner/releases/download/v6.3.1/forgejo-runner-6.3.1-linux-amd64
chmod +x act_runner
sudo -u runner mkdir -p /home/runner/.runner-cfg
cd /home/runner/.runner-cfg && sudo -u runner /usr/local/bin/act_runner generate-config > config.yamlEdit the generated file so that jobs run on the host rather than inside a container, because Buildah already provides the isolation and nesting the two adds nothing but complexity:
runner:
capacity: 2
timeout: 1h
labels:
- "debian-13:host"
host:
workdir_parent: /home/runner/work
cache:
enabled: true
dir: /home/runner/cacheCapacity two on eight cores is deliberate: builds are largely serial, and two concurrent jobs with four cores each finish sooner than four jobs fighting over the same cache. Register against your forge with the token it generated:
cd /home/runner/.runner-cfg
sudo -u runner /usr/local/bin/act_runner register --no-interactive \
--instance https://forge.example.com --token <registration token> \
--name ci-ams --labels debian-13:hostThen a unit, running as the unprivileged account:
[Unit]
Description=Actions runner
After=network-online.target
[Service]
User=runner
WorkingDirectory=/home/runner/.runner-cfg
ExecStart=/usr/local/bin/act_runner daemon --config /home/runner/.runner-cfg/config.yaml
Restart=always
Environment=HOME=/home/runner
Environment=XDG_RUNTIME_DIR=/run/user/3001
NoNewPrivileges=yes
[Install]
WantedBy=multi-user.targetSubstitute the real uid of the runner account in XDG_RUNTIME_DIR; id -u runner prints it. Rootless podman needs that directory to exist, which is what the linger setting in step one guarantees.
systemctl daemon-reload && systemctl enable --now act-runner5. A workflow that builds and pushes
In the repository, at .forgejo/workflows/image.yaml:
on:
push:
branches: [main]
jobs:
image:
runs-on: debian-13
steps:
- uses: actions/checkout@v4
- name: Build
run: |
buildah bud --layers --format oci -t app:${{ github.sha }} .
- name: Push
run: |
buildah login -u ci -p ${{ secrets.REGISTRY_PASSWORD }} registry.example.com
buildah push app:${{ github.sha }} docker://registry.example.com/app:${{ github.sha }}
buildah push app:${{ github.sha }} docker://registry.example.com/app:latest--layers turns on layer caching, which is the difference between rebuilding your dependencies on every commit and rebuilding them when they change.
Verify it
The runner should appear as online in the forge’s runner list within seconds of the unit starting. Then push a commit and watch the job from the machine:
journalctl -fu act-runnerWhen it finishes, confirm the image genuinely arrived rather than merely reporting success:
skopeo inspect --creds ci:<password> docker://registry.example.com/app:latest | head -20
skopeo list-tags --creds ci:<password> docker://registry.example.com/appYou want the digest, the layer list and both tags. Now prove it runs, on a different machine if you have one to hand:
podman run --rm registry.example.com/app:latest --versionLast, the claim this whole build rests on. While a job is running, look at who owns the processes:
ps -eo user,pid,comm | grep -E "buildah|podman" | head
sudo -u runner podman info --format "{{.Host.Security.Rootless}}"Every process belongs to runner, and the security check answers true. Nothing in the pipeline holds root, which means a compromised build script gets an unprivileged account and a namespace, not your registry keys and your hypervisor.
Afterwards
Registry storage grows without limit unless something removes old tags, so run registry garbage-collect on a weekly timer once you have a retention rule you believe in. If builds become the bottleneck rather than the tests, the comparison page shows what the next size up looks like; more cores help far less than most people expect, and faster ones help far more.