Gitea on Raspberry Pi: the story of how GitLab took down my homelab
It was a Friday night and I was proud of what I had just built. I had just brought up GitLab Community Edition on my Raspberry Pi 4B — a long-held dream of having a complete development environment entirely under my own control. Git, CI/CD, registry, all in a small box sitting on the shelf.
I went to sleep.
Saturday morning, I opened the terminal to push something and found… silence. The Raspberry Pi wasn’t responding. SSH timeout. No ping. I walked over to the shelf, looked at the slowly blinking green LED, and understood: GitLab’s memory footprint — likely a slow leak compounded by background jobs competing for CPU — had starved every other service on the node until the kernel had no choice but to intervene. The Pi had rebooted, and without enough headroom to bring everything back up, it just sat there.
Frustrating doesn’t even cover it.
Why GitLab sank the Pi
GitLab CE is an impressive piece of engineering. It’s also one that assumes you have at least 4GB of RAM available just for it — preferably 8GB. The Raspberry Pi 4B has 4GB total, split between the operating system, background processes, and everything else you want to run.
The GitLab stack includes: Puma (Ruby), Sidekiq, its own PostgreSQL, its own Redis, Gitaly, GitLab Workhorse, internal NGINX. Each of those processes has a considerable memory footprint. Together, they turned my Pi into a process scheduling problem — ironic for a device I used to study exactly that in college.
The lesson was simple and expensive: modest hardware demands modest software. You can’t take a solution designed for servers and expect it to fit on an ARM with limited resources.
Finding Gitea
After researching alternatives, I landed on Gitea. Written in Go. Single binary. No external dependencies beyond a database. The entire process — including web interface, API, and repository management — running in under 150MB of RAM at idle.
That difference isn’t accidental. It’s a direct consequence of the language and the design philosophy. Go compiles to a static binary, no external runtime, no interpreter. Boot time is nearly instant, the footprint is predictable, and behavior under memory pressure is considerably gentler than a Ruby application that depends on dozens of gems.
I ran Gitea as a Pod inside my k3s cluster, with a PVC for persistence. The Pi never rebooted again.
The current architecture
The environment I built has a few layers that fit together well:
k3s on Raspberry Pi 4B as the orchestration platform. k3s is Kubernetes for people without a data center — consumes under 500MB and starts in seconds. Traefik ships built in as the ingress controller, which eliminates an entire piece of the stack.
Gitea as the Git server and CI interface. Organizations inside Gitea map directly to Kubernetes namespaces — a discipline that keeps contexts separate and makes it easy to understand what belongs to whom. org/project in Gitea corresponds to the org namespace in the cluster.
Gitea Runner as the pipeline executor. Each push triggers the runner, which runs the steps inside a container in the cluster itself. No external agent, no dependency on a cloud service.
nerdctl + buildkitd for image builds. Replaces Docker in the runner environment — nerdctl speaks the same API, but integrates directly with k3s’s containerd. Images are built with a local disk cache and loaded directly into the k8s.io containerd namespace, with no intermediate registry needed.
Traefik exposing services via IngressRoute, with Cloudflare Tunnel providing external access without opening ports on the router.
The npm problem and the shift to Go
Up to this point, things were reasonably under control. The problem appeared when I tried to integrate a Node.js frontend application into the pipeline.
npm install on a modern application is a… particular experience. Hundreds of packages, transitive dependencies, files that resolve at install time, build scripts that can do practically anything. In an environment with a slow disk (SD card) and limited CPU (ARM Cortex-A72), an npm install + npm run build can take several minutes — and consume memory in a way that uncomfortably recalls GitLab.
The problem isn’t just performance. It’s unpredictability. A project’s node_modules can be 500MB or 2GB depending on dependencies. Build time varies with the network, with the exact package versions resolved by the lockfile, with optimization flags. Caching this correctly in a storage-constrained environment is a problem in itself.
That’s when I started preferring Go for new projects.
The difference is structural. Go compiles statically. The final artifact is a single binary with no runtime dependencies. go build is deterministic — given the same code and the same dependencies, it produces the same binary. Compilation time is reasonable even on ARM. And the resulting binary is megabytes, not gigabytes.
At runtime, a typical Go service consumes tens of megabytes of RAM at idle. A Node.js server with the same purpose usually starts at 100–200MB just for the interpreter process. On hardware with real constraints, that difference translates directly into how many services you can run simultaneously — and how many nights you sleep soundly without the Pi rebooting.
Go’s static typing also contributes to what I call behavioral determinism: type errors are caught at compile time, not runtime. In a CI pipeline, that means binaries that pass the build have a stronger contract with the environment they’ll run in. Fewer surprises at three in the morning.
What’s running today
The current environment hosts:
- Static sites in Hugo, with automatic deploy on push
- Docusaurus documentation, built and served as static
- Go services with build and deploy pipelines in the cluster
- Gitea itself and its infrastructure
All of this on a Raspberry Pi 4B that fits in the palm of your hand, consumes under 15W, and hasn’t rebooted since GitLab left the picture.
Sometimes the best hardware upgrade is choosing the right software.