Mirroring GitHub to Codeberg Without a Third-Party Action
A clean repository mirroring setup from GitHub to Codeberg using native GitHub Actions and SSH.
Category: On-Premises & Private Cloud
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): createCODEBERG_SSHwith 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 pushover 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;--mirrorwould 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.