Tools

Running Gitea Locally with OpenTofu

A pinned Gitea 1.27 on OrbStack's Kubernetes, provisioned end to end with OpenTofu: Gateway API instead of Ingress, and a CI runner that reaches the host Docker daemon without Docker-in-Docker.

Sep 28, 2026 · 8 min read · 1,648 words

Photo from pexels

Introduction

A few months ago, I wrote Running GitLab Locally with OpenTofu: a pinned GitLab instance on OrbStack's Kubernetes, brought up and torn down with task up / task down, with a runner that reaches the host Docker daemon through a mounted socket instead of Docker-in-Docker. I wanted the same thing for Gitea, both because it's the CI system I reach for on smaller projects and because I was curious how differently a much lighter Git server would need to be wired up.

Turns out: quite differently. Gitea's chart ships no ingress controller at all, its Actions runner has completely different registration mechanics, and the whole thing boots in under two minutes instead of GitLab's five to ten. Same two-stage OpenTofu shape as the GitLab example, same Docker-socket trick, but almost none of the plumbing in between is the same.

The problem

I wanted the same setup as GitLab, just for Gitea: one task up, one task down, a demo user I actually log in as instead of root, and a runner I can prove is hitting the real host daemon. Getting there took three fights.

First, Gitea's Helm chart bundles no ingress path of its own. GitLab's chart ships nginx-ingress and cert-manager, so getting HTTPS routing was a couple of set{} overrides. Gitea's chart only consumes a Gateway API Gateway if you already have one, via gatewayAPI.core.httpRoute. Classic Ingress is deprecated upstream in favor of Gateway API anyway, so I wanted to do it properly rather than reach for ingress-nginx and pretend I hadn't noticed the deprecation notice.

Second, the chart's database and cache default to HA. postgresql-ha.enabled and valkey-cluster.enabled are both true out of the box, which is a strange default for a "make it minimal for local dev" starting point, but here we are. Copy-pasting the chart's own defaults would spin up a multi-pod HA Postgres cluster to serve one laptop.

Third, the official Actions runner chart hard-wires Docker-in-Docker. gitea/helm-actions's values.yaml bakes a DinD sidecar into every runner pod, with no toggle to turn it off. Gitea's own Kubernetes runner docs name "pointing the basic flavour at a daemon outside the cluster" as the documented, non-privileged alternative, so that's what I wanted: the plain runner image, no sidecar, talking straight to OrbStack's socket.

The solution

Same shape as the GitLab example, two OpenTofu root modules driven by a Taskfile:

cd code/gitea-orbstack-tofu
 
task up        # stage 1 (platform) then stage 2 (config + runner). ~2 min first boot.
task trust     # add the self-signed CA to the macOS keychain (browser only)
task creds     # print URL + admin/demo credentials
 
# ...open https://gitea.k8s.orb.local, log in as the demo user...
 
task down      # destroy everything, incl. PVCs + both namespaces
StageProvidersWhat it does
01-platform/helm, kubernetes, tlsNamespace, Gateway API CRDs + NGINX Gateway Fabric + a Gateway resource, a self-signed TLS secret, the pinned Gitea Helm release (chart 12.7.0 -> Gitea 1.27.0, single-instance Postgres/Valkey), then mints an admin API token.
02-gitea-config/gitea, helm, kubernetesReads stage 1's outputs, creates the org, a non-root demo user (added via a team - Gitea has no direct org-membership resource), two seeded repositories, and the host-socket-mounted runner.

Same reason for the split as GitLab: the gitea provider needs a live instance and an admin token before it can configure anything, and a provider block can't depend on a resource created in the same apply.

Gateway API instead of Ingress

Stage 1 installs the whole stack itself, since the chart provides none of it:

01-platform/gateway-api.tf (the Gateway resource)
resource "terraform_data" "gitea_gateway" {
  depends_on = [
    helm_release.nginx_gateway_fabric,
    kubernetes_secret.gitea_tls,
  ]
 
  provisioner "local-exec" {
    command = <<-EOT
      cat <<MANIFEST | kubectl --context "$KUBECONTEXT" apply -f -
      apiVersion: gateway.networking.k8s.io/v1
      kind: Gateway
      metadata:
        name: gitea
        namespace: $NS
      spec:
        gatewayClassName: nginx
        listeners:
          - name: https-gitea
            protocol: HTTPS
            port: 443
            hostname: $HOSTNAME_VALUE
            tls:
              mode: Terminate
              certificateRefs:
                - name: $TLS_SECRET
      MANIFEST
    EOT
  }
}

That gets me the CRDs, NGINX Gateway Fabric (v2.7.2, the maintained successor to ingress-nginx), and the Gateway itself. I applied the Gateway with kubectl instead of a kubernetes_manifest resource because kubernetes_manifest checks its schema against the live CRD at tofu plan time, and that check fails when the CRD gets installed in the same apply. kubectl sidesteps the whole problem, the same trick the GitLab example already leans on for its own ordering headaches.

The chart's values then point at that Gateway:

01-platform/gitea-values.yaml
postgresql:
  enabled: true
postgresql-ha:
  enabled: false   # chart default is true
valkey:
  enabled: true
valkey-cluster:
  enabled: false    # chart default is true
 
ingress:
  enabled: false
 
