🔄 Update — June 2026. Gitea is no longer a mirror of GitHub: since the migration, Gitea is the homelab’s source of truth (code, GitOps and CI). The repositories have been converted into primary repos, ArgoCD reads Gitea internally, and GitHub is nothing more than a backup. The pull mirroring described below belongs to the old architecture (which, incidentally, had broken silently). See Migrating a GitOps homelab from GitHub to Gitea.
The bulk mirroring script ran, printed ✓ mirrored: 1, then stopped with no visible error — 22 repositories out of 23 missing, and nothing in the logs to explain why. The cause, found later: a combination of set -e and ((success++)) in bash, a classic trap detailed further down this article. But before getting there, Gitea itself had to be deployed.
Why host your own Git?
Gitea fills several roles in this homelab:
- Local mirror of GitHub: the 23 GitHub repositories are synchronised automatically every 8h. Locally available even without internet.
- Docker image registry: Gitea includes an OCI registry for custom images.
- Local CI/CD: compatible with the GitHub Actions syntax.
- Learning: understanding a Git forge by deploying one yourself.
Kubernetes architecture
Gitea needs the main application plus a PostgreSQL database.
Ingress (traefik)
│
▼
Service gitea :3000
│
▼
InitContainer (busybox, root) Deployment gitea (s6-overlay)
└─ chown /data/git/.ssh ──────────▶ git user (UID 1000)
│
│ DB connection
▼
Service postgres :5432
│
▼
Deployment postgres
│ PVC (Longhorn, 5Gi)
▼
PersistentVolume
Quirk: s6-overlay
Gitea uses s6-overlay as its process supervision system (PID 1). s6-overlay must start as root to initialise the services, then drops to the git user (UID 1000).
Adding runAsNonRoot: true or runAsUser: 1000 to the container securityContext prevents s6-overlay from initialising:
s6-svscan: fatal: unable to chdir to /etc/s6: Permission denied
Correct configuration: do not set runAsUser at container level. Use only fsGroup: 1000 at pod level.
spec:
securityContext:
fsGroup: 1000 # ✅ /data belongs to the git group
containers:
- name: gitea
# No container-level securityContext
Trap: SSH permissions at startup
On the first start against an empty volume, Gitea fails with:
permission denied (publickey) # during git SSH operations
The /data/git/.ssh directory does not exist yet on the PVC and must be owned by UID 1000. An InitContainer takes care of it before Gitea starts:
initContainers:
- name: fix-ssh-permissions
image: busybox
command:
- sh
- -c
- mkdir -p /data/git/.ssh && chown -R 1000:1000 /data/git/.ssh && chmod 700 /data/git/.ssh
securityContext:
runAsUser: 0 # root required for chown
volumeMounts:
- name: data
mountPath: /data
Configuration through environment variables
Gitea exposes its entire configuration through GITEA__<section>__<key>:
env:
- name: GITEA__database__DB_TYPE
value: postgres
- name: GITEA__database__HOST
value: postgres.gitea.svc.cluster.local:5432
- name: GITEA__server__DOMAIN
value: gitea.olig-homelab-2025.win
- name: GITEA__server__ROOT_URL
value: https://gitea.olig-homelab-2025.win/
- name: GITEA__mirror__ENABLED
value: "true"
- name: GITEA__mirror__DEFAULT_INTERVAL
value: "8h"
- name: GITEA__service__DISABLE_REGISTRATION
value: "true"
- name: GITEA__service__REQUIRE_SIGNIN_VIEW
value: "true"
DISABLE_REGISTRATION and REQUIRE_SIGNIN_VIEW are critical — a Gitea exposed on the internet without these restrictions is an immediate target.
Bulk mirroring from GitHub
Creating 23 mirrors by hand through the web interface would take an hour. The Gitea API exposes a /api/v1/repos/migrate endpoint that makes it possible to automate everything in a bash script.
The mirror-github-repos.sh script
#!/usr/bin/env bash
set -euo pipefail
GITHUB_USER="${GITHUB_USER:-YellowBeanie}"
GITEA_URL="${GITEA_URL:-https://gitea.olig-homelab-2025.win}"
MIRROR_INTERVAL="${MIRROR_INTERVAL:-8h}"
: "${GITHUB_TOKEN:?GITHUB_TOKEN env var is required}"
: "${GITEA_TOKEN:?GITEA_TOKEN env var is required}"
# Auto-detect the Gitea username from the token (avoids casing issues)
GITEA_USER=$(curl -sf \
-H "Authorization: token $GITEA_TOKEN" \
"$GITEA_URL/api/v1/user" | python3 -c "import sys,json; print(json.load(sys.stdin)['login'])")
# GitHub pagination: fetch every private repo
page=1; repos=()
while true; do
batch=$(curl -sf \
-H "Authorization: Bearer $GITHUB_TOKEN" \
"https://api.github.com/user/repos?per_page=100&page=$page&affiliation=owner")
count=$(echo "$batch" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))")
[ "$count" -eq 0 ] && break
while IFS= read -r repo; do repos+=("$repo")
done < <(echo "$batch" | python3 -c "
import sys, json
for r in json.load(sys.stdin):
print(r['clone_url'], r['name'])")
((page++))
done
success=0; skipped=0; failed=0
for entry in "${repos[@]}"; do
clone_url=$(echo "$entry" | awk '{print $1}')
repo_name=$(echo "$entry" | awk '{print $2}')
# Idempotency check: skip if already present
http_check=$(curl -so /dev/null -w "%{http_code}" \
-H "Authorization: token $GITEA_TOKEN" \
"$GITEA_URL/api/v1/repos/$GITEA_USER/$repo_name")
if [ "$http_check" = "200" ]; then
echo "↩ $repo_name — already exists, skipping"
skipped=$((skipped + 1))
continue
fi
http_code=$(curl -s -o /tmp/gitea_response.json -w "%{http_code}" \
-X POST "$GITEA_URL/api/v1/repos/migrate" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"clone_addr\":\"$clone_url\",\"auth_token\":\"$GITHUB_TOKEN\",
\"repo_name\":\"$repo_name\",\"repo_owner\":\"$GITEA_USER\",
\"mirror\":true,\"mirror_interval\":\"$MIRROR_INTERVAL\",
\"private\":true,\"service\":\"github\"}")
if [ "$http_code" = "201" ]; then
echo "✓ $repo_name"
success=$((success + 1))
else
msg=$(python3 -c "import sys,json; d=json.load(open('/tmp/gitea_response.json')); \
print(d.get('message','unknown error'))" 2>/dev/null || echo "unknown error")
echo "✗ $repo_name — HTTP $http_code: $msg"
failed=$((failed + 1))
fi
done
echo "✓ mirrored: $success | ↩ skipped: $skipped | ✗ failed: $failed"
[ "$failed" -gt 0 ] && exit 1 || exit 0
The script is idempotent: re-running it after an interruption skips the mirrors already created.
Obstacle: Cloudflare Access blocks the API
Gitea is protected by Cloudflare Access (Zero Trust). From the outside, or from a session without a CF cookie, every request to gitea.olig-homelab-2025.win is redirected to the Cloudflare login page:
$ curl -s https://gitea.olig-homelab-2025.win/api/v1/user -H "Authorization: token xxx"
# → 302 redirect to cloudflareaccess.com/cdn-cgi/access/login/...
# curl -f returns exit code 22, Python receives an empty string → JSONDecodeError
Solution for the local network: add an entry to /etc/hosts (Linux/Mac) or C:\Windows\System32\drivers\etc\hosts (Windows) pointing straight at Traefik’s IP:
10.0.0.22 gitea.olig-homelab-2025.win
On the LAN, DNS resolves to Traefik directly — Cloudflare is never involved. Run the script with GITEA_URL=http://gitea.olig-homelab-2025.win (HTTP, the ingress does not terminate TLS locally).
Gitea token scopes
The API token needs both scopes:
read:user— required by/api/v1/userfor username auto-detectionwrite:repository— required by/api/v1/repos/migrateto create the mirrors
A write:repository-only token returns HTTP 403 on /api/v1/user. With curl’s -f flag, that 403 produces empty output → JSONDecodeError in Python.
Bash traps encountered
CRLF on Windows: Git on Windows converts line endings LF → CRLF by default. A bash script with CRLF fails silently or with cryptic errors. Fix: add a .gitattributes at the root of the repo:
*.sh text eol=lf
* text=auto
Then renormalise the existing files:
git add --renormalize .
git add gitea/mirror-github-repos.sh
git commit -m "chore: re-normalize shell scripts to LF"
set -e + ((counter++)) = silent exit: in bash, (( expression )) returns exit code 1 when the expression evaluates to 0. With set -e, ((success++)) when success=0 kills the script after the first success — exactly what happened: only the first repo got mirrored.
# ❌ Kills the script when the counter is 0
((success++))
# ✅ Always safe
success=$((success + 1))
Synchronised development workflow
Here is how the three environments (local PC, Gitea, GitHub) stay in sync:
⚠️ Historical diagram — pre-migration architecture, obsolete since 2026-06-18. See the update at the top of the article.
┌──────────────────────────────────────────────────────┐
│ Local PC │
│ git push origin → GitHub (remote "origin") │
│ git push gitea → Gitea (remote "gitea", optional) │
│ │
│ Direct access (LAN) through the hosts file: │
│ 10.0.0.22 → gitea.olig-homelab-2025.win │
└──────────────┬───────────────────────────────────────┘
│ git push
▼
┌──────────────────────────┐
│ GitHub │
│ (source of truth) │
│ │
│ GitHub Actions: │
│ push → build Docker │
│ → ghcr.io/yellowbeanie/ │
└─────────────┬────────────┘
│ mirror pull (every 8h)
▼
┌──────────────────────────┐ ┌─────────────────────┐
│ Gitea │ │ ArgoCD │
│ gitea.olig-homelab-.. │ │ │
│ 23 mirrored repos │ │ watches GitHub │
│ Cloudflare Access │ │ deploys to k3s │
│ (external) or │ │ when a manifest │
│ Traefik direct (LAN) │ │ changes │
└──────────────────────────┘ └─────────────────────┘
For applications (e.g. the blog) the CI/CD flow is:
⚠️ Historical diagram — pre-migration architecture, obsolete since 2026-06-18. See the update at the top of the article.
git push actual-state
│
▼ GitHub Actions (blog.yml)
│ - QEMU + Buildx (linux/arm64)
│ - docker build → ghcr.io/yellowbeanie/homelab-blog:latest
▼
ArgoCD detects the image change (3min polling)
│
▼
kubectl rollout → blog pod on k3s
│
▼
blog.olig-homelab-2025.win (Traefik → pod)
For Kubernetes manifests (e.g. a new service):
⚠️ Historical diagram — pre-migration architecture, obsolete since 2026-06-18. See the update at the top of the article.
Edit gitea/deploy-gitea.yaml
│
▼ git push
│
ArgoCD automatic sync (or manual)
│
▼
kubectl apply → k3s
git push y dépose le code, puis deux flux divergent —
le miroir pull toutes les 8h alimente Gitea, et ArgoCD déploie sur k3s
dès qu'un manifest change.
Trap: PostgreSQL
PostgreSQL must start as root for initdb. Do not set a securityContext on the postgres container — the postgres:16-alpine image handles the transition itself.
If the postgres pod crashed several times before initialisation completed, the password hash can end up inconsistent:
FATAL: password authentication failed for user "gitea"
Fix through kubectl exec:
kubectl exec -n gitea deployment/postgres -- \
psql -U gitea -d gitea -c \
"ALTER USER gitea WITH PASSWORD 'new-password';"
Then update the matching SealedSecret and restart Gitea.
Update 2026-05-12 — Proxy headers behind Traefik
The problem: Gitea, sitting behind Traefik (which terminates TLS through the Cloudflare Tunnel), was serving http:// URLs instead of https:// in its notifications, its redirects, and the git clone URL displayed in the UI. Concrete symptoms: HTTPS clones failed because Gitea redirected to the HTTP port, and webhooks were generated as http://.
Root cause: Gitea did not trust the X-Forwarded-Proto: https headers sent by Traefik. Those headers arrive from an IP in the pod CIDR (10.42.0.0/16), not from an IP explicitly declared as trusted in the Gitea configuration — so they were silently ignored.
Fix (commit 379a887 fix(gitea): trust pod CIDR proxy headers to fix HTTPS scheme detection): declare the pod CIDR as a trusted proxy. Concretely, in the gitea-config ConfigMap (or app.ini):
[server]
PROTOCOL = http
DOMAIN = gitea.olig-homelab-2025.win
ROOT_URL = https://gitea.olig-homelab-2025.win/
REVERSE_PROXY_TRUSTED_PROXIES = 10.42.0.0/16
REVERSE_PROXY_AUTHENTICATION_HEADER = X-Forwarded-User
REVERSE_PROXY_LIMIT = 1
The lesson: any service that builds absolute URLs must be able to recognise the scheme of the proxy in front of it. It is the same trap as with WordPress, GitLab or Mattermost — always think about REVERSE_PROXY_TRUSTED_PROXIES (or its equivalent: trusted_proxies in WP, nginx_listen_port in GitLab) as soon as a reverse proxy terminates TLS.
Lessons learned
Always debug curl manually before writing a script: a curl -sv URL reveals in 10 seconds whether the problem is DNS, TLS, auth, or content. The -f flag hides HTTP errors — never use it without capturing the response body alongside.
Test token scopes: modern APIs use granular scopes. Check every endpoint manually with curl -w "%{http_code}" before wiring it into a script.
set -euo pipefail is your friend… and your enemy: this set is essential for robust scripts, but it exposes silent bugs in common bash constructs ((( ++ )), pipelines, subshells). Always test the script with counters at 0.
.gitattributes files save hours: on a mixed Linux/Windows team, or as soon as a bash script lives in a repo cloned on Windows, a .gitattributes with *.sh text eol=lf avoids hours of debugging \r: command not found.