How I set up GitLab runners for my projects

After configuring a few runners I realized: the question isn't "shell or docker", it's "which host does this runner live on, and what is it allowed to touch".

Which host to place them on

My current principle: a deploy runner lives on the same host it deploys to. That enables DooD — the job container drives the host's docker through a mounted /var/run/docker.sock, no SSH needed at all.

Runners for lint and tests can live on another continent — they don't need the host, just CPU and network.

Which executor to pick

Docker executor — 90% of cases

The job runs in a clean container from an image like image: node:22. Nothing leaks between jobs, clean and predictable.

[[runners]]
  name = "shared-check"
  executor = "docker"
  [runners.docker]
    image = "alpine"
    volumes = ["/cache"]

Docker executor with DooD — for deploys

Add the socket and, for a group runner, the releases directory:

[[runners]]
  name = "deployer"
  executor = "docker"
  [runners.docker]
    image = "docker:28"
    user = "runner"                    # uid:gid — so the job isn't root on the socket
    volumes = [
      "/var/run/docker.sock:/var/run/docker.sock",
      "/opt/apps:/mnt/deploy:ro",      # shared releases dir for all projects
      "/cache"
    ]

What else you'll need:

Shell executor — special cases only

The job runs directly on the host, no isolation. I almost never use it: it's dirty and jobs can see everything. The exception is building something that stubbornly refuses to fit into a container.

How I keep the cache bloat in check

Runners eat disk in three ways: GitLab cache (/cache or S3), buildkit layer cache, and images themselves. My defense system:

  1. GitLab cache — S3 or with a limit. For local /cache on the host disk I set a timer:
    # /etc/systemd/system/gitlab-cache-cleanup.service
    ExecStart=/bin/sh -c "find /cache -maxdepth 3 -type d -mtime +7 -exec rm -rf {} +"
    
    with a daily timer (OnCalendar=daily, Persistent=true).
  2. Build cache — in the build job's after_script: docker builder prune -af --filter until=72h.
  3. Imagesdocker image prune -f there too, plus a weekly docker system prune -af over ssh, when I'm looking at alerts anyway.
  4. Disk alert at 80% — without it, any timer set eventually loses.

One more small thing that saved my nerves: name runner tags by meaning (check, test, build, deploy) and bind jobs to tags in the pipeline — then a deploy never accidentally goes to a runner without the socket, and lint doesn't wait for the deploy machine to free up.