Free CI on Hardware You Already Own: Gitea Actions on a NAS, Set Up by an AI Agent

GitHub Actions on a private repo eventually sends you an invoice. Ours arrived as a polite refusal: "recent account payments have failed or your spending limit needs to be increased." The workflows were fine. The tests were fine. The billing state was not, and we had already decided we weren't going to pay to run tests on code that lives on our own hardware anyway.

The same hardware — a QNAP NAS in a closet — was already running Gitea as the primary git remote. Gitea has had its own Actions runner system for a while now, and the underrated headline is this: it reads .github/workflows/ natively, GitHub syntax and all. No rewrite. The same YAML that GitHub refused to run for free, Gitea runs on your own box.

This post is the recipe. The next two posts in this series are about what happened when the runner actually started executing our test suite (13 runs, 13 real bugs) and the review process that kept the fixes honest (the reviewer who refutes).

One more thing that makes this writeup a little unusual: an AI agent did nearly all of it — the API calls, the SSH surgery on the runner, the workflow tuning, the 20-odd commits. The human contributions were two password prompts and a handful of decisions. I'll mark clearly which was which, because the split turned out to be instructive.

What you need

  • A NAS or any always-on box that can run Docker (ours is a QNAP running Container Station; a Synology, a Pi 5, or an old laptop all work).
  • Gitea ≥ 1.21 hosted on it (or anywhere on your LAN), with admin access.
  • A repo with GitHub-style workflows in .github/workflows/.
  • If your CI installs packages from a private registry (ours pulls from GitHub Packages): a token with read access to it.

Step 1: Enable Actions and get a runner registration token

Actions is on by default in recent Gitea ([actions] ENABLED = true in app.ini for older versions). Everything else is scriptable through the API — this is the part the agent did with a plain token, no UI clicking:

API="https://gitea.example.internal/api/v1"
AUTH="Authorization: token $GITEA_API_KEY"

# turn Actions on for the repo
curl -X PATCH -H "$AUTH" -H 'Content-Type: application/json' \
  -d '{"has_actions": true}' "$API/repos/you/your-repo"

# get a runner registration token (instance-wide)
curl -H "$AUTH" "$API/admin/runners/registration-token"

The one genuinely human step in our setup: the API token lived in the macOS Keychain, which refuses non-interactive access. The agent wrote a script; the human ran it in a real terminal and typed a password once. Everything downstream was automated.

Step 2: Run act_runner in Docker

Gitea's runner is act_runner. On the NAS:

docker run -d --restart always --name gitea-runner \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v gitea-runner-data:/data \
  -e GITEA_INSTANCE_URL=https://gitea.example.internal \
  -e GITEA_RUNNER_REGISTRATION_TOKEN=<token from step 1> \
  docker.io/gitea/act_runner:latest

The docker socket mount matters: jobs run in sibling containers, so the runner container itself stays tiny and your jobs get clean environments.

Why is my runner online but no jobs start?

This is the gotcha that eats the first hour, so it gets its own heading. Gitea assigns jobs to runners strictly by label. Your workflow says runs-on: ubuntu-latest; if the runner registered with only a custom label like self-hosted, every job queues forever while the runner sits there, online and smug.

We hit exactly this — a runner registered months earlier with GITEA_RUNNER_LABELS=self-hosted for a different repo. The fix is a label that maps ubuntu-latest to a docker image. Labels live in the runner's /data/.runner file:

/data/.runner (inside the runner container)
"labels": [
  "self-hosted:host",
  "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
]

The agent did this over SSH: backed the file up first, added the new label instead of replacing the old one (the self-hosted label was serving another repo — additive edits to shared infrastructure, always), restarted the container, and watched the log:

runner: runner, with version: v0.2.11, with labels: [self-hosted ubuntu-latest], declare successfully
task 217 repo is you/your-repo ...

It picked up the queued backlog within one second of restarting.

Step 3: Secrets — the GITHUB_TOKEN trap

If your workflow installs from GitHub Packages (or any external private registry), there's a subtle failure baked into the GitHub-compatible syntax: secrets.GITHUB_TOKEN exists on Gitea — but it's a Gitea token. It authenticates to your Gitea, not to npm.pkg.github.com. Installs fail with authentication errors that look like registry flakiness.

