Mikael Bendiksen Mikael's BrainDump

A place to put my ideas

Froste

The Great GitLab Migration

Three years ago, I found myself staring at a migration to a new GitLab server from a old GitLab server. The old was slow and had been running longer than some of my production services. The kind of server that had seen more commits than a politician’s promise list, and was starting to show its age like a sysadmin who’s been on-call for too long. It was time for a migration.

The Problem: GitLab Server Aging Like Fine Wine (But Not in a Good Way)

My original GitLab instance (repo01.bendiksens.net) had been faithfully serving my repositories for what felt like geological epochs. But like any good sysadmin, I knew the signs: slower response times, occasional hiccups, and that nagging feeling that the server was plotting against me. Plus, I had this shiny new server (repo02.bendiksens.net) just sitting there, begging to be put to work.

The challenge? Migrating dozens of repositories without losing commit history, branches, or my sanity. GitLab’s built-in import/export feature is great for individual projects, but when you’re dealing with a whole ecosystem of interconnected repos, you need something more surgical.

Image Description

The Solution: Python Scripts and Git Config Hacks

Enter the migration script. A beautiful piece of Python code that’s part automation, part digital archaeology, and entirely necessary when you’re dealing with more repositories than you care to admit.

import os
import requests
import subprocess
import fileinput

url = "https://repo02.bendiksens.net/api/v4/projects/user/2"
token = "TOKEN HERE"
headers = {
  "PRIVATE-TOKEN": token
}

os.chdir('/home/duux/migrategitlab')
all_subdirs = [d for d in os.listdir('.') if os.path.isdir(d)]
for dirs in all_subdirs:

    # create repo on server
    payload = {"name" : dirs}
    response = requests.request("POST", url, headers=headers, data=payload)
    print(payload)

    for line in fileinput.input([f"/home/duux/migrategitlab/{dirs}/.git/config"], inplace=True):
        print(line.replace('https://repo01.bendiksens.net/', '[email protected]:'), end='')

    workdir = f"/home/duux/migrategitlab/{dirs}/"
    p = subprocess.Popen(["git", "push", "-u", "origin"], cwd=workdir)
    p.wait()

This script does three things that would make any DevOps engineer smile:

  1. Creates new repositories on the target server using GitLab’s API
  2. Modifies git config files in-place to point to the new server
  3. Pushes all branches to the new location

The beauty of this approach is that it preserves everything: commit history, branches, tags, and that one commit message from after midnight that you’re still embarrassed about and didn’t knew how to squash.

Downside? You need to have all your repos already on your machine.

The Migration Process: Like Herding Cats, But Digital

The actual migration was surprisingly smooth, which is always suspicious in IT. Here’s what happened:

  1. Clone all repositories from the old server to a staging area manually
  2. Run the migration script to create new repos and update remotes
  3. Push everything to the new server
  4. Update CI/CD pipelines to point to the new server
  5. Test everything until your eyes bleed

The git config modification was the real MVP here. By changing the remote URL from https://repo01.bendiksens.net/ to [email protected]:, we essentially told git “hey, your new home is over there” without having to manually reconfigure each repository.

Fast Forward Three Years: Enter Gitea

Now, three years later, I’m planning another migration. This time, it’s not just about moving servers - it’s about moving to a completely different platform: Gitea.

Why Gitea? Because it’s faster than a sysadmin running to fix a production outage. Seriously, the performance difference is noticeable. GitLab is like a Swiss Army knife - it does everything, but sometimes you just need a really good knife.

Gitea is lightweight, fast, and doesn’t require a server farm to run. It’s like the difference between running a full Kubernetes cluster and a simple Docker container. Sometimes simpler is better, especially when you’re running everything on your own infrastructure.

Image Description

The Gitea Migration Script: Evolution of the Migration Tool

Fast forward to today, and I’m preparing for the Gitea migration. The script has evolved, but the core principles remain the same. Here’s the updated version for Gitea:

import os
import requests
import subprocess
import fileinput
import json

# Gitea API configuration
gitea_url = "http://192.168.0.236:3000/api/v1"
gitea_ip = "192.168.0.236"
gitlab_url = "https://git.bendiksens.net/api/v4"
headers_gitea = {
    "Authorization": f"token {token_gitea}",
    "Content-Type": "application/json"
}
# Gitlan API configuration
token_gitlab = "GITLAB_TOKEN"
old_gitlab_user = "unk1nd"
token_gitea = "GITEA_TOKEN"
headers_gitlab = {
    "PRIVATE-TOKEN": token_gitlab
}

