Gitea Actions: Self-Hosted CI That Boots in Seconds

GitHub Actions syntax, your hardware, no queue and no minutes bill

Contents

The thing that finally pushed me off hosted CI for homelab projects was not the price and not privacy, though both matter. It was the waiting. Every push, I would watch a hosted runner spend thirty to sixty seconds provisioning a fresh virtual machine, cloning my tiny repository, and installing a toolchain, all before a single line of my actual build ran. For a real feedback loop on a small project, most of the wall-clock time was the CI platform getting out of bed. Gitea Actions runs the same workflow syntax on a runner that is already awake on my own hardware, and the job starts more or less the instant I push.

Gitea Actions arrived as a stable feature in Gitea 1.20 and has matured steadily since. It is deliberately compatible with GitHub Actions: the same .gitea/workflows/*.yaml (or .github/workflows) files, the same uses: / run: / steps: structure, and a large fraction of the marketplace actions work unchanged. That compatibility is the whole reason it is worth using instead of an older self-hosted CI with its own bespoke config language — you get to reuse everything you already know about Actions, on a server you control.

Why self-host CI at all

Advertisement

The obvious reasons hold up. You are not billed by the minute, so a churny project that would burn through a free tier costs you nothing but electricity. Your source never leaves your network, which matters for anything with secrets or private data baked into the build. And you are not subject to someone else’s queue when their free runners are congested.

The reason that surprised me is latency, and it is structural rather than incidental. A hosted runner is ephemeral by design — it must be created clean for each job, which is genuinely the right security model for a service running strangers’ code, and it costs you a cold start every single time. A self-hosted act_runner is a long-lived process sitting on a warm machine with a warm Docker image cache and your dependencies already pulled. The clone is a LAN operation measured in milliseconds. The job begins immediately because nothing has to be provisioned first. On a small repository the difference between “starts in a second” and “starts in a minute” completely changes how often you are willing to lean on CI, and CI you actually use is worth far more than CI you avoid because it is slow.

There is a genuine cost on the other side of the ledger. You now own the runner: its security, its disk filling with old images, its upgrades. For a homelab that already runs Gitea, this is a small marginal burden, and it pairs naturally with a GitOps setup like the Flux-and-Gitea loop where the same server is already the hub of everything.

Turning on Actions and registering a runner

Actions are off by default. Enable them in Gitea’s app.ini:

1
2
3
4
5
[actions]
ENABLED = true
; Where Gitea resolves bare action names like `actions/checkout@v4`.
; Point it at GitHub so the familiar marketplace names just resolve.
DEFAULT_ACTIONS_URL = github

Restart Gitea, and a new “Actions” tab appears in repository and organisation settings, where you generate a registration token. That token is how a runner proves it is allowed to accept jobs. Guard it like a password — anyone holding it can register a machine that will happily execute whatever your workflows tell it to.

The runner itself is act_runner, a single Go binary. It is happiest as a container so its Docker access and lifecycle are tidy. Here is a Compose service that registers on first boot and then stays up accepting jobs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# docker-compose.yml — one act_runner for the whole instance
services:
  runner:
    image: gitea/act_runner:latest
    restart: unless-stopped
    environment:
      GITEA_INSTANCE_URL: "https://git.mylab.local"
      GITEA_RUNNER_REGISTRATION_TOKEN: "${RUNNER_TOKEN}"
      GITEA_RUNNER_NAME: "homelab-runner-01"
      # Labels advertise which images this runner can provide.
      GITEA_RUNNER_LABELS: "ubuntu-latest:docker://catthehacker/ubuntu:act-22.04,ubuntu-22.04:docker://catthehacker/ubuntu:act-22.04"
    volumes:
      # The runner needs the Docker socket to spawn job containers.
      - /var/run/docker.sock:/var/run/docker.sock
      - ./runner-data:/data

The GITEA_RUNNER_LABELS line is the part worth dwelling on, because label mismatches are the single most common reason a workflow sits pending forever. A label maps a name your workflow asks for — runs-on: ubuntu-latest — to a concrete Docker image the runner will spin up to run that job in. If your workflow says runs-on: ubuntu-latest and no registered runner advertises an ubuntu-latest label, Gitea has nowhere to send the job and it waits indefinitely with no error. The label is the contract between what the workflow asks for and what the runner can supply.

Docker mode versus host mode

Advertisement

act_runner can execute jobs two ways, and choosing the wrong one causes a lot of confusion.

In docker mode (the default and what the Compose file above uses), each job runs inside a fresh container built from the labelled image, and the runner talks to the Docker socket to create it. This gives you clean, isolated, reproducible jobs that resemble hosted runners, and it is the right default for almost everything. The catch is that any workflow step which itself wants Docker — building an image, running a service container — needs Docker available inside the job container, which means either mounting the socket through or running a docker-in-docker sidecar.

In host mode (a label pointing at host instead of an image), the job runs directly on the runner machine with no container wrapping it. This is faster and gives trivial access to the host’s Docker, at the cost of isolation — the job can see and touch the runner’s filesystem and tools. I keep host mode for a dedicated, disposable runner box and never for anything that runs untrusted code, because a job in host mode is effectively a shell on the runner.

A workflow that actually does something

Because the syntax is GitHub-compatible, a real pipeline looks exactly as you would expect. Here is one that lints, tests, and builds a container image, using the same actions/checkout you would use on GitHub:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# .gitea/workflows/ci.yaml
name: build
on:
  push:
    branches: [main]
  pull_request:

jobs:
  test-and-build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run tests
        run: |
          go vet ./...
          go test -race ./...

      - name: Build image
        run: |
          docker build -t registry.mylab.local/webapp:${GITHUB_SHA::7} .

      - name: Push to local registry
        run: docker push registry.mylab.local/webapp:${GITHUB_SHA::7}

That docker build step is exactly where self-hosted CI earns its keep and also where it trips you up, so it is worth pairing the build stage with a deliberate image strategy — small, reproducible images like the ones in distroless and multi-stage builds build faster on a warm runner and give the pipeline less to cache.

Secrets, and running more than one runner

Two practical points come up the moment a pipeline does anything beyond running tests.

Secrets work the way you would expect from GitHub. You set them per repository, per organisation, or per user in the Gitea settings, and they surface in workflows as ${{ secrets.MY_TOKEN }}, masked in the logs. A registry password for that docker push step, or a deploy key, lives there rather than in the workflow file. The one habit worth keeping is to scope secrets as narrowly as the level allows — an organisation-wide secret is available to every repository in the org, including any you later add without thinking, so reserve org-level for things that genuinely belong to every project and keep the rest at repository scope.

On runners, you are not limited to one. Register several act_runner instances against the same instance and Gitea load-balances jobs across whichever ones advertise a matching label, so two pushes no longer queue behind each other. This is also how you segregate work: a runner labelled for building images can carry the Docker socket, while a separate, more locked-down runner handles ordinary test jobs with no socket at all. Give each runner a distinct name so the Actions settings page tells you which machine is doing what, and you have a small pool that scales with how much you throw at it.

Troubleshooting the runner

A job stays “pending” and never starts. This is a label mismatch nine times out of ten. Compare the runs-on: in your workflow against the labels the runner registered with (visible in the Actions settings page). If the workflow asks for ubuntu-latest and the runner only offers ubuntu-22.04, nothing connects them. Either add the label to the runner or change runs-on. There is no error because from Gitea’s side the job is simply waiting for a capable runner that will never appear.

docker build inside a job fails with “cannot connect to the Docker daemon”. The job container has no Docker of its own. Mount the host socket into the job (via the runner’s config) or run a docker-in-docker service. The socket-mount route is simpler and fast, with the caveat that the job then shares the host’s Docker and can see other containers — acceptable on a private homelab runner, risky if the repo is public and accepts pull requests.

The runner registers but immediately goes offline. Almost always the runner cannot reach the instance URL you gave it, or cannot verify its TLS certificate. If Gitea uses an internal certificate authority, the runner container needs that CA in its trust store, or it will refuse the HTTPS connection silently. Check the runner logs; a certificate error there is unmistakable once you look.

Actions from the marketplace fail to download. If DEFAULT_ACTIONS_URL is not set to github, a bare uses: actions/checkout@v4 tries to resolve against your own Gitea instance, finds nothing, and errors. Set it to github, or mirror the actions you depend on into your own Gitea and reference them fully — mirroring is the more self-contained choice if you dislike your CI reaching out to github.com on every run.

Builds are slow despite the warm runner. Check that your build is reusing the layer cache rather than pulling base images fresh each time. In docker mode, the job container is fresh but the host Docker cache persists between jobs when you mount the socket, so a well-ordered Dockerfile caches beautifully. A docker system prune on a cron will keep disk under control, but run it during your maintenance window rather than mid-day, since it evicts exactly the cache that makes the next build fast — which is the same reboot-scheduling discipline I use for automatic security updates.

Is it worth it?

If you already run Gitea, adopting Actions is close to free and pays back the first time you push a fix and watch the job start before you have switched windows. The compatibility with GitHub’s syntax means there is almost no new mental model to learn, and the latency win genuinely changes your habits — you start running CI on small commits you would previously have pushed straight to main, because it no longer costs you a minute of thumb-twiddling.

Where I would hesitate is the pull-request-from-strangers case. Self-hosted runners executing untrusted code is a real security exposure, and if your repo is public and accepts PRs, you want careful isolation, an ephemeral runner, and a review gate before workflows run. For private homelab projects, which is where most of this lives, none of that applies and the calculus is simple: same syntax, your hardware, no queue, no bill, and a runner that boots in seconds because it never went to sleep. For that use case it has quietly replaced hosted CI for me entirely, and I have not missed it once.

Advertisement
Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.