The fix: a real PAT with read:packages, stored as a repo secret (again, pure API):

curl -X PUT -H "$AUTH" -H 'Content-Type: application/json' \
  -d '{"data":"<github PAT>"}' \
  "$API/repos/you/your-repo/actions/secrets/GH_PACKAGES_TOKEN"

…and the workflow's npm-auth step references secrets.GH_PACKAGES_TOKEN instead of secrets.GITHUB_TOKEN. (If GitHub-side CI ever comes back, add the same secret there — that's the whole migration cost.)

Step 4: Service containers work too

Our queue package has integration tests that need a real Redis. GitHub-style services: blocks work in act_runner's docker mode — the service is reachable by its service name:

.github/workflows/ci.yml
jobs:
  test:
    runs-on: ubuntu-latest
    services:
      redis:
        image: redis:7-alpine
    steps:
      # ...
      - name: Run tests
        run: pnpm -r run test
        env:
          REDIS_HOST: redis

This mattered more than we expected — tests that were gated behind "skip unless Redis is available" had never run anywhere, and the moment CI gave them a real Redis, one of them caught a real bug in shipped source. That story is in part 2.

Step 5: Respect the hardware — scope what runs

A NAS is not a 64-core build farm. Three adjustments made CI pleasant instead of punishing on a 64-project pnpm monorepo:

Docs-only pushes skip CI entirely:

on:
  push:
    branches: [main]
    paths-ignore:
      - '**/*.md'
      - 'docs/**'

Code pushes test only what changed — pnpm can select "packages changed since a commit, plus everything that depends on them":

pnpm --filter "...[${{ github.event.before }}]" run test

Two traps we hit so you don't have to. First, this needs fetch-depth: 0 on checkout, and a fallback to a full run when there's no usable base commit (force-pushes, first push) or when root-level shared files changed. Second — and this one is sneaky — examples or apps that depend on workspace packages via the file: protocol break on cold CI checkouts: pnpm copies file: dependencies into node_modules at install time, before anything is built, so the copy has no build output. Locally it works forever because your node_modules copies date from a lucky install. Use workspace:* instead; it symlinks the real package directory, so the CI build step is visible through the link.

Full runs test the surface you actually ship. Our repo carries ten zero-consumer packages that are explicitly deferred; their 5,000-odd tests were most of the full-run wall time. Full runs now test the packages that real consumers use; the deferred ones still test automatically the moment a commit touches them. That's the honest version of "skip the unused stuff" — nothing is hidden, it's just gated to relevance.

Step 6: Two ordering rules for cold checkouts

Both of these pass locally forever and fail only in CI, because your dev machine has warm build output and CI doesn't:

  1. Build before type-check. Anything that type-checks against a workspace dependency's dist/ types needs that dist to exist. Locally it always does. On a fresh checkout it doesn't, until the build step runs.
  2. Memory is a real constraint. Type-aware ESLint loads entire TypeScript programs; several lint processes in parallel on a RAM-limited box ended in V8 heap aborts. pnpm --workspace-concurrency=1 for the lint step, plus NODE_OPTIONS: --max-old-space-size=4096, and it fits.

What the agent could and couldn't do

Worth being precise about, since "an AI set up my CI" invites skepticism:

Agent, unassisted: all Gitea API operations (enable Actions, registration tokens, secrets), the SSH runner surgery once a key was authorized, every workflow edit, every diagnostic log pull through the Actions API, and all 20+ commits.

Human, irreducibly: typing the Keychain password once, running ssh-copy-id once, and the judgment calls — which packages count as sellable surface, whether a heavy test should get a longer timeout or become a cheaper test (the human was right: cheaper), and "this is taking too long" when the iteration loop needed cutting.

That split — agent does the mechanism, human does the policy — is roughly where this kind of ops work has landed for us, and it's a good deal for both sides.

The payoff

The runner now executes the same workflows GitHub wouldn't, on hardware that was already paid for and already on. Tag pushes publish packages end-to-end (proven three times before we trusted it). And the first real full-suite run found more genuine bugs than any single code review in the repo's history — which is the story part 2 tells run by run.