Skip to content
Infrastructure

Setting Up Gitea as a GitHub Backup

By Victor Da Luz
gitea github homelab proxmox ansible traefik backup git

I wanted a reliable mirror for the GitHub repositories that power this homelab. Gitea runs as the backup target, not the primary Git host. GitHub stays canonical for collaboration; the home instance is there if GitHub blips or if I need everything local.

The problem

GitHub is fine day to day, but I do not want a single external dependency for code I rely on to rebuild services. The setup had to support pull mirrors, live inside Proxmox, sit behind Traefik with a real certificate, and stay describable in state so Homepage and Uptime Kuma can point at it without hand-waving.

What I chose

I put Gitea in Docker-in-LXC on node02, gave the stack a static IP on the services VLAN, and exposed it through Traefik using the existing wildcard certificate. The container uses the upstream image with SQLite, with persistence under /opt/gitea/data on the host.

Ansible drives the whole thing: it drops the docker-compose.yml under iac/services/gitea/, creates the data directory with the right ownership, and runs community.docker.docker_compose_v2 so repeats are idempotent. Same pattern as the other Docker-backed services (Homepage, Apprise, and so on).

After the container was up, I finished the first-run installer at the HTTPS URL, created an admin account, and moved on to mirrors.

Gotchas that actually mattered

AppArmor and Docker. Compose needed to tweak ip_unprivileged_port_start under /proc/sys, and the default LXC AppArmor profile blocked that. I fixed it by running the Proxmox container playbook with a permissive profile for that CT (effectively lxc.apparmor.profile: unconfined in the container config) before Gitea would deploy cleanly.

Data directory ownership. Ansible first created /opt/gitea/data as root. The Gitea image runs as UID/GID 1000. SQLite could not create gitea.db until a follow-up task chowned the tree to 1000:1000.

API token scopes. The mirror automation calls user/org APIs, not just repo APIs. The token needs read:user in addition to repository write scope. write:repository alone is not enough.

Mirror interval format. Gitea expects mirror_interval as a string like 240m, not a bare number. The Python helper converts numeric intervals from YAML into that string form so I do not have to remember each time.

Wiring it into the repo

  • state/services.yaml holds the service entry, Traefik and DNS names for LAN and public hostname, and the hooks for monitoring.
  • procedures/gitea-setup.md is the human checklist; it stays aligned with what Ansible actually deploys.
  • iac/services/gitea/mirrors.yaml lists every GitHub repository to mirror. I used the GitHub API once to enumerate repos and generate the file instead of maintaining it by hand.
  • scripts/gitea_mirrors.py talks to Gitea’s migration API: bulk create or update mirrors, enforce the sync interval (I settled on four hours), and trigger a sync on demand. It is idempotent, so re-running after editing the YAML is safe.

Results

After the container profile and filesystem permissions were sorted, I ran the installer, then the mirror script. Everything I care about from GitHub now lives in Gitea and pulls on a schedule. Four hours is a better fit for a backup mirror than the half-hour interval I first considered: less churn on disk and on GitHub’s API, still fresh enough if I need the copy.

The site sits behind Traefik on the usual domain, Uptime Kuma watches it like the rest of the stack, and adding a repo is now “edit YAML, run the script” instead of clicking through the UI twenty-eight times.

Lessons learned

Treat the mirror host like infrastructure: same Ansible patterns as other services, same state files, same monitoring. The sharp edges were policy and ownership, not Gitea itself. If I did it again, I would set the AppArmor expectation and the 1000:1000 ownership in the first revision of the role instead of debugging it in production.

Related reading

Infrastructure

Consolidating audiobooks and ebooks into a single Audiobookshelf

I was running two media servers, Audiobookshelf for audiobooks and Kavita for ebooks, when one could do both. Rebuilding the homelab in v3 was the excuse to merge them: one Ansible-deployed Audiobookshelf, local-disk storage, and a USB-drive ZFS scare in the middle of the migration.

Read

Ready to Transform Your Career?

Let's work together to unlock your potential and achieve your professional goals.