Running Gitea with act_runner for Fully Local CI/CD
There’s a point in every homelab journey where you start looking at your Git hosting and think — why am I still pushing my server configs, Terraform state references, and private infrastructure details to a public cloud provider? I hit that point recently, and decided to do something about it.
For me the answer was Gitea — a lightweight, self-hosted Git forge with a GitHub-shaped interface. But that was only half of it. I also wanted the CI/CD part: no pipelines reaching out to GitHub Actions, GitLab Runners, or any external service. Everything stays local.
That’s where act_runner comes in.
The Shape of the Setup
%%{ init: { 'look': 'handDrawn' } }%%
graph LR
A[Workstation] -->|git push| B[Gitea LXC<br/>192.168.10.20:3000]
B -->|job queued<br/>Gitea Actions| C[act_runner<br/>registered runner]
C -->|spawns| D[Docker container<br/>per job step]
D -->|status + logs| B
Everything in that diagram runs behind the firewall. No egress to a CI provider, no repository mirror in someone else’s cloud.
Why Gitea?
I’ve used GitHub for years. It works. But when your repositories contain things like:
- Server network configurations
- Ansible playbooks with internal IPs
- SOPS-encrypted secrets tied to your homelab
- Or just configs and code that tell everyone “hey, this is my home server setup, everything that I’m using”
…the idea of them sitting on someone else’s servers starts to feel unnecessary. Gitea gives me the familiar Git interface with none of that exposure — no telemetry, no usage tracking, no terms of service changes I didn’t ask for.
Setting it up was straightforward — I use Proxmox VE Scripts to provision LXC containers, and Gitea is one of them.
Twenty minutes later I had a working Git server behind my firewall. Repos migrated, SSH keys added, done.
The CI/CD Problem
But here’s where it got interesting. Having repos is one thing. Having pipelines that actually run is another.
Gitea Actions is the built-in CI/CD system, compatible with GitHub Actions workflow syntax. If your workflow files already exist, they mostly just work — Gitea reads them from .gitea/workflows/ or, if that directory is absent, falls back to .github/workflows/. Everything I’d previously written about pinning versions in GitHub Actions carries over unchanged, and matters more here, since you own the runner images too. The catch? You need a runner. That’s act_runner’s job.
Enable Actions first — it is off by default on older instances:
# /etc/gitea/app.ini
[actions]
ENABLED = true
What is act_runner?
act_runner is Gitea’s official runner, built on top of nektos/act — the same tool that lets you run GitHub Actions workflows locally on your machine. The difference is that act_runner registers itself as a proper Gitea Actions runner and picks up queued jobs automatically. It needs a Docker daemon on the same host, because every job step runs in a container.
First, register the runner with your Gitea instance. Head to Gitea admin panel -> Actions -> Runners and generate a registration token. Then:
# Get binary
curl https://dl.gitea.com/act_runner/0.3.1/act_runner-0.3.1-linux-amd64 -o act_runner
chmod +x act_runner
# Register runner
./act_runner register --instance http://192.168.10.20:3000 --token <my_runner_token> --no-interactive
# Start runner
./act_runner daemon
The runner writes a .runner file in its working directory — that holds the registration identity, so back it up or you will be re-registering after every rebuild.
My Gitea host is an Alpine LXC, so supervision is OpenRC rather than systemd:
#!/sbin/openrc-run
# /etc/init.d/act-runner
name="Gitea act_runner"
description="Gitea Actions Runner for CI/CD"
user="root"
directory="/root/act_runner"
command="${directory}/act_runner daemon"
pidfile="/var/run/act-runner.pid"
depend() {
need net
after docker
}
start_pre() {
checkpath --directory --owner ${user}:${user} --mode 0755 \
/var/run/${RC_SVCNAME} /var/log/${RC_SVCNAME}
}
Don’t forget to chmod +x /etc/init.d/act-runner and rc-update add act-runner default to enable it on boot!
A Real Workflow Example
Here’s what an actual workflow looks like in my setup. This one runs TFLint and Terraform fmt checks on my infrastructure repos — the CI end of the Terragrunt setup I use for those repos, now running on hardware I own:
name: Terraform Lint
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
container:
image: hashicorp/terraform:1.9
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install tflint
run: |
curl -s https://raw.githubusercontent.com/terraform-linters/tflint/master/install_linux.sh | bash
- name: Init TFLint
run: tflint --init
- name: Run TFLint
run: tflint --recursive
- name: Check formatting
run: terraform fmt -check -recursive
Nothing fancy. But the important thing is that it runs entirely inside my own network.
Two Things That Surprised Me
runs-on: ubuntu-latest is not GitHub’s image. Runner labels are a mapping you define at registration time, and the default ubuntu-latest points at catthehacker/ubuntu:act-latest — a community image, not the GitHub-hosted runner image. Anything your workflow assumes is preinstalled probably isn’t. The mapping lives in the runner config:
# config.yaml
runner:
labels:
- "ubuntu-latest:docker://catthehacker/ubuntu:act-22.04"
- "terraform:docker://hashicorp/terraform:1.9"
Generate the file with act_runner generate-config > config.yaml, then pass it via act_runner daemon --config config.yaml. Labels can also be set once at registration with --labels.
“Fully local” needs one extra setting. uses: actions/checkout@v4 resolves against https://github.com by default, so a workflow that references marketplace actions still reaches out to the internet. Point Gitea at its own instance and mirror the actions you depend on:
# /etc/gitea/app.ini
[actions]
ENABLED = true
DEFAULT_ACTIONS_URL = self
With self, Gitea looks for actions/checkout in a local actions organization. Mirroring the handful of actions you actually use is a one-time job, and after it the pipeline has no external dependency at all.
What’s Next
The basics run. Next up: more runners for parallel jobs, image caching to cut pipeline startup time, and versioning the Traefik and Homepage configs so adding a service doesn’t mean editing config files by hand on the PVE host.
But for now, my configs are where they belong — on my own servers, running on my own hardware, with no one else in the loop.
That’s how it should be.