GitHub went down on January 14th. I didn’t notice until someone tweeted about it.
My infrastructure repo was fine. My CI pipeline was fine. My deployments kept running. Flux was still reconciling Kubernetes manifests from a Git server sitting three feet from the cluster it was deploying to. The entire incident was invisible to me because none of my critical path touches GitHub.
That’s not luck. That’s architecture.
Why Self-Host Git? (The Real Reasons)
Every article about self-hosted Git leads with “privacy and control.” Fine. True. But also vague enough to be meaningless. Here are the actual reasons I run my own Git server, the ones that matter at 2 AM when something is broken.
My infrastructure repo is accessible when the internet isn’t. I run OPNsense, MikroTik managed switches, Proxmox clusters, K3s. The repo that defines all of that lives on git.argobox.com, which resolves to 10.42.0.x internally. If my ISP goes down, if there’s a DNS outage, if Cloudflare has another bad day — I can still pull the Ansible playbook that fixes my router. Try doing that when your infrastructure-as-code lives on someone else’s infrastructure.
Sensitive configs never leave the network. I have files that contain internal hostnames, identity mappings, network topology, the kind of stuff that isn’t a security vulnerability by itself but paints a very detailed picture of my infrastructure if it leaked. Private GitHub repos are encrypted at rest, sure. But “trust us” is a different posture than “it never left my LAN.”
Local clone and push is instant. Not “fast.” Instant. LAN-speed Git operations are a different experience entirely. Cloning a repo with 134 commits takes under a second. Pushing a branch is so fast it feels like a local operation. Once you’ve lived with that, the half-second of internet latency on every git push to GitHub feels like dial-up.
Direct webhooks to local CI/CD with zero external dependency. Push to Gitea, webhook fires to Woodpecker CI on the same network, pipeline runs in local containers, Flux picks up the change. The entire loop from commit to deployment happens without a single packet leaving my network. That’s not paranoia. That’s reducing the blast radius.
The Numbers
I didn’t set out to accumulate 63 repositories. It just happened, the way it always happens. You start with one infrastructure repo and suddenly you’re organizing things into two organizations.
63 repositories. Split across two Gitea organizations: InovinLabs for personal and experimental projects, KeyArgo for anything that touches production infrastructure.
The spread tells a story if you look at it. Nine university projects from my WGU degree, preserved because I’m sentimental about code that got me through Database Management at 11 PM on weeknights. The argobox site repo sitting at 134 commits — every blog post, every layout tweak, every “I swear I’ll redesign the nav eventually” commit. gentoo-build-swarm with 89+ commits documenting the evolution of a distributed Gentoo package build system that started as a bad idea and turned into a slightly less bad idea with good documentation. apkg-gentoo at 42 commits, a custom package manager for binary Gentoo packages that I built because the existing tooling didn’t do what I needed.
Some of those repos are active daily. Some haven’t been touched in two years. All of them are mine, on hardware I control, backed up on a schedule I set. No terms of service changes. No sudden feature deprecation. No “we’re sunsetting free private repos” surprise.
The Setup
Gitea runs as a Docker container. That’s it. That’s the setup.
services:
gitea:
image: gitea/gitea:latest
container_name: gitea
environment:
- USER_UID=1000
- USER_GID=1000
volumes:
- ./gitea:/data
- /etc/timezone:/etc/timezone:ro
ports:
- "3000:3000"
- "222:22"
restart: unless-stopped
I use SQLite as the database. Not PostgreSQL. Not MySQL. SQLite. Because I’m a single user with 63 repos, not running a GitLab competitor. SQLite handles this workload without breaking a sweat, and it means my entire Gitea instance — data, config, repos, everything — is a single directory that I can tar and move anywhere.
Don’t over-engineer the database layer for a single-user Git server. I’ve seen people spin up PostgreSQL clusters with replication for their personal Gitea instance. That’s not engineering. That’s resume-driven development. SQLite has been fine for over a year. It’ll be fine for the next five.
Port 3000 for the web interface, port 222 for SSH. That SSH port matters — more on that in the tips section. The whole thing sits behind a reverse proxy with SSL, so git.argobox.com gets a proper cert and I don’t have to think about it.
The configuration is minimal:
[server]
DOMAIN = git.argobox.com
SSH_DOMAIN = git.argobox.com
ROOT_URL = https://git.argobox.com/
DISABLE_SSH = false
SSH_PORT = 222
[service]
DISABLE_REGISTRATION = true
REQUIRE_SIGNIN_VIEW = true
[security]
INSTALL_LOCK = true
Registration disabled. Sign-in required to view anything. Install lock on. Three settings that turn Gitea from “open platform” to “personal vault.” The whole container uses maybe 200MB of RAM. My monitoring dashboard barely notices it exists.
The Monorepo Strategy
The most important repository on the entire instance is the infrastructure monorepo. One repo. Everything.
infrastructure/
├── ansible/ # Configuration management
├── terraform/ # Cloud resources
├── kubernetes/ # K3s manifests
└── scripts/ # Build and deployment tools
The argument for splitting these into separate repos is organizational purity. The argument against is that infrastructure changes are almost never isolated to one layer. When I add a new service, I’m writing the Ansible role to configure the host, the Kubernetes manifest to deploy the container, and the Terraform resource for any cloud dependencies. That’s one logical change across three directories.
Atomic commits across layers. One commit that says “add Woodpecker CI” includes the Ansible role for the Woodpecker agent, the Kubernetes deployment manifest, and the script that configures OAuth with Gitea. Bisecting a problem means finding one commit, not correlating timestamps across three repos.
I tried the multi-repo approach for about six months. Lasted until the third time I had to coordinate changes across repos and forgot to push one of them. The monorepo is simpler. Simpler is better when you’re the only one on the team and it’s midnight.
CI/CD with Woodpecker
Gitea integrates with Woodpecker CI — a community fork of Drone — via OAuth. The setup is straightforward: Woodpecker registers as an OAuth application in Gitea, gets access to repos, and listens for webhooks. Push code, webhook fires, pipeline runs.
# .woodpecker.yml
pipeline:
# Test Python code
test:
image: python:3.11
when:
path:
include: [ 'scripts/**' ]
commands:
- pip install -r requirements.txt
- pytest tests/
# Lint Kubernetes manifests
lint-k8s:
image: stackrox/kube-linter:latest
when:
path:
include: [ 'kubernetes/**' ]
commands:
- kube-linter lint kubernetes/
# Notify on failure
notify:
image: plugins/slack
settings:
webhook:
from_secret: slack_webhook
when:
status: [ failure ]
The path-based triggers are the key detail. Change a Python script in scripts/? Only the Python tests run. Update a Kubernetes manifest? Only the linter runs. Touch both? Both pipelines fire. This keeps build times short and feedback fast, which matters more than you’d think when you’re iterating on infrastructure changes.
The entire pipeline runs locally. Woodpecker spins up containers on the same Docker host, pulls the code from Gitea over the LAN, runs the tests, reports back. If my internet connection dies mid-push… well, I can’t push to GitHub. But I can push to Gitea, and Woodpecker will still run the pipeline, and Flux will still pick up the Kubernetes changes. The whole inner development loop is internet-independent.
That’s the real value of self-hosted CI/CD. Not cost savings. Resilience. GitHub Actions is phenomenal. But it’s also a dependency on GitHub’s infrastructure, GitHub’s runner availability, and GitHub’s definition of “free tier.” Woodpecker running on my hardware has none of those constraints.
Mirror to GitHub (Best of Both Worlds)
I’m not a hermit. Some projects benefit from being on GitHub. Visibility, collaboration, the occasional star from a stranger who found your project useful. I don’t want to give that up.
So I mirror.
Public repos get pushed to GitHub automatically. Private repos stay on Gitea. I have a repo called mirror-gitea-to-github that handles the synchronization — it’s essentially a cron job that iterates through a list of repos and does a git push --mirror to the corresponding GitHub remote.
The flow is always one-directional. Gitea is the source of truth. GitHub is the mirror. I never push directly to GitHub. If someone opens a PR on a mirrored repo, I pull it down to Gitea, review it there, and the merge propagates back to GitHub on the next sync.
GitHub for collaboration. Gitea for sovereignty. That’s the entire strategy in one line. I get the network effects of the largest code hosting platform in the world without depending on it for anything critical. If GitHub disappeared tomorrow, I’d lose some stars and some issue history. I wouldn’t lose a single line of code.
The mirror config is simple — each repo gets an entry with the Gitea source and GitHub destination. The script handles authentication via SSH keys, and the cron job runs every six hours. Good enough for public repos that don’t change every hour.
Backup Strategy
Gitea’s data is just files. That’s the beauty of SQLite and bare Git repositories.
# Daily backup
tar -czf gitea-backup-$(date +%Y%m%d).tar.gz /opt/gitea/data
# Sync to NAS
rclone sync /backups/gitea nas:/backups/gitea
The daily tar captures everything: the SQLite database, the bare Git repos, the Gitea configuration, avatars, attachments. One archive. rclone pushes it to the NAS. The NAS has its own backup schedule to off-site storage. Three copies, two media, one off-site. The 3-2-1 rule isn’t glamorous but it works.
The repos themselves are an additional layer of redundancy because they’re Git. Every clone is a full backup. My workstation has clones of the repos I work with daily. The GitHub mirrors are another copy of the public ones. Even if the Gitea server, the NAS, and the off-site backup all failed simultaneously — a scenario that would require my house to burn down and the cloud to vanish — I’d still have most of my code on whatever machine I’m currently using.
The backup I trust most is the one I don’t have to think about. Automated, tested (I do a restore drill every few months), and boring. Good backups are boring.
Gitea vs GitHub: Honest Comparison
I’m not going to pretend Gitea replaces GitHub. It doesn’t. Here’s where each one wins.
| Feature | GitHub | Gitea (Self-Hosted) |
|---|---|---|
| Availability | Their uptime (99.9%+) | My uptime (good, but I sleep) |
| Privacy | They see everything | Stays on my LAN |
| Speed | Internet latency | LAN speed |
| Cost | Free with limits | Free (minus electricity) |
| CI/CD | Actions (massive ecosystem) | Woodpecker (smaller but capable) |
| Features | Everything | Core Git + issues + PRs |
| Collaboration | Billions of users | Just me |
| Maintenance | Zero | On me |
GitHub Actions alone is reason enough to keep a GitHub account. The ecosystem of reusable workflows, the marketplace, the integration with every tool in existence. Woodpecker is good. Actions is a different league in terms of breadth.
But GitHub can change its terms of service. GitHub can sunset features. GitHub can have outages that affect my deployment pipeline. GitHub’s Copilot training controversy showed that “your code on their platform” has implications beyond hosting.
I use both. Deliberately. For different things.
Tips from Running It for a Year
SSH on a non-standard port (222). If you’re running Gitea on a machine that also runs an SSH server — and you probably are — port 22 is taken. Port 222 avoids the conflict. Add it to your ~/.ssh/config so you don’t have to remember:
Host git.argobox.com
Port 222
User git
IdentityFile ~/.ssh/id_ed25519
Behind a reverse proxy with SSL. Always. Gitea serves HTTP on port 3000 by default. Don’t expose that directly. Traefik, Nginx, Caddy — pick one, get a cert, terminate TLS at the proxy. It’s 2025. There’s no excuse for unencrypted Git traffic, even on a LAN.
Disable registration immediately. The default Gitea install allows anyone to register. If you’re self-hosting for personal use, close that door before you even create your admin account. Set DISABLE_REGISTRATION = true in the config. If you forget and find a dozen spam accounts a week later… well, I definitely didn’t learn that one the hard way.
Push mirrors for public repos. Set up Gitea’s built-in push mirror feature or use a dedicated mirror script. The built-in mirror works per-repo and pushes on every change. The script approach gives you more control over which repos mirror and on what schedule. I use the script because I wanted filtering and batch operations, but the built-in option works fine for simpler setups.
Monitor disk space. 63 repos don’t use much space — maybe 2GB total including all the Git history. But if you have repos with large binaries or a lot of LFS objects, disk usage can creep up. I have a simple alert that fires if the Gitea data directory exceeds 80% of its allocated storage. Haven’t triggered it yet. Probably will the day after I stop watching it.
63 repositories. Two organizations. One Docker container with a SQLite database. The entire thing runs on less RAM than a single Chrome tab.
GitHub is a fantastic platform. I use it every day for open source, for collaboration, for the network effects that make software development better. But my infrastructure code, my private projects, my build system configs — those live on hardware I control, on a network I manage, backed up on a schedule I set.
The January 14th outage lasted about two hours. Twitter was full of developers unable to push code, unable to trigger deployments, unable to access their own repositories. I found out about it the next morning over coffee, scrolling through the aftermath.
My repos were fine. My pipelines were fine. My deployments never stopped.
That’s not paranoia. That’s just good engineering.