┌────────────────────────────┐ │ Zero-Downtime Forgejo │ │ Migration: Moving Git │ │Repositories Across Domains │ │ 2026-08-29 │ │ │ ├────────────────────────────┤ │ << Back to Blog │ └────────────────────────────┘
╔══════════════════════════════════════╗ ║ Zero-Downtime Forgejo Migration: ║ ║Moving Git Repositories Across Domains║ ║ 2026-08-29 ║ ║ ║ ╠══════════════════════════════════════╣ ║ << Back to Blog ║ ╚══════════════════════════════════════╝
╔══════════════════════════════════════════════════════════╗ ║ Zero-Downtime Forgejo Migration: Moving Git Repositories ║ ║ Across Domains ║ ║ 2026-08-29 ║ ║ ║ ╠══════════════════════════════════════════════════════════╣ ║ << Back to Blog ║ ╚══════════════════════════════════════════════════════════╝
╔══════════════════════════════════════════════════════════════════════════════╗ ║ Zero-Downtime Forgejo Migration: Moving Git Repositories Across Domains ║ ║ 2026-08-29 ║ ║ ║ ╠══════════════════════════════════════════════════════════════════════════════╣ ║ << Back to Blog ║ ╚══════════════════════════════════════════════════════════════════════════════╝
Zero-Downtime Forgejo Migration: Moving Git Repositories Across Domains
Table of Contents
- The Context / The Problem
- The Deep-Dive / Root Cause Analysis
- The Implementation / Architecture
- Lessons Learned & Best Practices
- References
The Context / The Problem
Renaming the primary domain of a self-hosted Git forge sounds straightforward until hundreds of automated CI/CD runners, production webhooks, developer SSH key rings, and LFS blob pointers fracture simultaneously. During an infrastructure consolidation, we migrated our central Forgejo instance between two top-level domains without dropping a single active SSH clone or triggering host key verification warnings.
Our self-hosted developer platform initially operated under git.filmtek.cloud. It serves as the single source of truth for our infrastructure-as-code repositories, custom kernel configurations, internal Rust crates, and automated deployment manifests. Over two hundred developer workstations and continuous delivery runners in Kansas City, New York, and Frankfurt interacted with the forge over SSH (git@git.filmtek.cloud:...) and HTTPS.
As part of our brand consolidation under our anycast network domain (git.femboy.fan), we needed to migrate the entire forge:
- Zero Git Remote Breakage: Workstations and CI runners retaining the old
git.filmtek.cloudremote URLs had to continue pushing and pulling transparently over SSH and HTTPS. - Host Key Continuity: Users connecting to the new domain must never encounter
WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!. - Database Integrity: Forgejo stores fully qualified domain names and webhook URLs across several PostgreSQL relational tables and Git LFS metadata pointers. If these strings did not match the new domain, internal webhook dispatchers, OAuth callbacks, and package registries would fail silently.
- Zero Maintenance Window: Developers were actively committing changes throughout the global migration window. We could not take the database offline or lock the git repositories in read-only mode.
The Deep-Dive / Root Cause Analysis
A naive migration typically involves changing app.ini, restarting the service, and pointing a new CNAME record. Doing so in an active production environment causes immediate failures across three architectural layers.
SSH Host Key Fingerprint Mismatch
When an SSH client connects to git.femboy.fan instead of git.filmtek.cloud, OpenSSH validates the host key presented by the server against entries in ~/.ssh/known_hosts.
# ~/.ssh/known_hosts git.filmtek.cloud,10.100.0.15 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGx...
If the new server provisions a new host key, or if OpenSSH serves separate host keys across internal container boundaries, every automated pipeline immediately halts with:
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ @ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY! Someone could be eavesdropping on you right now (man-in-the-middle attack)!
To eliminate warnings, the SSH server must serve the identical Ed25519 and RSA private keys under both domain names, and SSH client configurations must be primed or aliased seamlessly.
PostgreSQL Hardcoded URL Traps
Forgejo does not merely compute URLs dynamically on request. To optimize query performance, several database tables store fully qualified URLs at creation time:
webhook: Webhook payload delivery endpoints often point back to internal forge mirrors or CI webhooks.oauth2_application: Redirect URIs for single sign-on applications hardcode the old origin.lfs_meta_object: Large File Storage tracking keys and verification digests.user: Avatar URLs and external profile links.repository: Avatar and mirror upstream sync URLs.
A simple search across the database dump revealed over 4,200 occurrences of the literal string git.filmtek.cloud:
SELECT count(*) FROM webhook WHERE target_url LIKE '%git.filmtek.cloud%'; -- returned: 148 SELECT count(*) FROM oauth2_application WHERE redirect_uris LIKE '%git.filmtek.cloud%'; -- returned: 22
Simply updating ROOT_URL in /etc/forgejo/app.ini left existing webhooks attempting to push events to stale destinations, causing webhook queue buildup and database lock contention.
The Implementation / Architecture
We executed the migration using a three-phase operational blueprint: dual-homed SSH configuration, transactional schema rewrite, and Caddy layer-4/layer-7 split routing.
Incoming Traffic (Old & New Domains) | v +--------------+ | Caddy Ingress | +--------------+ / \ HTTPS:443 SSH:22 / \ v v [HTTP Layer-7] [SSH Proxy & Passthrough] 308 Redirect Dual Host Keys (Ed25519) or Proxy | v v +-------------------------------+ | Forgejo Container | | (git.femboy.fan) | | - app.ini (ROOT_URL updated) | | - PostgreSQL (Regex updated) | +-------------------------------+PostgreSQL Transactional Schema Rewrite
We authored a PostgreSQL transaction script using regex substitutions to safely rewrite all internal URLs while preserving data types and jsonb structures:
BEGIN; -- 1. Update active webhooks pointing to internal mirrors UPDATE webhook SET target_url = regexp_replace(target_url, 'https?://git\.filmtek\.cloud', 'https://git.femboy.fan', 'g') WHERE target_url LIKE '%git.filmtek.cloud%'; -- 2. Update OAuth2 redirect URIs (array of strings serialized as JSON/text) UPDATE oauth2_application SET redirect_uris = regexp_replace(redirect_uris, 'git\.filmtek\.cloud', 'git.femboy.fan', 'g') WHERE redirect_uris LIKE '%git.filmtek.cloud%'; -- 3. Update external avatar and repository description links UPDATE "user" SET avatar = regexp_replace(avatar, 'git\.filmtek\.cloud', 'git.femboy.fan', 'g') WHERE avatar LIKE '%git.filmtek.cloud%'; -- 4. Update repository clone links in wiki mirrors UPDATE repository SET description = regexp_replace(description, 'git\.filmtek\.cloud', 'git.femboy.fan', 'g'), original_url = regexp_replace(original_url, 'git\.filmtek\.cloud', 'git.femboy.fan', 'g') WHERE description LIKE '%git.filmtek.cloud%' OR original_url LIKE '%git.filmtek.cloud%'; -- 5. Validate no orphaned strings remain in critical tables DO $$ DECLARE v_remaining_hooks INT; BEGIN SELECT count(*) INTO v_remaining_hooks FROM webhook WHERE target_url LIKE '%git.filmtek.cloud%'; IF v_remaining_hooks > 0 THEN RAISE EXCEPTION 'Migration aborted: % orphaned webhooks detected', v_remaining_hooks; END IF; END $$; COMMIT;
Dual Host Key SSH Preservation
To guarantee SSH host key continuity, we extracted the active host keys from the old server and mounted them directly into Forgejo's OpenSSH server configuration (/etc/ssh/):
# Verify host key fingerprints match exactly ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub # 256 SHA256:XkP8n9... root@mail-core (ED25519)
In the Forgejo configuration (/etc/forgejo/app.ini), we configured Forgejo's built-in SSH server and root URL:
[server] PROTOCOL = http DOMAIN = git.femboy.fan ROOT_URL = https://git.femboy.fan/ HTTP_ADDR = 0.0.0.0 HTTP_PORT = 3000 SSH_DOMAIN = git.femboy.fan SSH_PORT = 22 START_SSH_SERVER = false ; Using OpenSSH with authorized_keys command SSH_LISTEN_PORT = 22 DISABLE_SSH = false [repository] DEFAULT_BRANCH = master ENABLE_PUSH_CREATE_USER = true ENABLE_PUSH_CREATE_ORG = true
Caddy Reverse Proxy & Smart Git Smart-HTTP Redirects
Git Smart-HTTP protocol requires careful handling. A standard 301 Moved Permanently on an HTTP POST request can cause legacy Git clients to drop request bodies or fail silently. We implemented HTTP 308 Permanent Redirect (which preserves POST methods and streaming request bodies) alongside smart SSH proxying:
# Legacy domain: preserve git smart-http protocol while migrating web browsers git.filmtek.cloud { # Git Smart-HTTP endpoints: redirect with 308 to preserve POST payloads @git_http { path /info/refs* path /*/git-upload-pack* path /*/git-receive-pack* } handle @git_http { redir https://git.femboy.fan{uri} 308 } # Browser UI traffic: standard 301 handle { redir https://git.femboy.fan{uri} 301 } } # New canonical domain git.femboy.fan { encode zstd gzip # Git LFS chunk upload limit request_body { max_size 5GB } reverse_proxy 10.100.0.15:3000 { header_up X-Real-IP {remote_host} header_up X-Forwarded-For {remote_host} header_up X-Forwarded-Proto {scheme} } }
Lessons Learned & Best Practices
- Use HTTP 308 Instead of 301 for API & Git Endpoints: RFC 7538 defines
308 Permanent Redirect, which guarantees that HTTP clients must not change the request method from POST to GET. Older Git clients clobber pushes when receiving a 301. - Preserve Exact SSH Host Keys Across Renames: Never regenerate SSH host keys when renaming a server. Client known_hosts stores both hostnames and IP addresses against host keys. By maintaining the same key, clients connecting via IP or aliases connect transparently.
- Audit Embedded URLs Inside Relational Tables: Forges and content systems frequently cache absolute canonical URLs in database columns. Always run comprehensive regex audits across dumps before declaring a domain migration complete.
- Test Webhook Queues Post-Migration: Webhooks that fail during migration can back up in task worker queues, eventually exhausting retry pools. Monitor
gitea.logorforgejo.logfor recurring HTTP 404/500 delivery attempts.