Self-Hosted CI on k3s: Gitea Actions, and the Two Gotchas That Ate My Afternoon
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, sameuses: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:2376Note 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-buildThe 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.yamlNow 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/cacheon a persistent volume — warm runs skipnpm cientirely. - Config changes auto-roll the runner. No more "did it actually apply?"
- A theme pipeline that gates on
gscanand 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.