Setting Up Gitea as a GitHub Backup
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.yamlholds the service entry, Traefik and DNS names for LAN and public hostname, and the hooks for monitoring.procedures/gitea-setup.mdis the human checklist; it stays aligned with what Ansible actually deploys.iac/services/gitea/mirrors.yamllists 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.pytalks 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
Making the PBS NFS mount self-healing: auto-recovery with a systemd timer
The nas-backups datastore went inactive mid-session because the NFS mount had silently died. I fixed the immediate issue and then built a proper recovery layer so it would not happen again.
Replacing Firefly III with Actual Budget
Why I swapped out a finance app I never opened for one I actually use, and how the Docker-in-LXC deployment on Proxmox went.
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.
Ready to Transform Your Career?
Let's work together to unlock your potential and achieve your professional goals.