Mirroring GitHub to Codeberg Without a Third-Party Action

Category: On-Premises & Private Cloud

Tags: podman, github-actions


I wanted a second home for my repository in case GitHub ever went away — or went weird — and Codeberg was the obvious pick. What I didn’t want was another marketplace action in the middle of that path. A plain git push over SSH turns out to be enough.

This post documents the full setup: a dedicated SSH key, the right secrets in the right places, and a workflow that pushes only real branches and tags — no stray origin/* remote-tracking refs cluttering the mirror.

The goal

  • Mirror from GitHub to Codeberg on every push to main.
  • No third-party dependency in the workflow.
  • Only branches and tags — a clean repo, no origin/* refs leaking over.

A dedicated SSH key

The mirror should not reuse any existing key. I generated a fresh ed25519 pair locally, scoped to this one purpose:

ssh-keygen -t ed25519 -C "github-actions-codeberg-mirror" -f ~/.ssh/codeberg_mirror -N ""

Keys in the right places

The two halves of the pair go to opposite sides — and getting them mixed up is the single most common way this fails.

  • Codeberg Deploy Key (Repository → Settings → Deploy keys): paste the content of ~/.ssh/codeberg_mirror.pub. Enable Allow write access — without it the mirror push is read-only.
  • GitHub Secret (Settings → Secrets and variables → Actions): create CODEBERG_SSH with the content of ~/.ssh/codeberg_mirror — the private key.

If a key form shows Key is invalid. You must supply a key in OpenSSH public key format, the private key landed where the public one belongs. Swap them.

The workflow

File: .github/workflows/sync-mirror.yml:

name: 🪞 Mirror to Codeberg
on:
  push:
    branches: [main]
  workflow_dispatch:
  schedule:
    - cron: "30 0 * * 0"

jobs:
  codeberg:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Configure SSH
        run: |
          mkdir -p ~/.ssh
          printf '%s\n' "${{ secrets.CODEBERG_SSH }}" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          ssh-keyscan -H codeberg.org >> ~/.ssh/known_hosts
          cat <<'EOF' > ~/.ssh/config
          Host codeberg.org
            HostName codeberg.org
            User git
            IdentityFile ~/.ssh/id_ed25519
            IdentitiesOnly yes
          EOF

      - name: Verify SSH access
        run: |
          ssh -T git@codeberg.org || true
          git ls-remote git@codeberg.org:adrian-altner/website.git > /dev/null

      - name: Mirror to Codeberg
        run: |
          git remote add mirror git@codeberg.org:adrian-altner/website.git

          # Remove previously mirrored remote-tracking refs (for example refs/remotes/origin/*).
          while IFS= read -r ref; do
            git push mirror ":${ref}"
          done < <(git ls-remote --refs mirror 'refs/remotes/*' | awk '{print $2}')

          # Mirror only real branches and tags.
          git push --prune mirror \
            +refs/heads/*:refs/heads/* \
            +refs/tags/*:refs/tags/*

Three triggers cover the bases: every push to main, a manual workflow_dispatch button, and a weekly cron on Sunday at 00:30 UTC — the cron is the belt to the push-based suspenders, in case a Codeberg outage ever swallows a push silently.

The mirror step does two things in order. First it clears any refs/remotes/* that slipped into earlier runs — I had a few refs/remotes/origin/* on Codeberg from an older setup and they kept re-appearing. Then it pushes refs/heads/* and refs/tags/* explicitly, with --prune, so deletions on GitHub propagate rather than accumulate.

Expected behaviour during testing

On a successful SSH handshake Forgejo (which Codeberg runs) prints:

... successfully authenticated ... but Forgejo does not provide shell access.

That’s not an error — it’s the daemon refusing a shell while still happily handling fetch and push. If you see it, auth worked.

What to take away

  • No marketplace action needed. git push over SSH plus a deploy key covers the mirror cleanly.
  • Keep the keypair dedicated. One ed25519 key, one purpose — revoking it never affects anything else.
  • Push explicit refspecs, not --mirror. +refs/heads/*:refs/heads/* and +refs/tags/*:refs/tags/* keep the destination clean; --mirror would carry over every remote-tracking ref you happen to have.
  • Belt and suspenders on the schedule. Push trigger for latency, weekly cron as a safety net.