Skip to content
5 min read

Gitea Actions on k3s: Self-Hosted CI Without GitHub

Gitea Actions runs GitHub Actions-style YAML on your own hardware. Running Gitea and act_runner on k3s puts CI pipelines and runner resources under your control.


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.

Want the next article by email?

Subscribe →
Was this helpful?
Share
DW

David Woitzik

Hybrid Cloud Engineer

I work mostly with Azure, Terraform, and Zero-Trust network design, and publish the Terraform templates and write-ups I wanted when I was solving the same problems.

Keep reading

More like this in your inbox

New Terraform modules and articles by email. No spam.