We migrated our organization from Confluence Data Center to Confluence Cloud. After the migration, users often ended up on the wrong instance, either following an old DC link to content that had moved to Cloud, or needing to cross-check a Cloud page against its DC version.
To fix this, we built a Forge app that automatically redirects users in both directions. Sharing it here in case it helps others doing similar migrations. Open to questions in the comments.
Happy to discuss trade-offs in the comments.
What the app does
Two redirect directions, handled by two different frontend modules but the same backend:
- Cloud → DC: A user on a Cloud page clicks a button and gets taken to the equivalent DC page (if one exists).
- DC → Cloud: A user follows an old DC link and the app resolves it to the corresponding Cloud page and redirects them there.
The core of the app is a lookup table mapping DC page IDs to Cloud page IDs, plus a handful of fallback strategies for when a direct mapping isn't available.
Why Forge
The whole thing runs on Atlassian Forge. Nothing is self-hosted; there are no external servers. The relevant Forge pieces we used:
- Hosted compute (Node.js functions) for backend resolvers
- Forge SQL for storing the mappings
- Forge UI Kit for the admin dashboard and the redirect page
- External fetch permissions declared in the manifest so the app can open the DC Page without any issues
One thing worth calling out: because everything lives inside Forge, there's no infrastructure to manage, no separate auth layer to build, and no secrets to store. This makes it easy to divest the DC infrastructure eventually
High-level architecture
┌─────────────────────────────────────────────────────────────────────────┐
│ Confluence Cloud (Forge) │
│ │
│ ┌──────────────────────────┐ ┌──────────────────────────────────┐ │
│ │ Frontend Modules │ │ Backend Resolvers │ │
│ │ │ │ │ │
│ │ • Cloud→DC Module │────▶│ • Mapping Lookups │ │
│ │ • DC→Cloud Module │────▶│ • Tracking Writer │ │
│ │ • Admin Settings UI │────▶│ • Statistics & Export │ │
│ └──────────────────────────┘ │ • Schema Migrations │ │
│ └──────────────┬───────────────────┘ │
│ │ │
│ ┌──────────────▼───────────────────┐ │
│ │ Forge SQL │ │
│ │ │ │
│ │ • page_mappings │ │
│ │ • redirect_tracking │ │
│ │ • migration_log │ │
│ └──────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
│ │
▼ ▼
Confluence Cloud API Confluence DC instance
(page metadata, CQL search) (destination for Cloud→DC)A few design notes:
- The countdown is there partly for UX (users see what's happening and where they're going) and partly so the resolver has time to complete without making navigation feel instantaneous-but-broken when something fails.
- Every attempt is tracked, including failures. This is what lets the admin team spot pages that have broken links pointing at them and add missing mappings.
- The three input shapes mean the app can handle links that were copy-pasted out of wikis in various styles over the years.
How the mappings get loaded
The mapping data doesn't generate itself. Before (or during) migration, a separate process exports a CSV of DC → Cloud page ID pairs with all the URL variants and metadata. Then:
- A batch script reads the CSV and sends records to a backend resolver via GraphQL
- The resolver performs an upsert (
INSERT ... ON DUPLICATE KEY UPDATE), so re-runs are idempotent — safe to re-run to patch or update rows - Admins can also add, correct, or delete individual mappings through the dashboard UI
For the batch import itself, we chunk records (around 1,000 per call), dispatch several chunks concurrently, and cap a single run at around 10,000 records. Those numbers were tuned to stay well inside Forge's per-invocation limits; your mileage will vary depending on payload size and Forge SQL latency in your region.
Admin dashboard
Forge global settings page, admin-only (enforced by Forge). Five sections:
| Tab | Purpose |
|---|
| Dashboard | DB health, total mapping count, breakdown by space |
| Test Lookup | Query the mappings table by DC page ID or (space + title) |
| Add Mapping | Manually insert or update a single row |
| Tracking | Daily redirect volume, success/failure rates, paginated log, CSV export |
| Danger Zone | Bulk delete |
The tracking tab turned out to be more valuable than we expected. It's how admins find the "long tail" of broken inbound links that nobody filed a ticket about.
Things that worked well
- Keeping everything in Forge. Zero infrastructure.
- Tracking every DC → Cloud attempt. Track every redirect attempt.
- Idempotent batch import. Being able to re-run the import without fear made mapping corrections painless.
- Fallback chain for URL resolution. Direct page ID → display URL → error. Users almost never hit the error tier in practice.
Things I'd think about if starting over
- Forge SQL limits. Batch import sizing needs careful tuning. Worth prototyping early with realistic data volumes.
- Cutoff date handling. Ours is a single date. If your migration happens in waves per space, you'd want a per-space cutoff instead.
- Admin UX for bulk corrections. Our dashboard handles one row at a time, which is fine for occasional fixes but painful if you discover a systematic mapping issue.
Happy to answer questions on any of this, especially around Forge SQL, the migration system, or the tracking design.