# User/Organization where repos will be created
username = "duux"  # or your organization name

def get_old_repos():
    response = requests.get(
        f"{gitlab_url}/projects?per_page=100",
        headers=headers_gitlab
    )
    if response.status_code == 200:
        print(f"Fetched repo list from {gitlab_url}")
        data = response.json()
        print(f"Found {len(data)} repos")
        print(data)
        return data
    else:
        print(f"Failed to pull {gitlab_url}: {response.status_code} - {response.text}")
        return None
    
def pull_old_repo(repo_url):
    try:
        repo_url = repo_url.replace("git.bendiksens.net", "repo02.bendiksens.net")
        subprocess.run(["git", "clone", repo_url], check=True)
        print(f"Successfully pulled {os.path.basename(repo_url)}")
        return True
    except subprocess.CalledProcessError as e:
        print(f"Failed to pull {os.path.basename(repo_url)}: {e}")
        return False

def create_gitea_repo(repo_name, description=""):
    """Create a new repository in Gitea"""
    payload = {
        "name": repo_name,
        "description": description,
        "private": True,  # Set to False for public repos
        "auto_init": False,  # Don't initialize with README
        "gitignores": "",
        "license": "",
        "readme": ""
    }
    
    response = requests.post(
        f"{gitea_url}/user/repos",
        headers=headers_gitea,
        json=payload
    )
    
    if response.status_code == 201:
        print(f"Created repository: {repo_name}")
        return response.json()
    else:
        print(f"Failed to create {repo_name}: {response.status_code} - {response.text}")
        return None

def update_git_remote(repo_path, new_remote_url):
    """Update the git remote URL in the repository"""
    config_file = os.path.join(repo_path, ".git", "config")
    
    if not os.path.exists(config_file):
        print(f"Git config not found in {repo_path}")
        return False
    
    try:
        for line in fileinput.input([config_file], inplace=True):
            # Replace the old GitLab URL with new Gitea URL
            new_line = line.replace(
                f'[email protected]:{old_gitlab_user}',
                f'git@{gitea_ip}:{username}'
            )
            print(new_line, end='')
        return True
    except Exception as e:
        print(f"Error updating git config in {repo_path}: {e}")
        return False

def push_to_gitea(repo_path):
    """Push all branches and tags to Gitea"""
    try:
        # Push all branches
        subprocess.run(["git", "push", "--all", "origin"], 
                      cwd=repo_path, check=True)
        
        # Push all tags
        subprocess.run(["git", "push", "--tags", "origin"], 
                      cwd=repo_path, check=True)
        
        print(f"Successfully pushed {os.path.basename(repo_path)}")
        return True
    except subprocess.CalledProcessError as e:
        print(f"Failed to push {os.path.basename(repo_path)}: {e}")
        return False

def main():
    """Main migration function"""
    # Change to the directory containing all repositories
    migration_dir = "/tmp/migrategitea"
    os.chdir(migration_dir)

    # pull down old repo
    old_repo_list = get_old_repos()
    for repo in old_repo_list:
        if old_gitlab_user in repo["path_with_namespace"]:
            pull_old_repo(repo["ssh_url_to_repo"])

    # Get all subdirectories (repositories)
    repos = [d for d in os.listdir('.') if os.path.isdir(d) and 
             os.path.exists(os.path.join(d, '.git'))]
    
    print(f"Found {len(repos)} repositories to migrate")
    
    successful_migrations = 0
    
    for repo_name in repos:
        print(f"\n--- Migrating {repo_name} ---")
        
        # Create repository in Gitea
        repo_info = create_gitea_repo(repo_name)
        if not repo_info:
            continue
        
        # Update git remote
        if not update_git_remote(repo_name, repo_info['clone_url']):
            continue
        
        # Push to Gitea
        if push_to_gitea(repo_name):
            successful_migrations += 1
    
    print(f"\n=== Migration Complete ===")
    print(f"Successfully migrated: {successful_migrations}/{len(repos)} repositories")

if __name__ == "__main__":
    main()

