One-time, full-fidelity migration from Gitea ≥ 1.23 to Forgejo v15+.
Forgejo's official drop-in path was severed at Gitea 1.22 (see Forgejo's Dec 2024 announcement). For Gitea 1.23+ there is no supported route — only DB surgery (forgejo#7638) or API-driven migration with documented data loss. This tool combines both: native DB dump + filesystem copy + schema-version trick, supplemented by API sync for items the DB migration misses (webhook URL rewrites, runner tokens, OAuth callback URLs, Actions secrets CSV).
Work in progress. See docs/what-breaks.md for the authoritative list of what
this tool handles and what requires manual operator action.
brew tap pacnpal/gitea2forgejo
brew install gitea2forgejoPulls in the required external tools (rsync, libpq for pg_dump,
mysql-client, sqlite, zstd) as formula dependencies automatically.
Update:
brew update && brew upgrade gitea2forgejoTap repo: https://github.com/pacnpal/homebrew-gitea2forgejo — formula is auto-bumped by GitHub Actions on every release.
Linux / macOS:
curl -fsSL https://raw.githubusercontent.com/pacnpal/gitea2forgejo/main/install.sh | bashWindows (PowerShell):
iwr -useb https://raw.githubusercontent.com/pacnpal/gitea2forgejo/main/install.ps1 | iexThe installer script:
- Detects your OS and CPU (amd64 / arm64)
- Installs all external tool dependencies via the platform package manager:
- Debian/Ubuntu:
apt→rsync openssh-client sqlite3 postgresql-client default-mysql-client zstd - Fedora/RHEL/CentOS:
dnf/yum→rsync openssh-clients sqlite postgresql mariadb zstd - Arch:
pacman→rsync openssh sqlite postgresql-libs mariadb-clients zstd - Alpine:
apk→rsync openssh-client sqlite postgresql-client mariadb-client zstd - openSUSE:
zypper→rsync openssh sqlite3 postgresql mariadb-client zstd - macOS:
brew→postgresql mysql-client zstd(rsync/ssh/sqlite preinstalled) - Windows:
winget→OpenSSH, Git, PostgreSQL, SQLite, zstd(for fulldump/restoreflows, WSL2 is recommended)
- Debian/Ubuntu:
- Resolves the latest release tag via GitHub's
/releases/latestredirect - Downloads the matching binary
- On Linux: installs to
/usr/local/bin/gitea2forgejo(prompts forsudoif the directory isn't writable) - On macOS: clears
com.apple.quarantineand applies an ad-hoccodesignso Gatekeeper doesn't block the first run - On Windows: installs to
%LOCALAPPDATA%\Programs\gitea2forgejo\, unblocks the file (removes SmartScreen zone marker), and adds the directory to your userPATH - Verifies the install with
gitea2forgejo --version
Environment variable overrides (same on both platforms):
INSTALL_DIR— override the target directoryVERSION— pin a specific release tag (VERSION=v0.2.9 curl ... | bash)SKIP_DEPS— set to1to skip the dependency install step
To update later: just run gitea2forgejo update, or re-run the installer — both are idempotent.
Each release attaches static binaries for 6 platforms, built and signed by
the SLSA3 Go builder
with one .intoto.jsonl provenance attestation per binary.
| Platform (Go) | OS | CPU | File |
|---|---|---|---|
linux/amd64 |
Linux | x86-64 / Intel | gitea2forgejo-linux-amd64 |
linux/arm64 |
Linux | ARM64 / aarch64 | gitea2forgejo-linux-arm64 |
darwin/amd64 |
macOS (Intel Macs) | x86-64 | gitea2forgejo-darwin-amd64 |
darwin/arm64 |
macOS (Apple Silicon) | ARM64 (M1/M2/M3/M4) | gitea2forgejo-darwin-arm64 |
windows/amd64 |
Windows | x86-64 | gitea2forgejo-windows-amd64.exe |
windows/arm64 |
Windows | ARM64 | gitea2forgejo-windows-arm64.exe |
PLATFORM=linux-amd64 # see table above
curl -L -o gitea2forgejo \
https://github.com/pacnpal/gitea2forgejo/releases/latest/download/gitea2forgejo-$PLATFORM
chmod +x gitea2forgejo
sudo mv gitea2forgejo /usr/local/bin/
gitea2forgejo --versionGitHub's /releases/latest/download/ URLs always redirect to the newest
non-prerelease asset, so this command keeps working across future releases
without edits. To pin to a specific version, swap /latest/download/ for
/download/v0.2.0/ (or whatever tag you want).
The release binaries are not Apple Developer-ID signed or notarized — Gatekeeper will refuse to run them by default. Two mitigation options:
Option A: strip the quarantine attribute (simplest).
curl -L -o gitea2forgejo \
https://github.com/pacnpal/gitea2forgejo/releases/latest/download/gitea2forgejo-darwin-arm64
xattr -dr com.apple.quarantine gitea2forgejo # remove Gatekeeper flag
chmod +x gitea2forgejo
./gitea2forgejo --versionOption B: ad-hoc self-sign (survives xattr resets and works across
subsequent runs without Gatekeeper prompting).
codesign --force --sign - gitea2forgejomacOS 26 ("Tahoe") extra step. Tahoe hardened Gatekeeper: double-clicking an unsigned binary no longer offers the old "right-click → Open" override from a Finder contextual menu. Workflow:
- Try to run once from Terminal — it will fail with a Gatekeeper message.
- Open System Settings → Privacy & Security, scroll to the "'gitea2forgejo' was blocked to protect your Mac" banner, and click "Open Anyway" (Touch ID / admin password required).
- Run the binary again from Terminal; you'll be prompted once more to confirm, then it executes normally thereafter.
If xattr -dr com.apple.quarantine + codesign --force --sign - are both
applied before first launch, Tahoe skips the Settings step entirely
because there's no quarantine flag for Gatekeeper to act on.
Avoid sudo spctl --master-disable — that disables Gatekeeper
system-wide and is stronger than you want.
- Linux: primary target. All external commands (
rsync,pg_dump,tar,mc,skopeo) are in distro package repos. - macOS: install
rsync,postgresql(forpg_dump),zstd,mcandskopeovia Homebrew. - Windows: native binaries run and the API-only flows (
preflight, manifest harvest, API supplement) work, but dump/restore shell out torsync/pg_dump/ tar-with-zstd. Use from WSL2 or Git Bash with MSYS2 packages installed; native PowerShell is not supported.
# Install once.
go install github.com/slsa-framework/slsa-verifier/v2/cli/slsa-verifier@latest
PLATFORM=linux-amd64
# Fetch binary + its provenance from the latest release.
curl -L -o gitea2forgejo-$PLATFORM \
https://github.com/pacnpal/gitea2forgejo/releases/latest/download/gitea2forgejo-$PLATFORM
curl -L -o gitea2forgejo-$PLATFORM.intoto.jsonl \
https://github.com/pacnpal/gitea2forgejo/releases/latest/download/gitea2forgejo-$PLATFORM.intoto.jsonl
# slsa-verifier needs the exact tag to cross-check against; resolve it from
# the release API in one step.
VERSION=$(gh release view --repo pacnpal/gitea2forgejo --json tagName --jq .tagName)
# Or without gh:
# VERSION=$(curl -sI https://github.com/pacnpal/gitea2forgejo/releases/latest | \
# awk -F/ '/^location:/ {print $NF}' | tr -d '\r')
slsa-verifier verify-artifact \
--provenance-path gitea2forgejo-$PLATFORM.intoto.jsonl \
--source-uri github.com/pacnpal/gitea2forgejo \
--source-tag $VERSION \
gitea2forgejo-$PLATFORMgo install github.com/pacnpal/gitea2forgejo/cmd/gitea2forgejo@latestThe binary lands at $(go env GOPATH)/bin/gitea2forgejo. This route does
NOT produce a SLSA provenance; use the release binary if you want supply-chain
attestations.
git clone https://github.com/pacnpal/gitea2forgejo
cd gitea2forgejo
go build -o gitea2forgejo ./cmd/gitea2forgejoRequires Go 1.26+. The binary is fully static (CGO_ENABLED=0) and works on
any linux/amd64 host.
Check what's running:
gitea2forgejo --versionSee what's new for each release at https://github.com/pacnpal/gitea2forgejo/releases.
Same curl as initial install, overwriting the file in place. Uses the
/latest/ URL so you never need to edit the version:
PLATFORM=linux-amd64
curl -L -o /tmp/gitea2forgejo \
https://github.com/pacnpal/gitea2forgejo/releases/latest/download/gitea2forgejo-$PLATFORM
chmod +x /tmp/gitea2forgejo
sudo mv /tmp/gitea2forgejo /usr/local/bin/gitea2forgejo
gitea2forgejo --version # confirm new version shownPin to a specific tag by swapping /latest/download/ for
/download/vX.Y.Z/.
On macOS, reapply the Gatekeeper mitigation (xattr -dr com.apple.quarantine
or codesign --force --sign -) after downloading the new binary — the
quarantine flag is set on the new download even if you cleared it on the
old one.
go install github.com/pacnpal/gitea2forgejo/cmd/gitea2forgejo@latestOr pin to a specific version: …@v0.1.2.
cd /path/to/gitea2forgejo
git fetch --tags
git checkout v0.1.2 # or: git checkout main
go build -o gitea2forgejo ./cmd/gitea2forgejoOnce you've run dump against a source, prefer finishing that migration with
the same binary version that produced the dump. Upgrading between dump
and restore is low-risk on patch bumps (everything in work_dir is plain
files + JSON), but newer versions may add manifest fields the old restore
doesn't know about.
Breaking changes that would affect in-flight runs are flagged as MAJOR version bumps in the release notes.
| Command | Status | Purpose |
|---|---|---|
init |
✅ shipped | SSH to source, read app.ini, auto-populate config.yaml. |
preflight |
✅ shipped | Read-only checks: versions, SSH, DB, disk, SECRET_KEY. |
dump |
✅ shipped | gitea dump + native DB dump + S3 mirror + source manifest. |
restore |
✅ shipped | File copy, DB import, schema trick, forgejo doctor. |
supplement |
🚧 planned | API fixes: hostname rewrites, runner tokens, Actions CSVs. |
verify |
🚧 planned | Re-harvest target manifest, diff against source, emit report. |
migrate |
🚧 planned | Run all five in order, with --resume-from=<phase>. |
Until migrate lands, run preflight → dump → restore by hand in that
order (see Usage below).
This section is the "I only have a Gitea server and want everything on
Forgejo" guide. It walks through infrastructure provisioning, data
handoff, cutover, and decommission end to end. Expect 3 – 10 hours
total elapsed time depending on repo/LFS volume; most of that is the
gitea dump tarball transfer and DB dump/restore.
| Term | Meaning |
|---|---|
| source | Your existing Gitea server (≥ 1.23). Call this gitea.example.com. |
| target | The new Forgejo server you will stand up. forgejo.example.com. |
| mig-host | The machine you run gitea2forgejo on. Can be your laptop. |
| work_dir | Local scratch directory on mig-host where all dump artifacts land. |
The mig-host needs network reachability + SSH access to both source and target, plus DB reachability to both databases. It does NOT have to be either the source or the target — in fact you'll have fewer surprises if it's a third box.
Open a note-taking scratchpad; you'll fill in these values as you go.
Source Gitea (existing):
- URL: e.g.
https://gitea.example.com - Gitea version (run
gitea --versionon the host); must be ≥ 1.23 -
app.inipath: typically/etc/gitea/app.inior/var/lib/gitea/custom/conf/app.ini - Data directory (
[server].APP_DATA_PATH): typically/var/lib/gitea/data - Repo root (
[repository].ROOT): typically/var/lib/gitea/git/repositories - DB dialect (
postgres/mysql/sqlite3) from[database].DB_TYPE - DB DSN (reconstructed from
[database].HOST/.NAME/.USER/.PASSWD) - SSH user with sudo rights on the source host
- Object storage? If
[storage.*]is configured with an S3/MinIO backend, capture endpoint, bucket, access key, secret key - Size of data dir:
du -sh /var/lib/gitea— used for free-space planning
Target (you will build this):
- A host with ≥ 2× source data-dir disk space, same CPU arch as source (for LFS compatibility — doesn't matter for most users)
- DNS name you'll cut over to: e.g.
forgejo.example.com - DB server (can be the same Postgres/MySQL cluster, different database)
- TLS strategy: Let's Encrypt / existing reverse proxy / self-signed
Pick any of the three install paths under Install above. Quick version for Linux:
curl -L -o gitea2forgejo \
https://github.com/pacnpal/gitea2forgejo/releases/latest/download/gitea2forgejo-linux-amd64
chmod +x gitea2forgejo && sudo mv gitea2forgejo /usr/local/bin/
gitea2forgejo --version# Debian 13+ / recent Ubuntu — mysql-client was dropped;
# MariaDB's client is a drop-in (provides mysql / mysqldump).
sudo apt install rsync postgresql-client default-mysql-client zstd openssh-client
# Debian 12 / older Ubuntu
sudo apt install rsync postgresql-client mysql-client zstd openssh-client
# Fedora / RHEL — `mariadb` provides mysql + mysqldump.
sudo dnf install rsync postgresql mariadb zstd openssh-clients
# macOS (Homebrew)
brew install rsync postgresql mysql-client zstdSkip the MySQL/MariaDB package if your source + target both use Postgres
(or both SQLite). Skip postgresql-client / postgresql if neither uses
Postgres. You only need the client tools for the DB engine(s) your
instances actually run.
Additionally:
- mc (MinIO client) if your source uses S3/MinIO storage
- skopeo if your source has OCI container packages in its registry
If you want gitea2forgejo to figure out as much of config.yaml as it
can on its own:
# Interactive: just run it and answer the prompts.
gitea2forgejo init
# Or one-shot:
export SOURCE_ADMIN_TOKEN=gta_...
export TARGET_ADMIN_TOKEN=fjo_...
gitea2forgejo init \
--source-url https://gitea.example.com \
--source-ssh root@gitea.example.com \
--target-url https://forgejo.example.com \
--target-ssh root@forgejo.example.com \
-o config.yamlIf any required flags are missing, init prompts for them at the TTY
(admin tokens are masked). Pass every flag to skip the prompts — useful
for CI / scripted runs.
init does:
-
SSH bootstrap — handles three common setup states automatically:
- Host not in
~/.ssh/known_hosts→ silently runsssh-keyscan -H <host>and appends the result. No prompting; it's safe because a CONFLICTING host key would produce a different error and fall through to the interactive path (we never auto-accept a changed key). - Key file missing → looks for
~/.ssh/id_ed25519,id_ecdsa,id_rsa, then falls back to$SSH_AUTH_SOCK(ssh-agent). - No usable credentials at all → at a TTY, offers to fix:
On yes, it runs
SSH to source 192.168.86.3 failed: ssh dial: ... no usable SSH auth Generate a new key and install it on 192.168.86.3? [Y/n]:ssh-keygen -t ed25519 -f ~/.ssh/gitea2forgejo, thenssh-keyscanto prime known_hosts, thenssh-copy-id(which prompts once for the remote password), then retries. Repeats for the target host.
On non-TTY stdin (CI/scripted), interactive prompts are skipped; the silent known_hosts fix still applies.
- Host not in
-
Runs
docker pson the remote to detect whether Gitea is in a container (and if so,docker inspectto resolve the bind-mountedapp.inipath). -
Reads the source
app.iniand extracts:data_dir,repo_root, DB type + host + port + name + user, S3 storage config. -
Does the same for the target (best-effort — fresh Forgejo installs often won't have an app.ini yet, in which case it falls back to standard defaults).
-
Writes
config.yamlwith secrets asenv:<NAME>references so you never commit them to disk.
After it runs, export the env vars it refers to and run
gitea2forgejo preflight --config config.yaml. That's usually all the
setup you need.
Requirements: ssh-keygen, ssh-keyscan, and ssh-copy-id must be on
$PATH for the bootstrap to work (they ship with openssh-client on
all major distros and come preinstalled on macOS).
If either side runs in Docker (or Podman), add a docker: block to that
instance. The tool still SSHes to the Docker host (not the container);
the block just wraps gitea dump, forgejo doctor, etc. in docker exec.
source:
url: https://gitea.example.com
ssh:
host: docker-host.example.com # the VM running Docker, not a container
user: root
key: ~/.ssh/gitea2forgejo
# Paths below are HOST paths — the bind-mounted volumes on the Docker
# host. Rsync reads from them directly; gitea dump writes to them from
# inside the container.
config_file: /srv/gitea/data/gitea/conf/app.ini
data_dir: /srv/gitea/data
repo_root: /srv/gitea/data/git/repositories
custom_dir: /srv/gitea/data/gitea
remote_work_dir: /srv/gitea/data/migration # must be bind-mounted!
docker:
container: gitea # from `docker ps`
user: git # user inside the container
binary: docker # or "podman"The critical constraint is that remote_work_dir must be a host path
that is bind-mounted at the same path inside the container. gitea dump
runs inside the container and writes its tarball to that path; the host
sees the file at the bind-mount location and SFTP fetches it from there.
If your docker-compose.yml mounts /srv/gitea/data → /data, gitea dump
will happily write to /data/migration/… inside the container, but the
host path is /srv/gitea/data/migration. Set remote_work_dir to the
host side path and make the container-internal binding match:
# docker-compose.yml (for the source Gitea)
services:
gitea:
image: gitea/gitea:1.23
volumes:
- /srv/gitea/data:/srv/gitea/data # <<< bind at same path both sidesOR keep the container's internal /data/... and bind to the matching
host path:
services:
gitea:
volumes:
- /srv/gitea:/data
# and in gitea2forgejo config:
source:
data_dir: /srv/gitea # host side
remote_work_dir: /srv/gitea/migration # host side
docker:
container: gitea
# inside the container /data/migration is writable and appears on host
# at /srv/gitea/migration — but the paths don't match. gitea dump
# will write using the --file arg you pass (host path), which the
# container sees at a different location and fails.
# Safer: keep paths IDENTICAL on both sides via bind-mount same-path.Similarly for the target Forgejo's docker: block.
Instead of hand-rolling the target Forgejo + Postgres setup, copy the
templates in templates/:
curl -L -o docker-compose.yml \
https://raw.githubusercontent.com/pacnpal/gitea2forgejo/main/templates/docker-compose.target.yml
curl -L -o .env \
https://raw.githubusercontent.com/pacnpal/gitea2forgejo/main/templates/docker-compose.env.example
$EDITOR .env # set FORGEJO_DOMAIN + DB credentials
docker compose up -d db # wait for DB to be healthy
docker compose up -d forgejo # starts, creates schema — DO NOT visit the web UI
docker compose stop forgejo # leave stopped until restore completesThe compose file uses identical host and container paths
(/srv/forgejo/data → /srv/forgejo/data) so remote_work_dir works
with no path translation; match it in config.yaml:
target:
data_dir: /srv/forgejo/data
repo_root: /srv/forgejo/repositories
custom_dir: /srv/forgejo/custom
remote_work_dir: /srv/forgejo/data/migration
docker:
container: forgejo
user: gitUnraid Community Applications installs Gitea (and Forgejo) as managed Docker containers. For gitea2forgejo:
- SSH target:
root@<unraid-host>on port 22. Unraid's root shell is enabled by default. - Paths on the Unraid host (standard CA template):
/mnt/user/appdata/gitea/gitea/conf/app.ini— the app.ini/mnt/user/appdata/gitea/— data/repos/custom all live under here
- Container name: usually
Gitea(capital G — Unraid's CA templates preserve the casing). Check withdocker ps --format '{{.Names}}'via Unraid's terminal. docker:block inconfig.yaml:source: ssh: { host: tower.local, user: root, key: ~/.ssh/gitea2forgejo } config_file: /mnt/user/appdata/gitea/gitea/conf/app.ini data_dir: /mnt/user/appdata/gitea repo_root: /mnt/user/appdata/gitea/git/repositories custom_dir: /mnt/user/appdata/gitea/gitea remote_work_dir: /mnt/user/appdata/gitea/migration docker: container: Gitea user: git binary: docker
gitea2forgejo init --source-ssh root@tower.local ...handles all of the above automatically on most Unraid installs; verify the detected paths before running preflight.
Unraid caveats:
- Don't run migration during an Unraid "Parity Check" — disk I/O will be miserable.
- The gitea user inside the CA-templated container is usually
git(uid 1000). If you customized PUID/PGID in the Gitea template, update thedocker.userin your config accordingly.
Do this before you start the cutover — installing Forgejo takes time you don't want on your downtime critical path.
-
Stand up the target host (cloud VM, bare metal, container — whatever your Linux distro strategy is). Give it a private IP on a network the mig-host can SSH to.
-
Create a
forgejosystem user:sudo useradd --system --home /var/lib/forgejo --shell /bin/bash forgejo sudo mkdir -p /var/lib/forgejo /etc/forgejo /var/log/forgejo sudo chown forgejo: /var/lib/forgejo /var/log/forgejo sudo chmod 750 /etc/forgejo && sudo chown root:forgejo /etc/forgejo -
Install the Forgejo v15 binary and systemd unit:
# see https://forgejo.org/download/ for the current LTS URL VER=v15.0.0 sudo curl -L -o /usr/local/bin/forgejo \ https://codeberg.org/forgejo/forgejo/releases/download/$VER/forgejo-15.0.0-linux-amd64 sudo chmod +x /usr/local/bin/forgejo sudo curl -o /etc/systemd/system/forgejo.service \ https://codeberg.org/forgejo/forgejo/raw/branch/forgejo/contrib/systemd/forgejo.service sudo systemctl daemon-reload
-
Provision the target DB. Empty schema, dedicated user.
# Postgres example: sudo -u postgres psql <<'SQL' CREATE USER forgejo WITH PASSWORD 'change-me-now'; CREATE DATABASE forgejo OWNER forgejo ENCODING 'UTF8'; SQL
-
Ideally, do not start Forgejo yet and do not run its web-based initial setup wizard. The target is simplest when it stays at "empty DB, binary installed, service stopped" until
gitea2forgejo restoredrops data into it.If you already ran the setup wizard (very common): set
options.reset_target_db: trueinconfig.yaml.preflightwill flag the pre-populated schema as a FAIL;restorewill wipe the target DB (DROP SCHEMA public CASCADEon Postgres,DROP DATABASEon MySQL,rmthe sqlite file) before importing the source dump. The reset is gated behind this flag specifically because it's destructive — you don't want to silently nuke a production target. -
Leave the service stopped but enable it so it starts automatically on boot:
sudo systemctl enable forgejo # enabled, not started
-
Set up your reverse proxy / TLS (nginx / Caddy / Traefik) pointing at
127.0.0.1:3000on the target. You can do this now even though Forgejo isn't running — the proxy will just return 502 until we start it.
On the source Gitea (still running at this point):
- Log in as a site admin user
- User menu → Settings → Applications → Generate New Token
- Name it "gitea2forgejo-migration"
- Tick all scopes (this is a one-time admin migration)
- Copy the token immediately — it is shown once only
Save as env var on mig-host:
export GITEA_ADMIN_TOKEN=gta_...You'll create the target token after restore completes — at that point
Forgejo will have imported your source user records, so you log in with the
same admin credentials you had on source.
For preflight + restore, gitea2forgejo needs to hit the target API. Two
options:
-
Option A (recommended): during target Forgejo install (step 3), also create a throwaway admin user via the CLI and generate a token for it:
sudo -u forgejo forgejo admin user create --admin \ --username bootstrap --email root@localhost --random-password sudo -u forgejo forgejo admin user generate-access-token \ --username bootstrap --scopes all
This user gets overwritten during restore, so it's disposable. Save the token as
FORGEJO_ADMIN_TOKEN. -
Option B: skip preflight's target-side checks (accept the warning) and only populate
FORGEJO_ADMIN_TOKENbetween restore and the eventualsupplement/verifyphases.
The migration user needs to run commands on both source and target. Set up a dedicated ed25519 key and deploy the public half to both sides:
ssh-keygen -t ed25519 -f ~/.ssh/gitea2forgejo -C gitea2forgejo-migration
ssh-copy-id -i ~/.ssh/gitea2forgejo.pub root@gitea.example.com
ssh-copy-id -i ~/.ssh/gitea2forgejo.pub root@forgejo.example.comPrime known_hosts so the tool's strict host-key verification succeeds:
ssh-keyscan -H gitea.example.com forgejo.example.com >> ~/.ssh/known_hostsVerify both work:
ssh -i ~/.ssh/gitea2forgejo root@gitea.example.com true && echo source OK
ssh -i ~/.ssh/gitea2forgejo root@forgejo.example.com true && echo target OKCopy the template and fill it in with the values from your scratchpad:
curl -L -o example.config.yaml \
https://raw.githubusercontent.com/pacnpal/gitea2forgejo/main/example.config.yaml
cp example.config.yaml config.yaml
$EDITOR config.yamlAll the information the binary needs (organized by config field):
| Config field | What it is |
|---|---|
source.url |
Public URL of source Gitea |
source.admin_token |
env:GITEA_ADMIN_TOKEN — from step 4 |
source.insecure_tls |
true if source uses self-signed cert; else false |
source.ssh.host |
Hostname of source host |
source.ssh.user |
SSH user with sudo rights |
source.ssh.key |
Path to your private key (e.g. ~/.ssh/gitea2forgejo) |
source.ssh.known_hosts |
~/.ssh/known_hosts (or leave default) |
source.config_file |
Absolute path to app.ini on source |
source.data_dir |
[server].APP_DATA_PATH from source app.ini |
source.repo_root |
[repository].ROOT from source app.ini |
source.custom_dir |
Usually $data_dir/../custom or /var/lib/gitea/custom |
source.binary |
Path or name of gitea binary on source (default: gitea) |
source.run_as |
User to sudo -u for gitea dump (usually gitea) |
source.remote_work_dir |
Writable scratch dir on source (default: /tmp/gitea2forgejo) |
source.db.dialect |
postgres / mysql / sqlite3 |
source.db.dsn |
env:GITEA_DB_DSN — see format below |
source.storage.* |
Only if you use S3/MinIO (else omit the block) |
target.* |
Same fields for target Forgejo |
work_dir |
Local scratch on mig-host; needs ≥ 2× source data size free |
hostname_rewrites |
List of {from, to} pairs for webhook URL / OAuth callback |
options.dump_format |
tar.zst (default) / tar.gz / tar / zip |
options.skip_* |
Skip specific dump stages (rehearsal use) |
options.reset_target_db |
DESTRUCTIVE. true if you already ran Forgejo setup wizard |
DSN formats:
# Postgres
postgres://user:password@host:5432/dbname?sslmode=disable
# MySQL (go-sql-driver form)
user:password@tcp(host:3306)/dbname?parseTime=true
# SQLite3 (just the file path)
/var/lib/gitea/data/gitea.db
Export the secrets as env vars (the config's env:FOO references resolve
them at runtime):
export GITEA_ADMIN_TOKEN=gta_...
export FORGEJO_ADMIN_TOKEN=fjo_...
export GITEA_DB_DSN='postgres://gitea:secret@gitea-db.example.com:5432/gitea?sslmode=disable'
export FORGEJO_DB_DSN='postgres://forgejo:secret@forgejo-db.example.com:5432/forgejo?sslmode=disable'gitea2forgejo preflight --config config.yaml
cat $(yq -r .work_dir config.yaml)/preflight-report.mdEvery check must be PASS (some WARNs are tolerable — read them). In
particular, SECRET_KEY / INTERNAL_TOKEN / JWT_SECRET present
is non-negotiable. If any are empty in source app.ini, STOP and
regenerate them (then users will need to re-login). Proceeding without
them means every 2FA secret, OAuth app client secret, and encrypted
Actions secret on source becomes unrecoverable garbage.
Also watch for the target: db empty check. If it FAILs reporting
tables present, someone has run Forgejo's setup wizard — set
options.reset_target_db: true in config.yaml and re-run preflight.
- Snapshot source DB:
pg_dump -Fc $GITEA_DB_DSN > pre-migration.dump - Restore into a disposable Postgres on a throwaway VM
- Boot a disposable Gitea pointed at the disposable DB
- Point a second throwaway VM at that disposable Gitea as the "source"
in a staging
config.yaml, with a third throwaway VM as the "target" Forgejo - Run the full
preflight→dump→restoreflow - Log in as a real user from source; verify 2FA works, webhooks fire, an Actions workflow with a secret succeeds, LFS clones, a package pulls
Every surprise discovered during rehearsal is a surprise you don't hit during the real cutover.
Announcement template (24 – 72 hours before):
Subject: Gitea migration, $DATE, downtime expected $N hours. We're moving from Gitea to Forgejo. During the cutover window you'll be logged out, git push/pull will be unavailable, webhooks will not fire, and Actions runs will not start. After the window, please re-login, re-enroll 2FA if the Authenticator app doesn't accept your old token (unlikely but possible), and regenerate your Personal Access Tokens (these cannot be migrated — old PATs will not work on the new server).
When the window opens:
ssh root@gitea.example.com 'sudo systemctl stop gitea'Confirm it's really stopped: curl -I https://gitea.example.com/ should
fail to connect.
gitea2forgejo dump --config config.yamlStages and their outputs in work_dir:
| Stage | Output |
|---|---|
| API harvest | source-manifest.json (full entity inventory of users, orgs, repos, etc.) |
| login_source | merged into the same manifest (LDAP/OAuth2/SMTP auth source definitions) |
gitea dump |
gitea-dump.tar.zst (app.ini + data/repos/custom + xorm SQL) |
| native DB | gitea.dump (Postgres) / gitea.sql (MySQL) / gitea.sqlite |
| S3 mirror | s3/ (attachments, lfs, packages, avatars) |
Duration: 30 min – 6 hours depending on repo + LFS volume.
gitea2forgejo restore --config config.yaml11 steps, all logged:
- SSH to target,
systemctl stop forgejo - Extract
gitea-dump.tar.zstintowork_dir/extracted/ rsyncdata/,repos/,custom/to the target host- Translate source
app.ini→ targetapp.ini(preserveSECRET_KEY, rewrite hostname, rewrite data paths, setCOOKIE_REMEMBER_NAME, set[actions].DEFAULT_ACTIONS_URL) pg_restore/mysql < dump.sql/ sqlite copy into target DBUPDATE version SET version = 305(forgejo#7638 schema trick)- Remove stale Bleve indexer files
chown -R forgejo:forgejodata/repos/custom- Start Forgejo — it runs forward DB migrations on boot (watch journal for errors)
forgejo doctor check --all --fix --log-file …on targetforgejo admin regenerate hooks
Duration: 30 min – 2 hours.
Hit the target directly by IP / internal DNS first — don't cut
gitea.example.com → forgejo.example.com yet.
-
https://forgejo-internal/loads and shows existing users/repos - Log in as an admin; admin panel loads
- Log in as a 2FA user; TOTP still works (proves
SECRET_KEYpreserved) - Open a repo; issues + PRs + comments render
-
git clone ssh://git@forgejo-internal/org/repoworks - Fetch an LFS file in a cloned repo
- Fire a webhook test delivery (
Repo → Settings → Webhooks → Test) and confirm signature verifies on the receiver side - Run an Actions workflow that uses a secret; output is correct
- Pull a package from the OCI registry (if used)
If anything fails, now is the time to stop and dig in. The source is still frozen-but-intact.
Point gitea.example.com (or whatever your canonical URL was) at the
target IP. Wait for DNS propagation (the TTL you used to set — hopefully
short for this change). Let's Encrypt / your TLS layer should already be
issuing for the target hostname (you set this up in step 3.7).
For a sharper cutover, run a temporary HTTP-level redirect on the old
source IP: 301 https://gitea.example.com/* → https://forgejo.example.com/*.
This catches anyone who cached DNS and gives them a clear signal.
Until supplement and verify subcommands ship, work through the
docs/post-migration-checklist.md.
The must-do items:
- Re-register Actions runners. Each runner's registration token is
hostname-scoped. Generate new tokens via
forgejo admin actions generate-runner-tokenand re-run the registration command on each runner host. - Announce PAT regeneration. Users who had PATs must issue new ones; old ones cannot be decrypted.
- Update external systems that integrate via webhooks or OAuth if they pointed at the old hostname (or rely on any of the above rotated tokens).
- Verify LFS storage transfer.
SELECT SUM(size) FROM lfs_meta_objecton both sides should match. - Re-login everyone. Sessions don't always survive the
COOKIE_REMEMBER_NAMEpreservation; it's safer to tell users to log out and log back in.
Leave the source running, offline-mode or stopped, for 2 – 4 weeks as an in-case-we-missed-something fallback. During that time:
- Monitor target for silent failures (integrations complaining about missing webhooks, CI runners that never re-registered)
- Re-run your smoke test suite every few days
- Keep the
work_dirdump tarballs — they are your only on-disk complete snapshot of the source state at cutover time
Once you're confident:
# On the source host:
sudo systemctl disable --now gitea
# After one final snapshot:
sudo apt purge gitea # or equivalent
# Archive (don't delete) /var/lib/gitea and its DB for complianceMinimum commands, assuming all prep is done:
# Sanity check — read-only, always safe
gitea2forgejo preflight --config config.yaml
# During downtime window
ssh root@gitea.example.com 'sudo systemctl stop gitea'
gitea2forgejo dump --config config.yaml # 30 min – 6 hr
gitea2forgejo restore --config config.yaml # 30 min – 2 hr
# Smoke test, then cut DNS- Everything the tool produces lands in
work_dir: dump tarball, native DB dump, S3 mirror,source-manifest.json,preflight-report.md, translatedtarget-app.ini, and (after restore) the remote doctor log. - Increase verbosity with
--log-level debug. - Each subcommand is mostly idempotent: rerunning
dumpre-harvests and overwrites; rerunningrestorere-extracts and re-rsyncs. Useful when a prior run failed partway through. - If target Forgejo fails to start after step 9 of restore, read its
journal:
ssh root@forgejo.example.com journalctl -u forgejo -e. The most common cause is a path inapp.inithat wasn't rewritten; edit the translatedtarget-app.inilocally, SFTP it up, and restart.
- Go 1.26+ (only if building from source or using
go install) - Source host: SSH access + admin token
- Target host: SSH access + admin token + empty Forgejo v15 install
- On the machine running the tool:
rsync,psqlormysql,mc(MinIO client) if S3 storage is in use,skopeoif OCI packages are in use