gatewayAPI:
  enabled: true
  core:
    httpRoute:
      enabled: true
      tls: true
      parentRefs:
        - group: gateway.networking.k8s.io
          kind: Gateway
          name: gitea
          sectionName: https-gitea

All four HA flags get set explicitly, even the two that already match Gitea's non-HA default, so a future chart bump can't silently re-enable HA underneath me without the diff showing up.

The runner: basic image, host socket, no sidecar

Since gitea/helm-actions can't drop its DinD sidecar, the runner here is a plain kubernetes_deployment running the official docker.gitea.com/runner:3.0.2 image directly, with the OrbStack socket bind-mounted in:

02-gitea-config/runner.tf (the runner Deployment)
resource "kubernetes_deployment" "runner" {
  spec {
    template {
      spec {
        container {
          image = "docker.gitea.com/runner:3.0.2"
 
          env {
            name  = "GITEA_RUNNER_REGISTRATION_TOKEN"
            value_from {
              secret_key_ref {
                name = kubernetes_secret.runner_registration.metadata[0].name
                key  = "token"
              }
            }
          }
          env {
            name  = "GITEA_RUNNER_LABELS"
            value = var.runner_label
          }
 
          volume_mount {
            name       = "docker-socket"
            mount_path = "/var/run/docker.sock"
          }
        }
 
        volume {
          name = "docker-socket"
          host_path {
            path = var.docker_socket_path
            type = "Socket"
          }
        }
      }
    }
  }
}

No privileged: true needed. The registration token itself comes from an endpoint the go-gitea/gitea Terraform provider doesn't expose (POST /orgs/{org}/actions/runners/registration-token), so stage 2 mints it the same out-of-band way stage 1 mints its admin token: a local-exec script writes the value to a gitignored local file, and a local_file data source reads it back into state, since the API only ever returns the token once, at creation.

Two TLS checks, not one

This is the part that cost me the most time. act_runner's own Go HTTP client happily accepts runner.insecure: true for the self-signed cert, set through a mounted ConfigMap since there's no environment variable for it. I set that, redeployed, and the runner registered fine. Then the first pipeline that used actions/upload-artifact failed with x509: certificate signed by unknown authority anyway.

Turns out the JS-based actions run in their own node subprocess, with its own independent TLS trust store that has never heard of runner.insecure. The fix is a second, completely separate opt-out:

.gitea/workflows/build-test.yml
jobs:
  build:
    runs-on: docker-socket
    container:
      image: node:20-bookworm-slim
    env:
      NODE_TLS_REJECT_UNAUTHORIZED: '0'

Both are local-only hacks for a self-signed cert. I wouldn't carry either into a real deployment.

While debugging that same pipeline, I also hit GHESNotSupportedError: @actions/artifact v2.0.0+, upload-artifact@v4+ and download-artifact@v4+ are not currently supported on GHES. The v4 artifact client refuses to run against anything that isn't literally github.com; Gitea's artifact API implements the older v3-compatible protocol. Pinning both actions to @v3 fixed it in one line each.

Proving the socket works

Same proof as the GitLab post: a workflow whose only job is to run docker version against the mounted socket.

.gitea/workflows/docker-socket.yml
jobs:
  docker-socket:
    runs-on: docker-socket
    container:
      image: docker:cli
    steps:
      - run: docker version

The runner comes online right after task up finishes:

And the job container's docker version reports the same Engine version as docker --context orbstack version on the host - no nested daemon, no DinD, straight through to the real one:

None of the surrounding scaffolding was clicked by hand either. The org, the demo user, both seeded repositories, and their workflows all come from stage 2:

And a conventional two-job build-then-test pipeline runs next to the socket demo, passing an artifact between jobs, so the example covers a normal CI flow as well as the Docker trick:

A couple of provider quirks worth naming

The go-gitea/gitea Terraform provider is younger than GitLab's, and it shows up in two small, perpetually drifting spots. A team's permission field always reads back as "none" once you're using granular per-unit permissions (the actual source of truth), so every tofu plan wants to "fix" it back to "write" forever. And migration_mirror_interval on a plain repository just never persists, since it's really a migration-type-repo setting. Both get a lifecycle { ignore_changes = [...] } rather than a fight I can't win - the real access underneath is unaffected either way.

Conclusion

I expected this to be a quick port of the GitLab setup, swap a few resource names and be done. It wasn't, and that was the interesting part: Gitea's leaner chart means I had to build the ingress path myself instead of flipping a switch, the runner's registration and TLS model needed its own set of fixes, and a whole class of GitLab problems (the removed registration-token flow, the missing bundled data services) doesn't exist here because Gitea never had them or still bundles what it needs by default.

The shape carried over cleanly, though: two OpenTofu stages for the same provider-needs-a-live-instance reason, a local-exec + local_file pattern for pulling a one-time API token into state, and the host-socket mount for reaching the real Docker daemon. I'll reach for that same shape again next time I build one.

Both examples now live side by side as code/gitlab-orbstack-tofu/ and code/gitea-orbstack-tofu/, comparable enough that the differences above are basically the whole story of what's genuinely different between the two platforms once you actually automate them. I hope this saves you the couple of hours I spent chasing two separate TLS trust stores. Happy self-hosting!

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.

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.