5. The CI runner host#
Note
This chapter is part of the reference documentation of the training-deployment-playcluster repository, and is maintained there.
play4 is deliberately not part of the swarm. It builds container
images and hosts the registry. Keeping it separate means a runaway build cannot
disturb the cluster serving traffic.
It gets its own playbook and its own inventory group. The group standalone
contains only play4, and group_vars/standalone/ holds settings that apply to
nothing else.
Before you run: a runner token#
The runner registers with GitLab using a token that GitLab issues, so create the runner on the GitLab side first. Register it on a group rather than a single project: a group runner takes jobs from every project in the group, so a new project can use the cluster without further setup.
In the group, go to and choose New group runner. (For a single project: , New project runner.)
Under Tags, enter
dockeranddeploy.Create the runner, and copy the token that starts with
glrt-. GitLab shows it once. Ignore thegitlab-runner registercommand on the same page — Ansible runs the equivalent.
Put the token in the vault, in place of the placeholder under
gitlab.runner_tokens.play4:
uv run ansible-vault edit inventory/group_vars/all/vault.yml
Run it#
uv run ansible-playbook playbooks/setup_ci.yml --limit play4.playcluster.plone.org
This run takes longer than the others: installing and registering the GitLab Runner adds several minutes.
--limit standalone does the same, since play4 is the only host in that group.
What it sets up#
Base configuration as before — proxy, packages, hostname, users, SSH, Docker — and then two things specific to this host:
A GitLab Runner, registered against
gitlab.com, running jobs in Docker containers.A container registry (zot) at https://registry.playcluster.plone.org, deployed with Docker Compose rather than as a swarm stack, since this host is not in the swarm. It has its own web UI on port 7443, and its settings live in
inventory/group_vars/standalone/registry.yml.
Several runner tasks show (censored due to no_log) instead of their details.
That is deliberate: they handle the runner token, and the role keeps it out of
the output.
Re-run just the registry when you change it:
uv run ansible-playbook playbooks/setup_ci.yml --limit standalone --tags registry
Where the runner is configured#
inventory/group_vars/standalone/runners.yml. This is the file you edit when
CI behaves oddly, and it is worth reading now rather than when something breaks:
gitlab_runner_coordinator_url: "https://gitlab.com"
gitlab_runner_registration_token_type: "authentication-token"
gitlab_runner_runners:
- name: "play4.playcluster.plone.org"
concurrent_specific: 4
token: "{{ vault.gitlab.runner_tokens.play4 }}"
executor: "docker"
docker_image: "docker:latest"
docker_volumes:
- "/var/run/docker.sock:/var/run/docker.sock"
- "/builds:/builds"
- "/cache"
extra_configs:
runners.docker:
privileged: true
Four lines deserve attention.
token comes from the encrypted vault, not from this file. It is the
runner's authentication token, and anyone holding it can pose as this runner
and receive your CI jobs — including their CI/CD variables.
concurrent_specific: 4 caps parallel jobs on this runner. Too low and
pipeline stages queue; too high and builds fight over CPU. Match it to the
host's core count.
docker_volumes mounts the Docker socket. This is the single most
consequential line here. It lets CI jobs use the host's Docker daemon directly,
which makes image builds fast and keeps the layer cache warm between jobs.
It also has two consequences worth stating plainly:
A job that starts a
docker:dindservice will find that service unable to create its own socket — it fails withdevice or resource busyafter a 30 second timeout, while the build quietly proceeds against the host daemon anyway. Pipelines on this runner should not usedind.A CI job can control every container on this host. That is acceptable because the host is dedicated to CI and is outside the swarm. It would not be acceptable on a cluster node.
privileged: true is required by some build tooling. With the socket
already mounted it adds little further exposure, but both are reasons this host
stays separate.
Verify#
On play4:
systemctl status gitlab-runner
grep -E '^(concurrent|check_interval) =' /etc/gitlab-runner/config.toml
concurrent should match the vCPU count. Then confirm GitLab agrees the runner
is online, under in the group — a runner can
be healthy locally and still not be registered.
Warning
gitlab-runner list prints the runner's authentication token in full. Do not
run it while sharing your screen, and do not paste its output anywhere.
Check the registry#
From your own machine:
curl -s -o /dev/null -w '%{http_code}\n' https://registry.playcluster.plone.org/v2/
401: TLS works and anonymous access is refused. Use a GET, as here: zot answers
a HEAD request (curl -I) on /v2/ with 405. The registry has three
entrances on the same host name:
https://registry.playcluster.plone.org/v2/The registry API that
dockeruses, on port 443. Only/v2/is served here.https://registry.playcluster.plone.org:7443/zot's web UI. Sign in with the
ciordeployaccount.https://registry.playcluster.plone.org:8443/dashboard/The dashboard of the Traefik in front of the registry, with the same credentials as the cluster's Traefik dashboard.
The two accounts have different rights, and it is worth proving that once. Log
in as ci and push a throwaway image:
docker login registry.playcluster.plone.org -u ci
docker pull alpine:3 && docker tag alpine:3 registry.playcluster.plone.org/probe/alpine:test && docker push registry.playcluster.plone.org/probe/alpine:test
Then log in as deploy. Pulling works; pushing must fail with requested access
to the resource is denied:
docker login registry.playcluster.plone.org -u deploy
docker pull registry.playcluster.plone.org/probe/alpine:test && docker push registry.playcluster.plone.org/probe/alpine:test
The swarm pulls with deploy and stores those credentials on every node, which
is why that account must not be able to push.
Running the registry from here on — its layout, the web UI and dashboard ports, and the retention policy that is still in dry-run mode — is covered in chapter 7.
You are done#
The cluster runs, Traefik is routing, and CI has somewhere to build. One step remains on the GitLab side: chapter 6 sets the CI/CD variables that let a pipeline push to this registry and deploy to this swarm.
The other half is a cookieplone Plone 6 project with a GitLab pipeline that lints, tests, builds images into this registry, and deploys a stack onto this swarm.
Two things from these chapters carry directly into it — the Traefik discovery settings
from chapter 3 (constraint-label, nw-public, the le resolver, the entrypoint
names), and the socket-mounted runner from this chapter, which decides how the
build jobs must be written.