Originally published at woitzik.dev
Disclosure: This post contains Amazon affiliate links (marked with *). As an Amazon Associate I earn from qualifying purchases. Buying through them costs you nothing extra.
GitHub Actions is free for public repos. For private repos, you get 2,000 minutes/month on the free tier. A homelab infrastructure repo that runs Terraform plan, Ansible lint, and container builds on every PR burns through 2,000 minutes in about a week. Gitea Actions runs the same YAML workflows on your own hardware, and the BMAX Mini PC* with 64GB RAM has more than enough for a Gitea instance and a handful of runners.
View the complete homelab infrastructure source on GitHub
Gitea Actions: The GitHub Actions Clone
Gitea added Actions in 1.19 as an experimental feature and has enabled it by default since 1.21. It's a CI system that reads workflow files in the same YAML format as GitHub Actions. The key differences:
- Workflows go in
.gitea/workflows/; Gitea also reads.github/workflows/when that's all a repo has - The runner is a separate binary (
act_runner) that polls Gitea for jobs - Logs and artifacts go to Gitea's own storage (local disk by default, configurable to MinIO/S3)
- Secrets and variables are managed in the Gitea UI
The YAML syntax is the same, and actions like actions/checkout@v4 are pulled from github.com by default. Most GitHub Actions workflows work in Gitea with minimal changes.
Deploying Gitea on k3s
Gitea runs as a StatefulSet with PostgreSQL:
apiVersion: apps/v1 kind: StatefulSet metadata: name: gitea namespace: gitea spec: replicas: 1 serviceName: gitea selector: matchLabels: app: gitea template: metadata: labels: app: gitea spec: containers: - name: gitea image: gitea/gitea:1.22 ports: - containerPort: 3000 - containerPort: 22 env: - name: GITEA__DATABASE__DB_TYPE value: "postgres" - name: GITEA__DATABASE__HOST value: "gitea-db.gitea.svc:5432" - name: GITEA__DATABASE__NAME value: "gitea" volumeMounts: - name: data mountPath: /data volumeClaimTemplates: - metadata: name: data spec: accessModes: ["ReadWriteOnce"] resources: requests: storage: 10Gi The Action Runner
The act_runner binary runs as a Deployment, polling Gitea for jobs:
apiVersion: apps/v1 kind: Deployment metadata: name: gitea-runner namespace: gitea spec: replicas: 2 selector: matchLabels: app: gitea-runner template: metadata: labels: app: gitea-runner spec: containers: - name: runner image: gitea/act_runner:latest env: - name: GITEA_INSTANCE_URL value: "http://gitea.gitea.svc:3000" - name: GITEA_RUNNER_REGISTRATION_TOKEN valueFrom: secretKeyRef: name: gitea-runner-token key: token - name: GITEA_RUNNER_NAME value: "k3s-runner" - name: GITEA_RUNNER_LABELS value: "ubuntu-latest:docker://node:20-bookworm" The runner registration token is generated in the Gitea UI under Site Administration → Actions → Runners → Create new Runner.
Two caveats with this manifest. With docker:// labels, act_runner starts each job in a container, so it needs a Docker daemon; on k3s that means a Docker-in-Docker sidecar or the act_runner dind image, which runs privileged. And act_runner stores its registration in a .runner file, so give each replica persistent storage (a StatefulSet fits better than a Deployment here), or every restarted pod registers as a new runner.
Workflow Migration
A GitHub Actions workflow migrates to Gitea by moving the file, or by leaving it where it is if the repo has no .gitea/workflows/ directory:
.github/workflows/ci.yml → .gitea/workflows/ci.yml The YAML content stays the same. Gitea provides the github context for compatibility, so github.event_name, github.repository and github.sha keep working; gitea.* is available as an alias.
Docker-in-Docker
Many CI workflows need Docker (building images, running containers). The Gitea runner supports this either by running job containers privileged or by mounting the Docker socket into them:
# .gitea/workflows/docker-build.yml name: Build and Push on: [push] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Build run: docker build -t myapp:${{ github.sha }} . - name: Push run: | echo ${{ secrets.DOCKER_PASSWORD }} | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin docker push myapp:${{ github.sha }} The runner label ubuntu-latest:docker://node:20-bookworm tells the runner to execute jobs inside a node:20-bookworm container, which has no Docker CLI. For image builds, point the label at an image that ships one, such as catthehacker's act image, and enable container.privileged: true (or a socket mount via container.options) in the runner's config.yaml. Privileged mode is not a label setting:
GITEA_RUNNER_LABELS: "ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-latest" Resource Limits
On a homelab BMAX Mini PC*, the Gitea runner shouldn't eat all the resources. Set resource limits on the runner Deployment:
resources: requests: cpu: "500m" memory: "512Mi" limits: cpu: "2" memory: "2Gi" Two runners capped at 2 GiB each is 4 GiB at most, negligible on a 64GB machine. The runners share CPU with the other k3s workloads: the scheduler reserves only the 500m request, and cpu: "2" is a throttling ceiling, not a reservation. Note that with Docker-in-Docker the job containers run inside the dind sidecar, so set limits on that container too.
The Trade-off
Gitea Actions is not as mature as GitHub Actions. Matrix builds and actions from the GitHub marketplace work, but some workflow syntax is ignored or only partly supported: jobs.<job_id>.environment, problem matchers, error annotations, complex runs-on expressions, and GitHub-only permissions scopes such as id-token. For a homelab infrastructure repo that runs Terraform, Ansible, and container builds, those gaps don't matter. Terraform changes still go through Atlantis for the actual apply, so Gitea Actions only needs to handle lint and plan. The YAML syntax is the same, the runner runs on your hardware, and there's no minute limit.