# I Built My Own GitHub (And Broke It About a Dozen Times Doing It)

*Part of my homelab series — where I'm turning old desktops and a laptop into a real, self-managed infrastructure stack. Currently between roles, and using that time deliberately: this series is as much a documented systems-administration project as it is a hobby.*

* * *

## The problem I was actually solving

I've got a handful of personal projects — mobile apps, web apps, backend services — sitting at close to 80GB across various local folders. GitHub's free private repos work fine for code, but I wanted something I fully controlled: my own git server, my own backups, my own uptime, no dependency on a third party's terms of service for projects I care about keeping around.

Enter **Forgejo** — a community-driven, fully open-source fork of Gitea. Lightweight, self-hostable, and — as I'd find out — genuinely capable of running on hardware most people would call "too old for anything serious."

This post walks through building it for real: the architecture, the decisions, and — more usefully than a clean tutorial — the actual mistakes I made and how I diagnosed them. If you're building something similar, the failure modes here are probably more useful to you than the happy path.

## The hardware

My homelab currently runs across two systems:

*   An **HP Envy m6 laptop** (i7-3632QM, 8GB RAM) running **Proxmox**, hosting close to a dozen LXC containers — DNS, ad-blocking, reverse proxy, media server, home automation, and now, git.
    
*   A **Pentium G3250 desktop** running **TrueNAS bare-metal**, with a 3-drive RAIDZ1 pool for storage.
    

Neither of these is impressive hardware by 2026 standards. That was kind of the point — proving I could design something *correctly* on constrained resources, rather than throwing RAM and cores at every problem.

## Sizing the container honestly

Before building anything, I checked what Forgejo actually needs. Its own docs don't publish hard minimums, but given its lineage (a fork of Gitea, itself famous for running on a Raspberry Pi), I landed on a deliberately conservative spec: **1 vCPU, 512MB RAM, 512MB swap.**

That mattered more than it might sound like. My Proxmox host only has 8GB total RAM, already running ~10 other services. Before adding anything, I actually checked real usage (`free -h`) rather than trusting the sum of every container's *declared* memory limit — which turned out to be a meaningfully different (and much less alarming) number than I expected. Lesson one: **declared resource limits and actual usage are not the same thing**, and conflating them leads to overly conservative — or wrongly confident — decisions.

## The storage decision that almost bit me

Here's where it got interesting. 8GB of local container disk is nowhere near enough for 80GB of projects. My first instinct was: mount my TrueNAS storage over the network and point everything at it.

Except — that's a real footgun if you don't think it through. **SQLite (which Forgejo uses as its database by default) explicitly warns against running on network filesystems**, because file-locking semantics over NFS/SMB don't reliably match what SQLite expects, and that can lead to database corruption under the wrong conditions.

The fix wasn't "don't use network storage" — it was **split the workload**: keep the tiny, fragile SQLite database on local disk, and route only the large, resilient git object storage to TrueNAS over NFS. Forgejo actually supports this cleanly via a `Repository Root Path` setting, separate from its main data directory — which meant I got the capacity I needed without touching the piece that actually needed to stay local.

## The permissions puzzle

Getting the NFS share to *work* was one thing. Getting it to work *correctly* was another.

I set up a dedicated TrueNAS identity (`git`, in a `git_users` group) with no login access anywhere — SMB, shell, SSH, TrueNAS UI, all explicitly denied. Its only job: own the files on this one share. I used **NFS's "Mapall" option** to force every request through this share to appear as that one identity, regardless of who's actually connecting — which sounds like a small detail, but it's the difference between "any UID that can reach this share gets full access" and "there is exactly one identity that matters here, and I control it completely."

Then I hit something I hadn't anticipated: inside the unprivileged LXC container, every file on the mount showed up owned by `nobody:nogroup`. Not a bug — a direct consequence of how **unprivileged containers shift their internal UIDs** on the host side, combined with the NFS server presenting an identity the container's UID namespace couldn't map to anything meaningful. The practical fix was opening the mount's "other" permissions, compensating with a much tighter network-level restriction instead (the NFS share only accepts connections from one specific IP — my Proxmox host, and nothing else on the LAN).