This evolved script includes several improvements over the original:

  1. Better error handling - Each step is wrapped in try-catch blocks
  2. Uses GitLab API - To tech all repos and pull them down
  3. Progress tracking - Shows success/failure for each repository
  4. Gitea-specific API - Uses Gitea’s REST API instead of GitLab’s
  5. Flexible configuration - Easy to modify for different usernames/organizations
  6. Tag support - Pushes both branches and tags
  7. Status reporting - Provides a summary at the end

The key differences from the GitLab version:

  • Uses Gitea’s API endpoint structure (/api/v1/user/repos)
  • Different authentication header format (Authorization: token)
  • More comprehensive repository creation options
  • Better error handling and reporting
  • Pulls down all repos from GitLab

Get out token in Gitea

From the Gitea UI you can head to /user/setting/applications/ and you can create a API token to use. Remember to remove it after.

Image Description

Get token from Gitlab

We also need a token to use with Gitlab. Same as Gitea, head to Gitlab UI at /-/profile/personal_access_tokens and generate one.

Image Description

Test run

Test run with only one repo from gitlab

Fetched repo list from https://git.bendiksens.net/api/v4
Cloning into 'ansible-playbooks'...
remote: Enumerating objects: 475, done.
remote: Counting objects: 100% (71/71), done.
remote: Compressing objects: 100% (56/56), done.
remote: Total 475 (delta 30), reused 35 (delta 15), pack-reused 404
Receiving objects: 100% (475/475), 54.60 KiB | 18.20 MiB/s, done.
Resolving deltas: 100% (195/195), done.
Successfully pulled ansible-playbooks.git
Found 1 repositories to migrate

--- Migrating ansible-playbooks ---
Created repository: ansible-playbooks
Enumerating objects: 475, done.
Counting objects: 100% (475/475), done.
Delta compression using up to 32 threads
Compressing objects: 100% (255/255), done.
Writing objects: 100% (475/475), 54.61 KiB | 54.61 MiB/s, done.
Total 475 (delta 195), reused 475 (delta 195), pack-reused 0
remote: Resolving deltas: 100% (195/195), done.
remote: . Processing 1 references
remote: Processed 1 references in total
To 192.168.0.236:duux/ansible-playbooks.git
 * [new branch]      master -> master
Everything up-to-date
Successfully pushed ansible-playbooks

=== Migration Complete ===
Successfully migrated: 1/1 repositories

Image Description

We are in business!

The New Challenge: GitLab Runners in a Gitea World

Ah, the runners. Those beautiful, hardworking little processes that turn your code into deployable artifacts. GitLab runners are like that friend who always shows up to help move, but then you realize they’re attached to the old house. You can’t just pick them up and move them. They need to be reconfigured, re-registered, and sometimes completely rebuilt. This is where things get interesting. GitLab runners are GitLab-specific, which means they won’t work with Gitea. I need to find alternatives for my CI/CD pipelines.

The options are:

  1. GitHub Actions - Because sometimes you need to embrace the cloud
  2. Jenkins - The old reliable that’s been around longer than some programming languages
  3. Drone CI - Lightweight and perfect for Gitea integration
  4. Buildkite - Because sometimes you want to keep your builds on your own infrastructure
  5. Gitea Runner - Setting up Gitea Runner on all of the places where old Gitlab Runners are running.

Each has its trade-offs, and I’m still evaluating which one will replace my current GitLab runner setup. The goal is to maintain the same level of automation without the overhead of GitLab’s runner system. Until I figure out that part I have set up the binary and connected it to Gitea and rewrote some of the Action YAMLs for my most important repos.

Gitea Actions example

Gitea does not use the same structure as Gitlab CI/CD. First of the .gitlab-ci.yaml file from the root of the repo are replaced with a yaml located in your repo structure like this .gitea/workflows/ci.yaml name of the file can be what ever you need based on how many or function of the action.

In this example i just called mine build_and_deploy.yaml (was listening to Metallica - Seek and Distroy while writing the file.)

name: Build and deploy blog

on:
  push:
    branches: [ master ]

env:
  CI_REGISTRY: r.bendiksens.net
  CI_REGISTRY_USER: duux
  CI_REGISTRY_IMAGE: blog:latest

