Files
hermes-skills/devops/infrastructure-recon/references/gitlab-to-gitea-migration.md
T

5.7 KiB

GitLab → Gitea Migration Technique

Session: 2026-06-29, migrating 12 repos from GitLab CE 15.8.1 (Docker on 10.0.30.100) to Gitea 1.25.5 (CT on 10.0.30.105).

Scenario

GitLab and Gitea coexist temporarily. DNS (git.familie-schoen.com) has been repointed from GitLab to Gitea. GitLab is still running in a Docker container but no longer reachable via its former domain. Goal: move all repos to Gitea, then decommission GitLab.

Key Challenges & Solutions

Challenge 1: Gitea Migrate API Fails When DNS Repointed

The Gitea /api/v1/repos/migrate endpoint accepts a clone_addr URL. If the GitLab domain now resolves to Gitea (because DNS was repointed), Gitea tries to clone from itself → Authentication failed.

Solution: Use the GitLab container's internal Docker IP instead of the domain.

# Get GitLab container internal IP
docker inspect development-gitlab-1 --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'
# → 172.18.0.5 (and possibly 172.24.0.2 on a second network)

Challenge 2: Gitea Migrate API Creates "Stuck" Repos

Even when the migrate API call fails (authentication error), Gitea creates an empty repo in a permanent "Migrating from..." state. These repos show a migration status page and cannot receive pushes normally.

Solution: Delete the stuck repos via API, recreate as empty repos, then push content via git push --mirror.

# Delete stuck repos
curl -sk -X DELETE -H "Authorization: token $GITEA_TOKEN" \
  https://git.familie-schoen.com/api/v1/repos/$OWNER/$REPO

# Recreate as empty repos (no auto_init)
curl -sk -X POST -H "Authorization: token $GITEA_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"$REPO","private":true,"auto_init":false}' \
  https://git.familie-schoen.com/api/v1/orgs/$OWNER/repos

Challenge 3: GitLab API Needs Token (No Anonymous Access)

With all repos private, the GitLab API returns empty results without auth.

Solution: Create a temporary Personal Access Token via the Rails console inside the GitLab container:

docker exec development-gitlab-1 gitlab-rails runner -e production \
  "u=User.where(admin:true).first; t=u.personal_access_tokens.create(scopes:[:api], name:'migration', expires_at:7.days.from_now); puts t.token"
# → glpat-xxxxxxxxxxxx

Revoke after migration:

docker exec development-gitlab-1 gitlab-rails runner -e production \
  "u=User.where(admin:true).first; u.personal_access_tokens.where(name:'migration').each{|t| t.revoke!}; puts 'revoked'"

Challenge 4: Orgs/Users Must Exist in Gitea First

GitLab repos are namespaced under users/groups (e.g., d.schoen/, codecamp-n/). Gitea needs corresponding orgs before repos can be pushed.

Solution: Create orgs via API before pushing:

curl -sk -X POST -H "Authorization: token $GITEA_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"username":"d.schoen","visibility":"private"}' \
  https://git.familie-schoen.com/api/v1/orgs

Prerequisites

  • SSH access to the host running GitLab container (for git clone --mirror)
  • Gitea API token (admin)
  • GitLab API token (or Rails-console-generated PAT)

Step 1: Create orgs in Gitea

List all GitLab namespace prefixes, create as Gitea orgs if they don't exist.

Step 2: Create empty repos in Gitea

for repo in "${repos[@]}"; do
  owner="${repo%%/*}"
  name="${repo##*/}"
  curl -sk -X POST -H "Authorization: token $GITEA_TOKEN" \
    -H "Content-Type: application/json" \
    -d "{\"name\":\"$name\",\"private\":true,\"auto_init\":false}" \
    https://git.familie-schoen.com/api/v1/orgs/$owner/repos
done

Step 3: Mirror-push from GitLab to Gitea

Run from the host that has the GitLab container (for internal IP access):

GITLAB_INTERNAL_IP="172.18.0.5"
GITLAB_TOKEN="glpat-xxxx"
GITEA_TOKEN="xxxx"
GITEA_INTERNAL="10.0.30.105:3000"

for repo_path in "d.schoen/terraform-proxmox-k8s" "codecamp-n/corporate_website" ...; do
  name="${repo_path##*/}"

  # Clone from GitLab internal IP
  git clone --mirror \
    "http://oauth2:${GITLAB_TOKEN}@${GITLAB_INTERNAL_IP}/${repo_path}.git" \
    "/tmp/migrate_${name}"

  # Push to Gitea internal IP
  cd "/tmp/migrate_${name}"
  git push --mirror \
    "http://dominik:${GITEA_TOKEN}@${GITEA_INTERNAL}/${repo_path}.git"

  # Cleanup
  cd /tmp && rm -rf "migrate_${name}"
done

Key URLs:

  • GitLab clone: http://oauth2:TOKEN@INTERNAL_IP/namespace/repo.git
  • Gitea push: http://USER:TOKEN@GITEA_IP:PORT/namespace/repo.git

Step 4: Verify

# Check each repo has branches and non-zero size
curl -sk -H "Authorization: token $GITEA_TOKEN" \
  https://git.familie-schoen.com/api/v1/repos/$repo_path/branches

Pitfalls

  1. Don't use Gitea migrate API when DNS is repointed — Gitea will clone from itself. Use git push --mirror from a host with internal access to GitLab instead.
  2. Failed migrations leave stuck repos — always delete + recreate empty before retrying.
  3. Empty GitLab repos — some repos may have no commits. git clone --mirror prints "warning: You appear to have cloned an empty repository." These will remain empty in Gitea too — this is expected, not an error.
  4. GitLab token expiry — Rails-console tokens expire. Set a reasonable expires_at and revoke after migration.
  5. Docker network IPs — a container on multiple networks returns multiple IPs separated without newlines. Use the first one or inspect carefully.
  6. Gitea auth for push — use http://USERNAME:TOKEN@host/path.git format (username + token as password).
  7. SSH heredoc through sshpass — when running multi-line scripts via sshpass ssh ... 'bash -s' << 'EOF', use a unique delimiter (e.g., HERMES_EOF) to avoid conflicts with embedded heredocs.