Twelve builds

A CI runner that builds container images without root

A self-hosted Actions runner on a Ryzen instance, building OCI images rootless with Buildah and pushing them to a private registry on the same machine.

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.com for the runner and registry.example.com for 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 runner

Those 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.com

Bind 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:5000

The 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 nginx

4. 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.yaml

Edit 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/cache

Capacity 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:host

Then 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.target

Substitute 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-runner

5. 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-runner

When 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/app

You 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 --version

Last, 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.

Ready when you are

Pick a city. Pick a size. Pay in coin.

No forms about who you are, no wait for a human to approve you, no phone call to verify anything. The invoice clears and the credentials land in your inbox.