Migrate from GitHub
scripts/migrate-github-to-konnos.sh moves every GitHub organization where you're an admin, plus every personal repo, into a fresh konnos instance. Single bash script, dry-run mode, idempotent on re-run, NDJSON log of every operation.
This guide walks you through using it. The script itself has inline --help text if you want the bare reference.
What gets migrated
- Branches, tags, commit history (full)
- Issues, pull requests, comments, labels, milestones
- Releases (with attached tarballs)
- Wiki content
- Git LFS objects
What doesn't
- GitHub Actions run history (your
.forgejo/workflows/*.ymlfiles are migrated as code; runs aren't replayed) - GitHub Discussions
- GitHub Sponsors / billing
- GitHub Apps and OAuth Apps registered against your repos
- Pages site content (the source files migrate; the rendered Pages publish chain doesn't)
Prerequisites
A working konnos instance (self-host.md) with an admin user.
Two tokens:
GitHub PAT (classic)
github.com/settings/tokens → Generate new token (classic). Required scopes:
repo(full)admin:org(auto-includesread:org,write:org)read:user
Set a 7–14 day expiration; revoke when migration is done.
konnos access token
https://code.your-domain.com/-/user/settings/applications → Manage Access Tokens → Generate. Required permissions:
- Organization: Read and Write
- Repository: Read and Write
- User: Read and Write
(For destination remapping that creates new orgs on the konnos side, the script also needs admin-org-creation rights — flagged automatically if missing during a dry-run.)
Setup
git clone https://code.konnos.org/konnos/konnos.git
cd konnos
cp .env.migration.example .env.migrationEdit .env.migration:
GITHUB_USER=your-github-username
GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
KONNOS_URL=https://code.your-domain.com
KONNOS_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
KONNOS_OWNER=your-konnos-username
# Optional: leave defaults until you need them
GITHUB_ORGS=
REPOS_FILTER=
ORGS_ONLY=false
ORGS_SKIP=false
THROTTLE=1
SKIP_ORGS=
ORG_REMAP=
REPO_REMAP=KONNOS_OWNER is the destination user whose namespace receives your personal repos (the GitHub repos you own directly, not org-owned). On a fresh konnos instance that's typically the admin user you set up at install.
Dry-run first — always
./scripts/migrate-github-to-konnos.sh --dry-runThe script connects to GitHub, lists every org you admin and every personal repo, prints what it WOULD migrate without doing anything. The output is also logged to konnos-migration-<UTC>.json (NDJSON).
Read the dry-run output. Verify:
- Every org you expect to see is listed
- Every repo you expect to migrate is listed
- The destination paths are what you want (
<owner>/<repo>on konnos)
If anything's wrong, use the remap flags below.
Real run
./scripts/migrate-github-to-konnos.shEach repo migrates serially with a 1-second throttle between API calls (configurable via THROTTLE). LFS-heavy repos take longer because the konnos instance fetches LFS objects from GitHub during import.
You'll see one log line per operation:
[14:32:15] repo migrated your-org/cool-project HTTP 201
Re-runs are safe. The script checks "does this destination repo already exist?" before each migration and skips if it does. If a single repo failed mid-run (network blip, rate limit), just re-run.
Destination remapping
By default, GitHub orgs land on konnos under the same name (github.com/foo/bar → code.your-domain.com/foo/bar), and personal repos land under KONNOS_OWNER. Three knobs for non-1:1 mappings:
Skip an org entirely
You don't want every GitHub org in your konnos. Maybe one is empty, archived, or off-strategy.
SKIP_ORGS=archived-org,abandoned-experimentThe script logs org skip for those and moves on.
Map a GitHub org to a different konnos owner
You want repos from multiple GitHub orgs to consolidate under a single konnos user (or a single konnos org).
ORG_REMAP=old-team:flndrn,linus-panda:flndrn,ghost-side-project:flndrnold-team/repo1 and linus-panda/repo2 both land under flndrn on konnos. If flndrn is the destination konnos user, repos go to flndrn/repo1 and flndrn/repo2.
Send a specific personal repo to a specific konnos org
You have a personal GitHub repo whose home should actually be a konnos org. For example, you used to develop personal/krypco under your account, but on konnos it belongs under the krypco org.
REPO_REMAP=your-github-username/krypco:krypco/krypcoFormat: src_owner/src_repo:dst_owner/dst_repo. Multiple comma-separated.
Migration order matters for collisions
If your-github-username/pandit and oldorg/pandit both exist on GitHub, only one ends up at flndrn/pandit on konnos. The personal repo migrates first (it's usually the most current), so oldorg/pandit is auto-skipped with repo skip because flndrn/pandit already exists.
Verify this is the right outcome by reading the dry-run log. If you'd rather the org's version win, swap them with the --repos-filter knob across two runs:
# run 1: only the org version of pandit
GITHUB_ORGS=oldorg REPOS_FILTER=pandit ORGS_ONLY=true ./scripts/migrate-github-to-konnos.sh
# run 2: everything else
./scripts/migrate-github-to-konnos.shWhat to verify post-migration
# A few sanity checks against the konnos instance:
curl -sH "Authorization: token $KONNOS_TOKEN" "$KONNOS_URL/api/v1/repos/search?owner=flndrn" | jq '.data | length'
# That number should match how many repos you expected under flndrn.
# Spot-check a single repo:
git clone "$KONNOS_URL/flndrn/some-repo.git" /tmp/check-clone
cd /tmp/check-clone
git lfs ls-files # if the source had LFS, these should download
git log --oneline | head -20If something looks off, check the NDJSON log:
jq -s 'group_by(.kind + "/" + .status) | map({(.[0].kind + "/" + .[0].status): length}) | add' \
konnos-migration-*.json
# {"org/skip": 1, "repo/migrated": 23, "repo/skip": 3}After migration succeeds
Three follow-up tasks:
1. Repoint your local clones
cd ~/your-projects/repo
git remote set-url origin ssh://git@code.your-domain.com:2222/your-konnos-owner/repo.git
# or HTTPS:
git remote set-url origin https://code.your-domain.com/your-konnos-owner/repo.gitFor SSH, add your public key to konnos first. After that, git operations don't prompt for credentials.
2. Repoint your CI / deploy chain
If you had GitHub-sourced auto-deploy (Dokploy, fly.io, Vercel, etc.), update each app's source to its konnos URL. The script's NDJSON log lists every <owner>/<repo> migrated — handy for batch repointing.
For konnos's own auto-deploy (Dokploy gitea provider), each repointed app needs:
- Source updated to
your-konnos-owner/repoon the gitea provider - A new webhook on the konnos repo pointing at Dokploy's deploy URL
See /forge/runner/README.md for runner setup if you want CI inside konnos itself.
3. Archive (don't delete) the GitHub originals
Don't delete the GitHub repos right away. Archive them — read-only, free, and a passive backup for 30+ days while you build confidence in the konnos copies. After a few weeks of running on konnos with no surprises, delete the GitHub copies if you want.
Common failure modes
| Symptom | Cause | Fix |
|---|---|---|
LFS object not found | GitHub LFS quota exceeded mid-fetch | Wait until quota resets next month, re-run script (idempotent). Or upgrade your GitHub LFS plan temporarily. |
403 Forbidden on org listing | PAT missing admin:org scope | Regenerate PAT with full admin:org scope checked. |
404 on a repo create | konnos instance API unreachable, network blip | Check KONNOS_URL resolves + responds. Re-run. |
409 already exists | Repo with that name already on konnos (e.g., from a partial earlier run) | Expected for re-runs; script logs skip. If you want to force re-migrate, delete the konnos-side repo first via the UI. |
instance address is empty | Misleading error from upstream — usually means the destination admin token has expired or its scopes shrank | Regenerate the konnos token, update .env.migration. |
Revoking tokens after success
# GitHub: github.com/settings/tokens — revoke the migration PAT
# konnos: code.your-domain.com/-/user/settings/applications — revoke the access tokenThe PAT's 7-day expiration handles this automatically if you set one. The konnos token has no auto-expiry; revoke manually.