jobs:
  build:
    runs-on: ubuntu-latest
    timeout-minutes: 60
    steps:
      - name: Check out repository code
        uses: actions/checkout@v4

      - name: Docker Build
        run: | 
          docker build --pull -t ${{ env.CI_REGISTRY_IMAGE }} --no-cache .

      - name: Tag Image
        run: |
          docker image tag ${{ env.CI_REGISTRY_IMAGE }} ${{ env.CI_REGISTRY }}/${{ env.CI_REGISTRY_USER }}/${{ env.CI_REGISTRY_IMAGE }}          

      - name: Push Image to repo
        run: |
          docker image push ${{ env.CI_REGISTRY }}/${{ env.CI_REGISTRY_USER }}/${{ env.CI_REGISTRY_IMAGE }}          

  redeploy:
    needs: build
    runs-on: shell
    steps:
      - name: Trigger redeploy hook
        run: |
          curl http://192.168.0.221:7050/rc?name=blog          

Runner has the following labels:

  • ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest
    • isolated docker container to use with gitea actions)
  • shell:host
    • To run command directly on the host running act-runner

Image Description

Switching from HTTP to HTTPS

Changing to HTTPS and drop the the :3000 port. As I have a own Virtual Machine running the Gitea Binary on Ubuntu I can rsync my wildcard certificate that is generated by LetsEncrypt into the server and edit the app.ini for Gitea.

git@gitea:~$ pwd
/home/git

git@gitea:~$ tree -pu
[drwxr-x--- git     ]  .
└── [drwxr-xr-x root    ]  letsencrypt
    └── [drwxr-xr-x root    ]  bendiksens.net
        ├── [-rw-r--r-- git     ]  cert.pem
        ├── [-rw-r--r-- git     ]  chain.pem
        ├── [-rw-r--r-- git     ]  fullchain.pem
        ├── [-rw------- git     ]  privkey.pem
        └── [-rw-r--r-- git     ]  README

3 directories, 5 files

I did the following changes in the app.ini related to TLS:

[server]
SSH_DOMAIN = gitea.bendiksens.net
PROTOCOL=https
DOMAIN = gitea.bendiksens.net
HTTP_PORT = 443
ROOT_URL = https://gitea.bendiksens.net:443/
CERT_FILE = /home/git/letsencrypt/bendiksens.net/fullchain.pem
KEY_FILE = /home/git/letsencrypt/bendiksens.net/privkey.pem
DISABLE_SSH = false
SSH_PORT = 22

Image Description

Now I have this nice FQDN I can use for both SSH and HTTPS traffic. And not all the quirks of having one for the UI and one for SSH that I ended up using for my old server (git.bendiksens.net VS. repo02.bendiksens.net). Hence why I use 2 different addresses in the migration script.

Lessons Learned and Future Plans

The original migration taught me several valuable lessons:

  1. Always have a rollback plan - Because sometimes the new server is worse than the old one
  2. Test your migration scripts - On a small subset first, because breaking production is bad for job security
  3. Document everything - Because three years later, you’ll forget why you made certain decisions, maybe this blog can help?
  4. Consider the entire ecosystem - It’s not just about the repositories, it’s about the CI/CD, webhooks, and integrations

The Technical Details That Matter

For anyone planning a similar migration, here are the key technical considerations:

Repository Migration:

  • Use git clone --mirror for bare repositories
  • Update remote URLs in .git/config
  • Push all branches and tags with git push --all --tags

CI/CD Migration:

  • Export your current pipeline configurations
  • Map GitLab CI syntax to your new CI system
  • Test thoroughly in a staging environment

Authentication:

  • Update SSH keys and deploy keys
  • Configure new access tokens
  • Update webhook URLs

Conclusion: The Never-Ending Migration Cycle

In the world of self-hosted services, migration is not a one-time event. it’s a lifestyle. Whether you’re moving from one GitLab server to another, or from GitLab to Gitea, the principles remain the same: plan carefully, test thoroughly, and always have a backup plan.

The Python script that made my original migration possible is a testament to the power of automation. It’s not just about saving time. It’s about reducing human error and ensuring consistency across dozens of repositories.

I’m reminded that the best tools are the ones that work for you, not the ones that work for everyone else. Sometimes that means moving from a feature-rich platform to a simpler, faster alternative.

And if there’s one thing I’ve learned from many years of running GitLab, it’s that sometimes the best solution is the one that gets out of your way and lets you focus on what really matters: writing code, not managing infrastructure.

Now, if you’ll excuse me, I have some CI/CD pipelines to migrate and some new servers to configure. Because in the world of DevOps, there’s always another migration waiting around the corner.

BTW, this blog post was pushed over Gitea and the actions that was put in place.

Image Description

Image Description

Image Description