From GitLab to Gitea: how my homelab CI/CD went from 3.4 GB to 100 MB
One of my repositories has a commit history that reads like a story in three acts. The first is about personal ambition. The second is about a Raspberry Pi that went silent in the middle of the night . The third is about finding the right tool for the right load.
Act I: GitHub Actions with a self-hosted runner
The starting point was simple: I wanted CI/CD running on the Pi, builds happening locally, automated deployments. The natural choice was to keep using GitHub Actions — but with a self-hosted runner in the cluster.
The Actions Runner Controller (ARC) looked elegant: a Kubernetes operator that manages runner pods automatically, scaling on demand. I set it up, it worked. But something nagged at me: every time a build was needed, a pod spun up from scratch, pulled the image, ran the job, then shut down.
The cycle was too slow for a Pi running on an SD card — and it felt like I was barely touching what the hardware could actually do. Fifteen watts of always-on compute, and I was routing builds through GitHub just for orchestration.
On top of that, ARC’s authentication model with GitHub requires a GitHub App, scoped tokens, and a configuration dance I had to redo every time something expired. For a personal homelab, not the kind of problem I wanted to be maintaining.
Act II: GitLab CE and the Pi that didn’t come back
The logic seemed sound: if the problem is depending on GitHub, why not host your own Git? GitLab Community Edition is open source, mature, has everything — Git, CI/CD, registry, issues, MRs.
I installed GitLab Omnibus on the cluster. It worked. For a few hours.
The next morning, the Pi was silent. SSH unresponsive, cluster unreachable. I walked over to the shelf, plugged in a monitor — it was running, but frozen. GitLab had consumed all available RAM and the kernel had triggered OOM (Out of Memory), killing processes at random until the system was inoperable.
The numbers, when I investigated afterward:
| Component | RAM at idle |
|---|---|
| GitLab Web | ~1.2 GB |
| Puma (app server) | ~800 MB |
| Sidekiq (background jobs) | ~600 MB |
| PostgreSQL (bundled) | ~400 MB |
| Redis (bundled) | ~200 MB |
| Gitaly (Git RPC) | ~200 MB |
| Total | ~3.4 GB |
The Pi has 4 GB. That left 600 MB for k3s, Traefik, and everything else. Any CI build killed the node.
GitLab is an excellent solution — for servers with 8 GB+. On a Raspberry Pi, it’s like putting an F1 engine on a bicycle.
Act III: Gitea and the lightness that changes everything
Gitea is a Git server written in Go. A single static binary, no runtime dependencies, no bundled PostgreSQL, no Sidekiq, no Gitaly. SQLite as the default database.
The number that convinced me: ~100 MB of RAM at idle.
That’s not an optimistic estimate. It’s what I see in kubectl top pod day to day:
NAMESPACE NAME CPU(cores) MEMORY(bytes)
apps gitea-xxx 8m 94MiThat frees up space for what actually matters: the builds.
The deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: gitea
namespace: apps
spec:
replicas: 1
strategy:
type: Recreate # important: prevents two pods trying to mount the same PVC
template:
spec:
containers:
- name: gitea
image: gitea/gitea:latest
resources:
requests:
memory: 128Mi
cpu: 100m
limits:
memory: 512Mi
cpu: 500m
env:
- name: GITEA__database__DB_TYPE
value: sqlite3
- name: GITEA__packages__ENABLED
value: "true"
- name: GITEA__service__DISABLE_REGISTRATION
value: "true"The strategy: Recreate is not a detail: with RollingUpdate (the default), Kubernetes tries to bring up the new pod before killing the old one. Two pods trying to mount the same PVC with SQLite results in database corruption. I learned this during an update rollout.
SSH access for push/pull lives on a separate NodePort:
apiVersion: v1
kind: Service
metadata:
name: gitea-ssh
spec:
type: NodePort
ports:
- port: 22
targetPort: 22
nodePort: 30022The git remote becomes ssh://git@<pi-ip>:30022/org/repo.git. Works exactly like GitHub.
The runner: act_runner and the buildkitd problem
Gitea uses act_runner — a runner compatible with GitHub Actions syntax. You write .gitea/workflows/deploy.yml with the same jobs and steps structure you’d use on GitHub, and it works.
The runner runs as a pod in the cluster, with access to k3s’s containerd via a socket mounted as a volume. This enables image builds without a Docker daemon — using nerdctl + buildkitd.
This part took work. The commits tell the story:
Problem 1: buildkitd by default uses the containerd worker, but k3s’s containerd lives at /run/k3s/containerd/ instead of /run/containerd/. The first build failed silently.
Problem 2: buildkitd’s containerd worker needed access to /var/lib/buildkit/ on the host — a directory that doesn’t exist on the Pi by default. Solution: switch to the OCI worker with runc, which is self-contained.
Problem 3: Inside the runner pod, 127.0.0.1:6443 is the container’s loopback, not the k3s API server. The kubeconfig generated by k3s points to 127.0.0.1, so kubectl apply from inside the pod fails. Solution: sed the kubeconfig to replace it with kubernetes.default.svc.cluster.local before use.
sed 's|https://127.0.0.1:6443|https://kubernetes.default.svc.cluster.local|g' \
/etc/rancher/k3s/k3s.yaml > /tmp/kubeconfigProblem 4: buildkitd needs access to k3s’s containerd snapshots at /var/lib/rancher. Mounting that directory as a hostPath volume fixed it.
In the end, the build workflow looks like this:
# Start buildkitd pointing to the OCI worker
buildkitd --addr unix:///run/buildkit/buildkitd.sock \
--oci-worker=true \
--containerd-worker=false \
--root /data/buildkit &
# Build with persistent cache
nerdctl \
--address /run/k3s/containerd/containerd.sock \
--namespace k8s.io \
build \
--cache-from type=local,src=/data/buildcache \
--cache-to type=local,dest=/data/buildcache,mode=max \
-t "${IMAGE}" .The PVC-backed cache makes a real difference: a second build of an application whose dependencies haven’t changed is 80% faster than the first.
Organizing repositories as namespaces
One thing Gitea inherits from GitHub (and GitLab calls “groups”) are organizations. In my homelab, I used this as a convention:
- Organization
docs→ site/documentation repositories, deployed to thedocsnamespace - Organization
apps→ applications, deployed to theappsnamespace
The deploy workflow reads the organization name and uses it as the namespace in kubectl apply. It becomes automatic: creating a repository in the right org already determines where it ends up in the cluster.
Backup: simple because it’s simple
One of the advantages of SQLite and a single PVC is that backup is trivial:
kubectl cp "apps/$POD:/data" "$BACKUP_DIR/$TIMESTAMP"
tar -czf "$BACKUP_DIR/$TIMESTAMP.tar.gz" -C "$BACKUP_DIR" "$TIMESTAMP"Everything that is Gitea lives in /data inside the pod: database, repositories, uploads, configuration. A kubectl cp captures it all. Runs via cron at 5am, keeps the last 7 days, with an option to encrypt with GPG before storing.
With GitLab, backup involved platform-specific scripts, separate PostgreSQL and Redis exports, coordination across multiple services. Here it’s tar.
What got left behind
It’s not a lossless migration. GitLab has things Gitea doesn’t (or has in simpler form):
- Merge Request reviews with richer inline comments
- Visual pipelines with job DAG
- Container Registry with automatic garbage collection
- Pages built in for static sites
For personal homelab use, none of these are blockers. Gitea has issues, PRs, webhooks, basic CI/CD, OCI registry — enough for everything I do.
Trading 3.4 GB for 100 MB on hardware with 4 GB available wasn’t a preference. It was a necessity. And the result was a cluster that survives a build without hitting OOM — which is exactly what I needed.
The manifests, runner configuration, and build scripts that make this setup work are available at gresas/setup-tools .