Skip to main content

Gitea Runner

CI/CD act

Gitea Act Runner is the official CI runner for Gitea Actions, compatible with GitHub Actions workflow syntax. It runs workflow jobs inside Docker containers (via Docker-in-Docker). Self-hosting runners means CI jobs execute on your own hardware with access to your internal network.

Alternatives considered​

Cloud Hosted

ToolOpen SourceFree TierMonthly Cost
GitHub ActionsNoLimitedFrom $0.008/min

Self Hosted

ToolOpen SourceFull FeaturesNotes
GitLab RunnerYesYesGitLab CI equivalent
JenkinsYesYesTraditional CI, significantly more complex

Installation​

Architecture​

  • StatefulSet: Two act_runner replicas (ordinals 1 and 2) in the gitea-runner namespace
  • Images: docker.io/gitea/runner, docker.io/docker (dind) sidecar, both digest-pinned via Renovate
  • Storage: Per-pod Longhorn PVCs for runner data (data-gitea-runner-1/2) and Docker TLS certs (docker-longhorn)
  • Networking: No inbound networking; runners poll Gitea for jobs

Security​

  • allowPrivilegeEscalation: false, capabilities dropped on runner container
  • Docker daemon secured with TLS mutual auth (DOCKER_HOST: tcp://docker:2376, TLS verify enabled)
  • Longhorn PVCs encrypted at rest via SOPS-managed keys

Updates​

Managed by Renovate. Both images are digest-pinned.

Data Management​

  • PVCs: Per-pod Longhorn-encrypted PVCs for runner state and Docker TLS certs (ephemeral by nature; no backup needed)
  • Backups: No k8up schedule — runner state is ephemeral.

User Management​

Not applicable. Runners authenticate to Gitea via registration token stored in SOPS secret.

Configuration Management​

  • Docker host and TLS config from ConfigMap
  • Runner registration token from SOPS-encrypted secret (shared with the Gitea app secret)

Actions Cache​

A single gitea-runner-cache Deployment runs the runner's standalone cache-server on a 20Gi Longhorn PVC, shared by all runner replicas via cache.external_server — so cache hits don't depend on which runner a job lands on. Requests are authenticated with a shared secret (gitea-runner-cache SOPS secret) that an init container renders into the runner config, replacing the committed placeholder. Workflows use it transparently through actions/cache, setup-node's cache: input, and setup-go.

Usage​

Workflows defined in Gitea repositories with .gitea/workflows/*.yaml are picked up by these runners. Jobs run inside Docker containers via the DinD sidecar. The runner registration token is stored in the Gitea secret and used to register each runner instance.

Cluster-specific deviations from the above live in the per-cluster README — see k8s/apps/gitea-runner/talos/README.md.

Cluster Deployment​

Depends on

Gitea Runner — Talos cluster

CI/CD act_runner nodes that execute Gitea Actions workflows.

Kubernetes Metadata
  • Image: docker.io/docker:29.9.0-dind@sha256:0b6d18a4a222e71c88be6034926b4f6cde2fc33c2218643d03ccfddc65995470
  • Image: docker.io/gitea/runner:5.1.1@sha256:2966f97fd3897a62f5f92e91255b0a886039051eff6f058b2292f10e28137ea1
Rendered manifests (kustomize build)
apiVersion: apps/v1
kind: Deployment
metadata:
annotations:
kustomize.toolkit.fluxcd.io/force: enabled
labels:
app.kubernetes.io/component: cache-server
app.kubernetes.io/name: gitea-runner
name: gitea-runner-cache
namespace: gitea-runner
spec:
replicas: 1
revisionHistoryLimit: 2
selector:
matchLabels:
app.kubernetes.io/component: cache-server
app.kubernetes.io/name: gitea-runner
strategy:
type: Recreate
template:
metadata:
labels:
app.kubernetes.io/component: cache-server
app.kubernetes.io/name: gitea-runner
spec:
automountServiceAccountToken: false
containers:
- command:
- gitea-runner
- cache-server
- -c
- /config/cache-config.yaml
- -d
- /data/cache
- -p
- '8088'
image: docker.io/gitea/runner:5.1.1@sha256:2966f97fd3897a62f5f92e91255b0a886039051eff6f058b2292f10e28137ea1
imagePullPolicy: IfNotPresent
name: cache-server
ports:
- containerPort: 8088
name: http
protocol: TCP
readinessProbe:
initialDelaySeconds: 3
periodSeconds: 5
tcpSocket:
port: http
resources:
requests:
cpu: 25m
memory: 128Mi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
volumeMounts:
- mountPath: /config
name: config
readOnly: true
- mountPath: /data
name: data
initContainers:
- command:
- sh
- -c
- |
printf 'cache:\n external_secret: %s\n' "$CACHE_SECRET" > /config/cache-config.yaml
env:
- name: CACHE_SECRET
valueFrom:
secretKeyRef:
key: CACHE_SECRET
name: gitea-runner-cache
image: docker.io/gitea/runner:5.1.1@sha256:2966f97fd3897a62f5f92e91255b0a886039051eff6f058b2292f10e28137ea1
name: render-config
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
volumeMounts:
- mountPath: /config
name: config
priorityClassName: tier-2
securityContext:
fsGroup: 10001
fsGroupChangePolicy: OnRootMismatch
runAsGroup: 10001
runAsNonRoot: true
runAsUser: 10001
seccompProfile:
type: RuntimeDefault
volumes:
- emptyDir: {}
name: config
- name: data
persistentVolumeClaim:
claimName: gitea-runner-cache
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
annotations:
kustomize.toolkit.fluxcd.io/force: enabled
labels:
app.kubernetes.io/component: runner
app.kubernetes.io/name: gitea-runner
name: gitea-runner
namespace: gitea-runner
spec:
replicas: 4
selector:
matchLabels:
app.kubernetes.io/component: runner
app.kubernetes.io/name: gitea-runner
template:
metadata:
labels:
app.kubernetes.io/component: runner
app.kubernetes.io/name: gitea-runner
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: node-role.kubernetes.io/control-plane
operator: DoesNotExist
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app.kubernetes.io/name: per-worker
topologyKey: kubernetes.io/hostname
automountServiceAccountToken: false
containers:
- command:
- sh
- -c
- |
while ! nc -z localhost 2376; do
echo 'waiting for docker daemon ...';
sleep 5;
done;
exec /sbin/tini -- run.sh
env:
- name: CONFIG_FILE
value: /actrunner/config.yaml
- name: GITEA_RUNNER_REGISTRATION_TOKEN
valueFrom:
secretKeyRef:
key: RUNNER_SECRET
name: gitea-runner
- name: GITEA_INSTANCE_URL
valueFrom:
secretKeyRef:
key: GITEA_INSTANCE_URL
name: gitea-runner
- name: DOCKER_HOST
value: tcp://127.0.0.1:2376
- name: DOCKER_TLS_VERIFY
value: '1'
- name: DOCKER_CERT_PATH
value: /certs/client
image: docker.io/gitea/runner:5.1.1@sha256:2966f97fd3897a62f5f92e91255b0a886039051eff6f058b2292f10e28137ea1
imagePullPolicy: IfNotPresent
name: gitea-runner
resources:
requests:
cpu: 10m
memory: 600Mi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
volumeMounts:
- mountPath: /actrunner
name: config-rendered
- mountPath: /certs
name: docker-certs
- mountPath: /data
name: data
- args:
- --mtu=1000
env:
- name: DOCKER_HOST
value: tcp://127.0.0.1:2376
- name: DOCKER_TLS_VERIFY
value: '1'
- name: DOCKER_TLS_CERTDIR
value: /certs
image: docker.io/docker:29.9.0-dind@sha256:0b6d18a4a222e71c88be6034926b4f6cde2fc33c2218643d03ccfddc65995470
imagePullPolicy: IfNotPresent
name: dind
resources:
requests:
cpu: 10m
memory: 500Mi
securityContext:
privileged: true
volumeMounts:
- mountPath: /certs
name: docker-certs
initContainers:
- command:
- sh
- -c
- sed "s|__CACHE_SECRET__|${CACHE_SECRET}|" /config-template/config.yaml > /actrunner/config.yaml
env:
- name: CACHE_SECRET
valueFrom:
secretKeyRef:
key: CACHE_SECRET
name: gitea-runner-cache
image: docker.io/gitea/runner:5.1.1@sha256:2966f97fd3897a62f5f92e91255b0a886039051eff6f058b2292f10e28137ea1
name: render-config
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
volumeMounts:
- mountPath: /config-template
name: config
- mountPath: /actrunner
name: config-rendered
priorityClassName: tier-2
securityContext:
seccompProfile:
type: RuntimeDefault
volumes:
- configMap:
name: config-4gb5t8ft48
name: config
- emptyDir: {}
name: config-rendered
- emptyDir: {}
name: docker-certs
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
storageClassName: longhorn-object-single-encrypted