Introduction
Two years ago I wrote Running GitLab Locally in Kubernetes, where I deployed GitLab on OrbStack's Kubernetes with the Helm chart and wired a runner so CI jobs could run docker against the host daemon. That post still works as a walkthrough, but it was all done by hand: a sequence of helm upgrade, kubectl, security add-trusted-cert, and a fair amount of clicking through the UI to create a token and a project. I wanted to have this fully automated.
Two things have moved on since then. GitLab is now on 19.x (the old post ran 16.11), and the runner registration-token flow the post relied on is gone: disabled in 17.0, removed in 18.0. So I did what I should have done the first time already: I codified the whole thing in OpenTofu and a Taskfile. One task up brings up GitLab, the runner, and fully seeds the instance, one task down destroys it.
This post is the successor. Same OrbStack + Helm idea, now reproducible, and with the runner part rewritten for how GitLab actually works today.
The problem
The 2024 setup had two weaknesses I kept bumping into.
First, it wasn't reproducible. Standing it up meant running the right commands in the right order, and tearing it down meant remembering to delete the PVCs by hand, or the next run would surprise me with stale data. And I am not a friend of click-ops. There was no pinned, one-command way to get the same instance every time.
Second, it broke on modern GitLab. The chart's bundled runner used to auto-register with a registration token, and that's the mechanism the old post leaned on. On GitLab 19.x, that path returns 410 Gone, because the registration-token flow was removed in 18.0. On top of that, the chart itself changed a lot between the 7.x I used in 2024 and today's 10.x: the bundled Redis, PostgreSQL, and MinIO are gone, and the ingress is off by default. A copy-pasting of the 2024 values file gets you a GitLab that never routes and a runner that can't register.
So the goal was: pin versions, task up / task down, and a runner that works end to end: a job running docker version against the OrbStack daemon, all codified.
The solution
The setup is two OpenTofu root modules driven by a Taskfile. From the operator's side, there are four commands:
cd code/gitlab-orbstack-tofu
task up # stage 1 (Helm) then stage 2 (config + runner). ~5-10 min first boot.
task trust # add the self-signed CA to the macOS keychain (browser only)
task creds # print URL + root password + demo-user token
# ...open https://gitlab.k8s.orb.local, run the docker-socket pipeline...
task down # destroy everything, incl. PVCs + namespacetask up generates a local-only .env.secrets (gitignored) for the seed admin token and the demo-user password, auto-detects the OrbStack Docker socket, and needs no manual editing.
Why two stages
The gitlab OpenTofu provider needs a live instance and an admin token before it can configure anything, and a provider block can't cleanly depend on a resource created in the same apply. That's the problem, and it's why the setup splits in two:
| Stage | Providers | What it does |
|---|---|---|
01-platform/ | helm, kubernetes | Namespace + the GitLab Helm release (chart 10.1.2 -> GitLab CE 19.1.2), then mints a seed root token by exec-ing into the toolbox pod. |
02-gitlab-config/ | gitlab, helm, kubernetes | Reads stage 1's outputs, then creates the group, a demo user, two projects with working CI, CI/CD variables, and the runner. |
Stage 1's token is the difficult step. The provider can't bootstrap its own first token (as gitlab_personal_access_token needs admin privileges it doesn't have yet), so I create it inside GitLab itself in a hacky way with gitlab-rails runner in the toolbox pod. I use kubectl exec rather than a Kubernetes Job on purpose: Job specs are largely immutable, so a failed first run would block the next tofu apply. Exec has no such resource to conflict on, which makes re-running task up after a partially finished boot safe.
The runner: the part that actually changed
This is where the 2024 post breaks. The GitLab chart's bundled runner is documented as working "out of the box", but only via the legacy registration-token workflow,
which is disabled in 17.0 and removed in 18.0. On 19.1.2, that auto-registration returns 410 Gone, and the current minimum-values baseline even ships gitlab-runner.install: false.
The modern flow is token-first. You create the runner through the API, which hands you a glrt-... authentication token, then you point a runner at GitLab with that token. In OpenTofu:
# Create the runner via the modern authentication-token flow.
resource "gitlab_user_runner" "docker_socket" {
runner_type = "instance_type"
description = "OrbStack docker-socket runner"
tag_list = ["docker", "orbstack"]
untagged = true
}
# A standalone gitlab-runner Helm release wired with that token and the
# OrbStack Docker-socket host_path mount, so jobs can run `docker`.
resource "helm_release" "gitlab_runner" {
name = "gitlab-runner"
namespace = var.namespace
repository = "https://charts.gitlab.io/"
chart = "gitlab-runner"
version = var.runner_chart_version # 0.90.1 -> Runner 19.1.1
values = [yamlencode({
gitlabUrl = var.runner_gitlab_url
rbac = { create = true }
runners = {
privileged = true
config = <<-EOT
[[runners]]
environment = ["GIT_SSL_NO_VERIFY=true"]
[runners.kubernetes]
image = "alpine:3"
helper_image = "${var.runner_helper_image}"
privileged = true
[runners.kubernetes.volumes]
[[runners.kubernetes.volumes.host_path]]
name = "docker-socket"
path = "${var.docker_socket_path}"
mount_path = "/var/run/docker.sock"
read_only = false
EOT
}
})]
set_sensitive {
name = "runnerToken"
value = gitlab_user_runner.docker_socket.token
}
}The host_path volume is the same trick as 2024: mount ~/.orbstack/run/docker.sock into every job container, just expressed as code and fed the socket path as a variable, because that path differs per machine.
Two details differ on Apple Silicon: the runner defaults to the x86_64- helper image (which has no arm64 manifest), so I pin the arm64 variant,
and jobs clone over the self-signed https://gitlab.k8s.orb.local, so the runner sets GIT_SSL_NO_VERIFY=true, which is fine for a throwaway local instance and for the demo project.
Once stage 2 applies, the runner shows up online, tagged the way I wanted:
What GitLab 19 needs
Chart 10.x is a slightly big change from the 7.x of the old post, and getting a minimal local instance to actually boot meant handling a few things the chart used to do for me:
| Concern | 2024 (chart 7.x, GitLab 16.11) | Now (chart 10.x, GitLab 19.1.2) |
|---|---|---|
| Redis / PostgreSQL / MinIO | Bundled by the chart | Removed in chart 10.0, so I run my own lightweight in-cluster ones |
| Ingress | On by default | global.ingress.enabled defaults to false, so set it to true or nothing routes |
| Runner registration | Registration token, auto-registers | Removed in 18.0, replaced by a glrt- auth token via gitlab_user_runner |
| certmanager | install: false and done | The issuer subchart won't template without an email, even when self-signing, so pass a placeholder |
| Runner helper image | Just worked | Pin the arm64 image on Apple Silicon |
If you want a minimal setup, GitLab 18.11 (chart 9.11.x) still bundles Redis, PostgreSQL, and MinIO and needs none of the above, so swap the chart version and drop the extra services. I went with 19.1.2 because I wanted the current release.
Proving the socket works
The whole reason to mount the socket is to let a CI job talk to the host daemon. The docker-socket-demo project has a two-line proof:
docker-socket:
image: docker:cli
variables:
DOCKER_TLS_CERTDIR: ""
script:
- printenv | grep -i DOCKER | sort
- docker versionRun it, and the job container's Docker client reports a Server: Docker Engine - Community, and that server is the host's OrbStack daemon, reached straight through the mounted socket:
And none of the surrounding scaffolding was clicked by hand. The group, the demo user, both projects, and the CI/CD variables all come from stage 2:
The second project, build-test-demo, carries a conventional two-stage pipeline next to the socket demo, so the example shows a normal CI flow as well as the Docker trick:
One security note I want to be explicit about: the runner is privileged and mounts the host Docker socket, so a job can do anything the host daemon can. That's fine for a local, throwaway instance and nowhere else. Never mount the host socket into a runner on a shared or production cluster.
Conclusion
The idea hasn't changed since 2024: GitLab on OrbStack via Helm, with a runner that reaches the host Docker daemon. What changed is that it's now a pinned, two-stage OpenTofu setup you bring up and tear down with a single command, and the runner uses the authentication-token flow that replaced the registration tokens GitLab removed along the way. So I can reuse the setup for more examples and tests without having to click through the UI or remember the right sequence of commands.
The two-stage split, the seed-token bootstrap, and the runner rewrite took the most work to get right. The rest is just OrbStack being really pleasant to work with.
I hope this helps you get a real GitLab with working CI running locally without the click-ops. Happy Gitlabbing!
This post was written with AI assistance and verified plus enhanced by a human.



