--- title: "Self-Hosting Gitea and Automating GitHub Mirrors" description: "Self-Hosting Gitea and Automating GitHub Mirrors (and Everything Cloudflare Broke Along the Way)" date: "2026-08-19" tags: ["self-hosting", "gitea", "dokploy", "cloudflare", "github-actions", "bash"] --- I run a Hetzner box with [Dokploy](https://docs.dokploy.com) for most of my personal infrastructure. I'd been meaning to get a self-hosted git server on it for a while, mainly so I have a durable mirror of my GitHub repos that doesn't depend on GitHub staying up, staying free, or staying interested in hosting my code. This is the log of actually doing it: what I picked, what broke, and the debugging trail for the parts that took longer than they should have. Full script and workflow are on [github.com/andwati/gitea-mirror-sync](https://github.com/andwati/gitea-mirror-sync) if you want to skip straight to the code. ## Why not GitLab GitLab CE was my first instinct. It's the "serious" self-hosted option, and Dokploy ships a template for it out of the box. But the omnibus image bundles Postgres, Redis, Sidekiq, Puma, Gitaly, Workhorse, and a Prometheus stack into a single container, with an official minimum of 4GB RAM. My box has 8GB total, already shared with a handful of other services. Giving GitLab half the machine just to get merge requests and a UI I mostly wouldn't use felt like the wrong trade. Gitea is a single Go binary. Idle footprint is a few hundred MB against GitLab's multiple gigabytes, boot time is seconds instead of minutes, and there's no `trusted_proxies` CIDR guessing or five-minute health check grace periods to plan around. For a personal git server, it's the right-sized tool. The trade-off is CI maturity (Gitea Actions is newer than GitLab CI) and no built-in SAST/DAST scanning, neither of which I actually needed here. ## The stack Gitea and Postgres, both in one Dokploy compose file, with everything pushed out to environment variables so the compose itself never needs touching again: ```yaml services: gitea: image: docker.gitea.com/gitea:${GITEA_IMAGE_TAG} restart: unless-stopped depends_on: postgres: condition: service_healthy environment: - USER_UID=${GITEA_USER_UID:-1000} - USER_GID=${GITEA_USER_GID:-1000} - GITEA__database__DB_TYPE=${GITEA_DB_TYPE} - GITEA__database__HOST=${GITEA_DB_HOST}:${POSTGRES_PORT} - GITEA__database__NAME=${POSTGRES_DB} - GITEA__database__USER=${POSTGRES_USER} - GITEA__database__PASSWD=${GITEA_DB_PASSWORD} - GITEA__server__DOMAIN=${GITEA_HOST} - GITEA__server__ROOT_URL=${GITEA_PROTOCOL:-https}://${GITEA_HOST}/ - GITEA__server__HTTP_PORT=${GITEA_HTTP_PORT:-3000} - GITEA__server__PROTOCOL=${GITEA_INTERNAL_PROTOCOL:-http} - GITEA__server__DISABLE_SSH=true - GITEA__server__SSH_PORT=${GITEA_SSH_EXTERNAL_PORT:-2222} - GITEA__server__SSH_LISTEN_PORT=${GITEA_SSH_LISTEN_PORT:-22} - GITEA__server__REVERSE_PROXY_TRUSTED_PROXIES=${GITEA_TRUSTED_PROXIES:-172.16.0.0/12,10.0.0.0/8} - GITEA__mailer__ENABLED=${GITEA_MAILER_ENABLED:-true} - GITEA__mailer__PROTOCOL=${GITEA_MAILER_PROTOCOL:-smtp+starttls} - GITEA__mailer__SMTP_ADDR=${GITEA_MAILER_SMTP_ADDR:-smtp.gmail.com} - GITEA__mailer__SMTP_PORT=${GITEA_MAILER_SMTP_PORT:-587} - GITEA__mailer__USER=${GMAIL_ADDRESS} - GITEA__mailer__PASSWD=${GMAIL_APP_PASSWORD} - GITEA__mailer__FROM=${GITEA_MAILER_FROM:-${GMAIL_ADDRESS}} - GITEA__mirror__ENABLED=${GITEA_MIRROR_ENABLED:-true} - GITEA__mirror__DEFAULT_INTERVAL=${GITEA_MIRROR_DEFAULT_INTERVAL:-8h} - GITEA__mirror__MIN_INTERVAL=${GITEA_MIRROR_MIN_INTERVAL:-10m} - GITEA__packages__ENABLED=${GITEA_PACKAGES_ENABLED:-true} - GITEA__service__DISABLE_REGISTRATION=${GITEA_DISABLE_REGISTRATION:-true} - GITEA__service__REQUIRE_SIGNIN_VIEW=${GITEA_REQUIRE_SIGNIN_VIEW:-false} - GITEA__other__SHOW_FOOTER_VERSION=false volumes: - gitea-data:/data ports: - "${GITEA_SSH_EXTERNAL_PORT:-2222}:${GITEA_SSH_LISTEN_PORT:-22}" expose: - "${GITEA_HTTP_PORT:-3000}" healthcheck: test: ["CMD", "wget", "-q", "--spider", "http://localhost:${GITEA_HTTP_PORT:-3000}/api/healthz"] interval: ${GITEA_HEALTHCHECK_INTERVAL:-15s} timeout: ${GITEA_HEALTHCHECK_TIMEOUT:-5s} retries: ${GITEA_HEALTHCHECK_RETRIES:-10} start_period: ${GITEA_HEALTHCHECK_START_PERIOD:-30s} postgres: image: docker.io/library/postgres:${POSTGRES_IMAGE_TAG:-17-alpine} restart: unless-stopped environment: - POSTGRES_USER=${POSTGRES_USER} - POSTGRES_PASSWORD=${GITEA_DB_PASSWORD} - POSTGRES_DB=${POSTGRES_DB} volumes: - pg-data:/var/lib/postgresql/data expose: - "${POSTGRES_PORT}" healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] interval: ${POSTGRES_HEALTHCHECK_INTERVAL:-10s} timeout: ${POSTGRES_HEALTHCHECK_TIMEOUT:-5s} retries: ${POSTGRES_HEALTHCHECK_RETRIES:-5} volumes: gitea-data: {} pg-data: {} ``` The GitHub Actions workflow that runs the mirror script: ```yaml name: Mirror GitHub repos to Gitea on: schedule: # Runs at 00:00 UTC daily. Midnight EAT (UTC+3) would be "0 21 * * *" the day before. - cron: "0 0 * * *" workflow_dispatch: inputs: debug_http: description: "Print request/response headers for every Gitea write call" type: boolean default: false jobs: mirror: runs-on: ubuntu-latest steps: - name: Checkout (for the script) uses: actions/checkout@v5 - name: Install jq run: sudo apt-get update && sudo apt-get install -y jq # The mirror runs from a GitHub-hosted runner, not the network I usually test from, # so it can hit Gitea through a different proxy path than my laptop does. This POSTs # a harmless JSON body and reports whether Content-Type survived the trip. A 422 # "Empty Content-Type" here means something between the runner and Gitea is dropping # the header, not that the mirror payload itself is wrong. - name: Preflight - can this runner POST JSON to Gitea? env: GITEA_URL: ${{ secrets.GITEA_URL }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} run: | url="${GITEA_URL%/}" echo "GET /api/v1/version -> $(curl -s -o /dev/null -w '%{http_code}' \ -H "Authorization: token ${GITEA_TOKEN}" "${url}/api/v1/version")" for proto in --http2 --http1.1; do code=$(curl -s $proto -o /tmp/pre.json -w '%{http_code}' \ -X POST "${url}/api/v1/markdown" \ -H "Authorization: token ${GITEA_TOKEN}" \ -H "Content-Type: application/json" \ -H "Expect:" \ --data-binary '{"text":"preflight","mode":"markdown"}') echo "POST /api/v1/markdown ${proto} -> ${code}" [ "$code" = "200" ] || head -c 300 /tmp/pre.json; echo done - name: Mirror all repos to Gitea env: GITHUB_TOKEN: ${{ secrets.GH_PAT }} GITEA_URL: ${{ secrets.GITEA_URL }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_OWNER: ${{ secrets.GITEA_OWNER }} DEBUG_HTTP: ${{ inputs.debug_http && '1' || '' }} run: ./mirror-github-to-gitea.sh ``` A few decisions worth explaining. **SSH on 2222, not 22.** The host's own sshd already owns port 22, so Gitea's internal SSH got mapped to 2222, with [`SSH_PORT`](https://docs.gitea.com/administration/config-cheat-sheet/) set to match so Gitea generates correct clone URLs. **Traefik/Dokploy terminates TLS, Gitea speaks plain HTTP internally.** [Dokploy's domain manager](https://docs.dokploy.com/docs/core/domains) handles the Let's Encrypt cert and routes to the container on port 3000. `REVERSE_PROXY_TRUSTED_PROXIES` is set so Gitea trusts the forwarded headers instead of thinking every request comes from the internal Docker network. **Mail via Gmail SMTP**, gated behind an App Password since Google dropped plain password auth for SMTP a while back. **Everything as env vars with sane defaults**, so the only things I actually type into Dokploy per deploy are the domain, the DB password, and the mail credentials. ## Token scopes Two tokens drive the mirror script, and both need to be scoped correctly or the migrate call fails in ways that look unrelated to permissions. ### **GitHub token (`GH_PAT`).** A classic Personal Access Token with the `repo` scope, generated at [github.com/settings/tokens](https://github.com/settings/tokens). This is the scope GitHub's own docs point to for reading private repositories, and it's also what Gitea's own migration guide recommends when the source is GitHub. A fine-grained token works too, provided it has read access to Contents and Metadata across all the repos you want mirrored, but classic `repo` is simpler to reason about for a personal account with dozens of repos rather than picking them one by one. ### **Gitea token (`GITEA_TOKEN`).** Needs the `write:repository` scope, generated from your Gitea instance at Settings → Applications → Generate New Token (`https:///user/settings/applications`). `write:repository` covers both the `/repos/migrate` call that creates new mirrors and the `PATCH` call the script uses to sync visibility on existing ones. `read:repository` alone isn't enough since both of those are write operations. ### **Where they get set.** Both live as encrypted secrets on the `gitea-mirror-sync` repo, not in the script or the workflow file: Settings → Secrets and variables → Actions → New repository secret, alongside `GITEA_URL` and `GITEA_OWNER`. The workflow references them only as `${{ secrets.GH_PAT }}` and `${{ secrets.GITEA_TOKEN }}`, so neither value is ever visible in a log or checked into the repo itself. ## Bot Fight Mode: the wall you can't route around The bot-challenge problem looked like a quick fix at first. Add a WAF custom rule that matches the hostname and skips security checks: Bot Fight Mode, managed rules, browser integrity check, all of it. That got a plain `curl` from my own machine through cleanly. It did nothing for the GitHub Actions runner, which kept getting served a JS challenge page on every write request. The reason turned out to be a real platform limit, not a rule I'd written wrong. Cloudflare's free-tier [Bot Fight Mode](https://developers.cloudflare.com/bots/get-started/bot-fight-mode/) doesn't run on the Ruleset Engine at all. It's a separate evaluation pipeline where `Skip`, `Bypass`, and `Allow` actions have no effect. Only Super Bot Fight Mode, which needs a Pro plan, runs on the Ruleset Engine and actually respects skip rules. On Free there's no way to carve out an exception for a specific host or path. It's off or it's on for the whole zone. For a git server whose traffic is almost entirely programmatic (API calls, `git clone`, CI runs, registry pushes), Bot Fight Mode was fighting the exact use case the server exists for. I turned it off zone-wide (Security → Settings → Bot traffic) rather than pay for Pro just to get a skip rule that would only ever have applied to this one subdomain anyway. ## The 422 that took a while to track down With Cloudflare out of the way, the mirror script started reaching Gitea and immediately failed every single repo with: ``` {"message":"[]: Empty Content-Type","url":".../api/swagger"} ``` `Content-Type: application/json` was set explicitly on every `curl` call, so the error made no sense at first glance. The actual cause was structural. The migrate request was built with `curl -d @- <\n`, and that bare newline terminates the header block early. Every header written after it, `Content-Type` included, lands in the request body instead of staying a header. GETs survive this because nothing they need comes after `Authorization`. Writes don't. The final version of the script trims all four credentials on load, warns if any of them needed trimming, and falls back to retrying over HTTP/1.1 if a write still comes back with "Empty Content-Type," since some reverse proxies handle HTTP/2 header framing differently than HTTP/1.1. ## What the script does now It pages through `/user/repos` on GitHub, not the public-only `/users/{user}/repos`, so private repos are included. For each one: - **Doesn't exist in Gitea yet** → migrates it as a pull mirror, matching GitHub's visibility (`private` flows straight through). - **Already mirrored, visibility has drifted** → `PATCH`es the Gitea repo to match GitHub's current visibility. Covers the case where a repo gets made private on GitHub after the mirror was first created. - **Already mirrored, visibility matches** → no-op. It also picked up an `ONLY=` escape hatch for smoke-testing one repo before running the whole account through it, and a `DEBUG_HTTP=1` flag that dumps redacted request and response headers when something's misbehaving. The [workflow](https://github.com/andwati/gitea-mirror-sync/blob/main/.github/workflows/mirror-to-gitea.yml) runs it daily on a [cron schedule](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule), with a `workflow_dispatch` trigger for manual runs, plus a preflight step that POSTs a harmless request to Gitea's markdown-render endpoint first and reports whether `Content-Type` survived the trip. If Cloudflare or the proxy chain ever regresses again, the workflow fails fast on the preflight instead of quietly mangling every repo migration. ## Takeaways - **Resource-match the tool to the box.** GitLab is the obvious choice and the wrong one for an 8GB personal server running other things too. Gitea's footprint fits reality. - **Cloudflare's proxy has real, non-obvious interactions with anything that isn't a browser.** TLS issuance and bot protection both assume the traffic model is "human on a webpage." A git server's actual traffic model is closer to machine talking to machine, on a schedule, indefinitely. - **Free-tier Bot Fight Mode is not configurable, full stop.** If your infrastructure sees more API and CI traffic than browser traffic, know going in that the free bot protection can't be scoped down. It's off or it's Pro. - **Heredocs and piped `while read` loops don't mix well when something inside the loop also wants stdin.** Process substitution sidesteps the whole category of bug. - **Trim your secrets.** A trailing newline in a pasted token is invisible in every UI that shows it back to you, and it will produce an error message that points nowhere near the actual cause.