Tools

Running GitLab Locally with OpenTofu

The 2024 hand-run GitLab-on-OrbStack setup, now codified in OpenTofu: GitLab 19, the modern runner token flow, and one `task up`.

Jun 10, 2026 · 8 min read

Photo from Pexels

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 + namespace

task 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:

StageProvidersWhat it does
01-platform/helm, kubernetesNamespace + 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, kubernetesReads 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:

02-gitlab-config/main.tf (runner creation)
# 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:

Concern2024 (chart 7.x, GitLab 16.11)Now (chart 10.x, GitLab 19.1.2)
Redis / PostgreSQL / MinIOBundled by the chartRemoved in chart 10.0, so I run my own lightweight in-cluster ones
IngressOn by defaultglobal.ingress.enabled defaults to false, so set it to true or nothing routes
Runner registrationRegistration token, auto-registersRemoved in 18.0, replaced by a glrt- auth token via gitlab_user_runner
certmanagerinstall: false and doneThe issuer subchart won't template without an email, even when self-signing, so pass a placeholder
Runner helper imageJust workedPin 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:

.gitlab-ci.yml (the docker-socket job)
docker-socket:
  image: docker:cli
  variables:
    DOCKER_TLS_CERTDIR: ""
  script:
    - printenv | grep -i DOCKER | sort
    - docker version

Run 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.

More from the blog

Keep reading - related notes from recent engagements.

DevOps

Advanced GitLab CI features in v19

A tour from job grouping to dynamic child pipelines, and a look at the experimental functions feature, every example run on a real GitLab 19 instance.

Ready to ship faster on AWS?

Tell us what you are building. We will map the fastest safe path to production and the platform to keep it there.

Notes from production

Occasional, no-fluff writing on AWS, DevOps, and running platforms that stay up. No spam, unsubscribe anytime.

Practical, not promotional
Real lessons from real engagements - architecture, automation, and incident post-mortems.
No spam
A few emails a year at most. Your address is never shared or sold.