Self-hosted Gitea with GitHub backup: migration lessons

#gitea #dokploy #github #git #self-hosted #karkhana

This post covers the migration of repos from GitHub to a self-hosted Gitea instance. The goal was not to leave GitHub — it’s to have Gitea as the primary (where CI/CD and workflows live) and GitHub as the redundant mirror.

Why self-hosted Gitea

GitHub works fine for public and simple private repos. But for a personal infrastructure that runs apps and services, self-hosting gives meaningfully more control:

  • Gitea Actions for CI/CD, running on the same VPS as Gitea itself — no third-party CI minutes, no separate service to authorize
  • Push-mirrors to GitHub for backup — Gitea is canonical, GitHub is the hot standby
  • Fine-grained access control without GitHub’s org/team model
  • No rate limits on API operations

The platform is Dokploy — a self-hosted alternative to Coolify for managing VPS-hosted services. Gitea runs in a Docker container on a VPS, backed by Postgres.

The migration script

The migration was done with a bulk script that:

  1. Enumerates repos from GitHub via the gh CLI, filtering by last push date and excluding forks/archived
  2. Clones each repo locally via SSH (using gh auth)
  3. Creates the repo on Gitea via the API
  4. Pushes to Gitea
  5. Configures a push-mirror back to GitHub
  6. Optionally applies soft branch protection on the default branch

The script is idempotent — re-running skips repos that already exist on Gitea, so transient failures can be retried safely.

Issues encountered

Gitea migration API couldn’t clone private GitHub repos

Problem: When Gitea tries to clone a private GitHub repo via its migration API, it failed with:

Authentication failed: clone error: exit status 128 -
fatal: could not read Username for 'https://github.com': terminal prompts disabled

Fix: Embed the PAT directly in the clone URL using GitHub’s token auth format:

https://x-access-token:<PAT>@github.com/owner/repo.git

instead of relying on auth_username / auth_password fields which Gitea’s migration API handles inconsistently.

Wrong API endpoint for repo creation

Problem: The script initially used POST /api/v1/repos/<owner> which returns HTTP 404 for non-admin users.

Fix: The correct endpoint is POST /api/v1/user/repos — creates a repo under the authenticated user’s account without needing admin privileges. The owner field in the JSON body is unnecessary for user-level creation.

HTTP 500 treated as hard failure

Problem: Transient Gitea server errors (HTTP 500) caused the script to abort mid-migration, leaving repos in an inconsistent state (empty shell created but no code pushed).

Fix: Treat HTTP 500 as a soft skip, logging to a separate failure file. On retry, the empty shell exists and the script skips the creation step cleanly, resuming from the push.

Push-mirror URL format rejected by Gitea

Problem: Gitea rejects the standard SSH shorthand URL format for push mirrors:

git@github.com:owner/repo.git  →  "Invalid Url"
ssh://git@github.com/owner/repo.git  →  "Invalid mirror protocol"

Fix: Only HTTPS with embedded token works for GitHub push mirrors:

https://x-access-token:<PAT>@github.com/owner/repo.git

The PAT here can be the same one used for migration. The push-mirror is created with sync_on_commit: true, so every push to Gitea immediately triggers a mirror push to GitHub. The 8-hour interval is a fallback, not the primary sync mechanism.

What ended up running

  • Repos migrated to Gitea under the user account
  • Push-mirrors configured back to GitHub for each migrated repo
  • Soft branch protection applied to default branches on GitHub (visible badge, still allows force-push)
  • Gitea version: running in Docker via Dokploy
  • GitHub as mirror: HTTPS token auth, sync_on_commit: true, 8-hour interval fallback

Verification

After migration, I cross-checked branch and tag counts between GitHub and Gitea for each repo — all matched. For a representative multi-branch repo, a manual diff confirmed complete parity including all feature branches.

One remaining gap

The push-mirrors use token-based HTTPS auth, which means the GitHub PAT is stored in Gitea’s database. If Gitea’s database is compromised, the token is exposed. For a personal setup with no other users, this is acceptable — but a more paranoid setup would use deploy keys (one key per repo, read-only on GitHub) instead. Gitea’s current push-mirror implementation doesn’t support deploy keys for GitHub specifically, only for generic git servers.

See also

This post is part of the Karkhana series. Gitea is the git layer of the personal workshop — the place where code lives, CI runs, and from which deployments are triggered. Related posts: