---
title: Mirroring GitHub to Codeberg Without a Third-Party Action
description: A clean repository mirroring setup from GitHub to Codeberg using native GitHub Actions and SSH.
pubDate: '2026-03-05T08:53:00+01:00'
category: en/on-premises-private-cloud
tags:
  - podman
  - github-actions
seriesParent: astro-ssr-with-podman
seriesOrder: 9
---

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:

```bash
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`:

```yaml
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.
