4 min read @leo

Self-Hosted CI on k3s: Gitea Actions, and the Two Gotchas That Ate My Afternoon

Wiring up self-hosted CI/CD on a k3s homelab with Gitea Actions — plus the two unglamorous bugs (an ancient runner image and a config that wouldn't reload) that turned an hour into an afternoon.
Gitea Actions logo — self-hosted CI on k3s

I run a small Kubernetes homelab — k3s, Flux for GitOps, Vault for secrets, a self-hosted Gitea behind a Cloudflare Tunnel. The one piece missing was CI/CD. I wanted pushes to my own Git server to build, test, and ship — without reaching out to GitHub Actions or standing up yet another SaaS.

This is the story of wiring that up with Gitea Actions, including the two gotchas that turned a one-hour job into an afternoon. The blog post you're reading was deployed by the very pipeline it describes.

Why Gitea Actions over Drone

The two obvious options for self-hosted CI next to Gitea are Drone and Gitea's own Actions. I went with Gitea Actions:

  • Built in. No separate service, database, or OAuth dance. The runner registers against Gitea and you're done.
  • GitHub Actions syntax. Same .gitea/workflows/*.yaml, same uses: actions, same ecosystem. Anything I learn transfers.

The architecture

A Gitea Actions runner (act_runner) doesn't run jobs itself — it spawns a Docker container per job. In Kubernetes that means the runner needs a Docker daemon, so the pod carries a privileged docker-in-docker sidecar:

┌──────────────── Pod (ns: gitea) ──────────────────┐
│  act_runner  ──registers──▶  Gitea                │
│      │                                            │
│      │ DOCKER_HOST=tcp://localhost:2376           │
│      ▼                                            │
│  dind (privileged)  ──spawns──▶  job containers   │
└───────────────────────────────────────────────────┘

Everything is Flux-managed. The registration token comes from Vault via External Secrets, and the runner's identity persists on a PVC so it keeps its registration across restarts. Enabling Actions on the Gitea side is one line of Helm values:

gitea:
  config:
    actions:
      ENABLED: "true"

The runner Deployment, trimmed to the essentials:

spec:
  containers:
    # docker-in-docker daemon — job containers spawn here
    - name: dind
      image: docker:27-dind
      securityContext:
        privileged: true
      env:
        - name: DOCKER_TLS_CERTDIR
          value: /certs
    # the runner
    - name: runner
      image: gitea/act_runner:0.6.1
      env:
        - name: GITEA_INSTANCE_URL
          value: http://gitea-http.gitea.svc.cluster.local:3000
        - name: GITEA_RUNNER_REGISTRATION_TOKEN
          valueFrom:
            secretKeyRef: { name: act-runner-secret, key: token }
        - name: DOCKER_HOST
          value: tcp://localhost:2376

Note the image tag — 0.6.1. Remember that. It matters later.

A real pipeline: deploying this theme

My first pipeline deploys the Ghost theme this blog runs on. The job validates the theme with gscan (Ghost's official linter), builds the zip, then uploads and activates it through the Ghost Admin API. The guiding principle: CI builds and validates; Flux deploys. A CI job should produce an artifact, not run kubectl apply.

name: theme-ci
on:
  push: { branches: [master] }

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Cache node_modules
        id: nm
        uses: actions/cache@v3
        with:
          path: node_modules
          key: nodemodules-${{ hashFiles('package-lock.json') }}
      - name: Install deps
        if: steps.nm.outputs.cache-hit != 'true'
        run: npm ci
      - name: gscan (Ghost 6 fatal gate)
        run: npx --yes gscan@latest . --v6 --fatal
      - name: Build + zip
        run: npm run zip
      - uses: actions/upload-artifact@v3
        with: { name: dawn-theme, path: dist/dawn.zip }

  deploy:
    needs: test
    if: github.ref == 'refs/heads/master'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/download-artifact@v3
        with: { name: dawn-theme, path: dist }
      - name: Deploy to Ghost (no rebuild)
        env:
          GHOST_ADMIN_API_KEY: ${{ secrets.GHOST_ADMIN_API_KEY }}
        run: bash deploy-theme.sh --no-build

The deploy job downloads the exact artifact test validated — it never rebuilds, so what ships is byte-for-byte what passed the gate.

And then the pipeline hung. Twice. Here are the two gotchas, both worth the afternoon.

Gotcha #1: actions/cache hung for minutes

⚠️ Symptom: the cache step stalled for 3+ minutes and blocked the whole run. The job never failed — it just sat there.

My first instinct was a networking problem — the runner's built-in cache server living somewhere the job container couldn't reach. So I checked. From a job container, the cache server's port was reachable over both host and bridge networking. Reachability was fine.

So I did the wrong thing: I built a workaround. A persistent /npmcache volume bind-mounted into every job container, npm pointed at it directly. It worked — npm installs went from ~250s to ~70s. But it only cached npm. Not node_modules, not pip, not Go build output. It wasn't caching; it was a special case.

Then I actually read the version I'd pinned: act_runner:0.2.13. The current release line is 0.6.x. I'd grabbed an ancient tag on day one and never questioned it. On 0.2.13 the cache server simply doesn't wire up correctly. I bumped the image to 0.6.1 and the standard actions/cache — for any path, keyed however you like — just worked. The /npmcache hack came back out.

💡 Lesson: before you debug the behavior of a tool, confirm you're running a current version of it. I spent an hour reverse-engineering a bug that a one-line image bump erased.

Gotcha #2: editing the config did nothing

⚠️ Symptom: I edited the runner's ConfigMap, Flux applied it cleanly, and the runner kept behaving exactly as before.

act_runner reads its config file once, at startup. A ConfigMap change doesn't touch the Deployment's pod template, so Kubernetes sees no reason to restart anything — and the running process keeps its stale in-memory config. The config was updated in the cluster and ignored by the only thing that reads it.

The clean GitOps fix is a kustomize configMapGenerator. It appends a content hash to the ConfigMap name and rewrites every reference to match:

configMapGenerator:
  - name: act-runner-config
    files:
      - config.yaml=files/act-runner-config.yaml

Now editing the config changes its hash (act-runner-config-t8chm5hkfh…-54ktmfkk4t), which changes the Deployment's volume reference, which changes the pod template — so Flux rolls the pod automatically. Config and process can no longer drift apart.

Where it landed

  • Self-hosted CI on k3s, GitHub-compatible workflows, zero external dependencies.
  • General-purpose actions/cache on a persistent volume — warm runs skip npm ci entirely.
  • Config changes auto-roll the runner. No more "did it actually apply?"
  • A theme pipeline that gates on gscan and ships through the Ghost Admin API — the same one that published this post.

Two gotchas, both mundane in hindsight: run a current version, and make your config changes observable to the process that consumes them. The unglamorous lessons are usually the ones that save the next afternoon.