The lesson that stuck with me here: **security isn't one control, it's layers that compensate for each other.** A permissive filesystem permission is fine *if* the network boundary around it is genuinely tight — but you have to reason about the whole picture, not just the setting in front of you.

## The service that was "broken" but wasn't

This one was my favorite debugging moment of the whole build. After installing Forgejo and starting it via systemd, the service kept crash-looping — `activating (auto-restart)`, over and over, timing out after exactly 90 seconds each time.

Reading the actual logs (not just the status line) showed something surprising: **the process was starting fine.** It was listening on port 3000, ready to serve the setup wizard. Systemd was killing a perfectly healthy process.

The cause: Forgejo's official service file uses `Type=notify`, a systemd mode where the *service itself* has to signal "I'm ready" — and Forgejo, by design, only sends that signal once you've completed the initial web setup wizard. Since nobody had visited the wizard yet, systemd's default 90-second patience ran out, and it killed a process that was working exactly as intended.

The fix was one line (`TimeoutStartSec=0`), but the real value was in the diagnostic process: **don't trust a summary status line over the actual logs**, and don't assume "it's failing" means "it's broken" — sometimes it means "it's waiting on something you haven't given it yet."

## SSH, and the assumption I got wrong

I assumed Forgejo would run its own SSH server the way GitHub or GitLab seem to. It doesn't, by default — it relies entirely on the box's own system SSH daemon, and silently manages the `authorized_keys` file behind the scenes, injecting a command wrapper that routes git operations through Forgejo instead of handing out a real shell.

This led me down a genuinely confusing troubleshooting path — SSH kept falling back to a password prompt despite a key being registered. Turned out the issue wasn't server-side at all: my client machine simply didn't have a private key sitting at any of the default filenames SSH checks automatically. I keep my keys deliberately organized under descriptive names for different services (a habit I'd recommend), which meant SSH's default auto-discovery just... never found it. A one-line addition to `~/.ssh/config`, explicitly pointing at the right key, solved it completely.

**Takeaway:** when debugging auth failures, verbose client output (`ssh -v`) is worth more than any amount of server-side guessing — it tells you definitively whether your client even *attempted* to offer a credential, which narrows the entire problem space immediately.

## Splitting web and SSH cleanly

Last piece: I wanted a clean `gitremote.home.arpa` for browsing and `git.home.arpa` for cloning — but Caddy, my reverse proxy, only understands HTTP(S). It has no concept of raw SSH traffic on port 22. Pointing my SSH-facing domain at Caddy's IP meant SSH connections hit *Caddy's own host*, not my git server at all — a subtle, easy-to-miss routing mistake.

The fix: two separate DNS records, each doing one job — the web hostname resolves to Caddy (which proxies to Forgejo over HTTP), and the SSH hostname resolves *directly* to the git container, bypassing the proxy entirely. Forgejo actually supports this natively — its web domain and SSH domain are independently configurable, so the URLs it displays match reality for each protocol.

## What this build actually demonstrated

Stepping back, here's the honest skill inventory from this one project:

*   **Linux systems administration** — user/permission management, systemd service configuration and debugging, package management on a minimal Debian install
    
*   **Virtualization** — LXC containers, unprivileged container UID mapping, resource sizing based on measured usage
    
*   **Storage & filesystems** — NFS configuration, permission models (POSIX vs. ACL), understanding *why* certain database engines are unsafe on network storage
    
*   **Networking** — DNS record design, reverse proxy configuration and its protocol limitations, understanding the difference between application-layer and transport-layer traffic
    
*   **Security reasoning** — least-privilege service accounts, defense-in-depth (network restriction + Mapall + local permissions working together), deliberate separation of admin and daily-use accounts
    
*   **Systematic troubleshooting** — reading logs over trusting status summaries, using verbose diagnostic flags, forming a hypothesis and testing it rather than guessing
    

None of this was theoretical. Every one of these came from something actually breaking, and having to figure out why.

## What's next

Forgejo's up, tested, and pushing/pulling cleanly over both HTTPS and SSH. Next in this series: exposing select services to the internet properly via a Cloudflare Tunnel (with Zero Trust access policies in front, not just raw exposure), and finishing out the DNS/ad-blocking layer.

If you're working through something similar — or if you're hiring for something adjacent to what's in this post — I'd genuinely like to hear from you.
