Hi everyone,
If you still run a Connect add-on on Spring Boot and need to move to Forge Remote, the usual fear is a full rewrite: new auth model, new Jira clients, new tenant keys, new UI, new manifest — all at once.
To avoid a massive rewrite, I built and open-sourced atlassian-runtime-bridge. It handles the infrastructure differences for you, so you can run the exact same Spring Boot backend across Connect, Forge Remote, and Forge Containers without changing your business logic.
Migration phases: What changes vs. what stays
| Phase | What you change | What you keep |
|---|---|---|
| Hybrid | Add Forge Custom UI + remotes; keep Connect iframe for a while | Same Spring app, same controllers |
| Forge Remote | Remove connectModules from manifest; UI is Forge-only |
Same Spring app, same business code |
| Forge Containers (optional, harder) | Run Spring in a container; storage → Forge SQL / KVS | Same service abstractions, different bridge module + data layer |
The library only standardizes the boring part: who is the tenant and how do we call Jira on each runtime.
Step 1 — Fix tenancy before you “go Forge”
This was the biggest data-model mistake to avoid:
- Your tables should not hang off
AtlassianHost.clientKeyor FK into Connect’s host table. - Use
cloudId(orinstallationIdwhere needed) as your tenant key.
Know the difference: cloudIdis permanent for the tenant.installationIdchanges completely every time a customer uninstalls and reinstalls your app. If you tie your domain data toinstallationId, that data becomes orphaned on reinstall. - During transition, a small mapping table
(cloud_id, client_key)is enough: resolvecloudIdfirst, fall back toclientKeyonly for stragglers on native Connect (if any remain).
Migration is about restructuring your database to use cloudId, not dragging legacy Connect tables into your new architecture.
Step 2 — One backend, two transports (hybrid → Forge Remote)
While hybrid:
- Connect iframe still hits your app with Connect JWT.
- Forge Custom UI hits the same
baseUrlvia remotes.
Instead of if (connect) { hostRestClient } else { forgeClient } in every service, I moved calls behind JiraProductAdapter and ManualAuthorizationService. The bridge (bridge-forge-connect) picks Connect vs Forge from SecurityContext.
Practical checklist:
1. Add `bridge-forge-connect` + set `app.id` from manifest.
<dependency>
<groupId>com.github.vzakharchenko</groupId>
<artifactId>bridge-forge-connect</artifactId>
<version>1.0.2</version>
</dependency>
# Forge app id (manifest app.id) — used by bridge services that read ${app.id}
app.id: ari:cloud:ecosystem::app/ba9fac83-69b2-465e-ac5e-918677c2f572
2. Refactor services to adapters (no runtime branching in business code).
@Controller
public class BusinessLogicController {
private static final String JIRA_REST_MYSELF = "/rest/api/3/myself";
private final JiraProductAdapter jiraProductAdapter;
...
ResponseEntity<JiraMyselfResponse> response =
jiraProductAdapter.authenticatedAsCurrentUser().exchange(JIRA_REST_MYSELF, HttpMethod.GET, null, JiraMyselfResponse.class);
3. Third-Party Services & Background Tasks (No Atlassian Context)
When executing work outside of an active Atlassian request thread - such as running a scheduled cron job or handling an incoming webhook from a third-party service - no Atlassian token is present in the request.
@GetMapping("/api/external-integration")
@IgnoreJwt
public Map<String, String> handleThirdPartyCall(
@RequestParam String accountId,
@RequestParam String installationId,
@RequestParam String cloudId) {
AtlassianHost host = new AtlassianHost();
host.setCloudId(cloudId);
host.setInstallationId("ari:cloud:ecosystem::installation/" + installationId);
// Programmatically establish security context for this specific tenant
manualAuthorizationService.authorize(host);
// Standard adapters now work transparently outside of native request context
AtlassianHostUser hostUser = AtlassianHostUser.builder(host).withUserAccountId(accountId).build();
ResponseEntity<JiraMyselfResponse> response = jiraProductAdapter.impersonation(hostUser)
.exchange("/rest/api/3/myself", HttpMethod.GET, null, JiraMyselfResponse.class);
return Collections.singletonMap("status", "processed");
}
4. Frontend Strategy: `One shared codebase` vs. `Two separate bundles`
You need to serve @forge/bridge to Forge users and the classic window.AP scripts to Connect users. There are two ways to handle this:
- Option A: The Alias Trick (One shared codebase) If you use
WebpackorVite, you can keep a single shared frontend and swap only the platform layer using a bundler alias:- Create two platform files:
src/platform/forge.ts(imports and uses@forge/bridge) andsrc/platform/connect.ts(uses standardfetchor classicwindow.AP.request). - Set up your bundler alias (
@platform) based on an environment variable (BUILD_TARGET=forgevsconnect). - In your app, just call
import { invoke } from "@platform";and let the bundler include the right transport layer. (I shared the exact config setup for this pattern in this forum thread).
- Create two platform files:
- Option B: Two separate frontend projects (Simpler path) If refactoring into a shared alias pattern feels too complicated right now, you can simply spin up two separate, independent frontend directories (one built around
window.AP, the other around@forge/bridge).- Trade-off: It is often much faster to set up initially, but keep in mind you will have to manually sync UI features and bugs across both codebases during the hybrid phase.
5. Manifest: add Forge `modules`; when ready, drop `connectModules` → target shape
# Forge remote sample manifest.
# - modules: Forge global page (Custom UI) + triggers that call the Spring remote to keep tokens fresh.
app:
id: ari:cloud:ecosystem::app/ba9fac83-69b2-465e-ac5e-918677c2f572
connect:
key: runtime-bridge-spring-boot-sample-atlaskit
remote: forge-remote
runtime:
name: nodejs24.x
remotes:
- key: forge-remote
baseUrl: https://vzakharchenko.ngrok.dev
operations:
- storage
- compute
- fetch
storage:
inScopeEUD: true
modules:
jira:globalPage:
- key: atlaskit-page-jira-remote
resource: main
resolver:
endpoint: forge-remote-ui
layout: blank
title: Atlaskit (Forge)
# Hourly job: Forge calls remote endpoint system-token-sync -> Spring POST /system/sync (ForgeController).
scheduledTrigger:
- key: token-sync
endpoint: system-token-sync
interval: hour
# After app install/upgrade: same remote as scheduler so offline token path can run once per event.
trigger:
- key: event-install
endpoint: system-token-sync
events:
- avi:forge:installed:app
- avi:forge:upgraded:app
endpoint:
# Custom UI + user calls: general remote to this Spring app (root path on connect baseUrl).
- key: forge-remote-ui
remote: forge-remote
route:
path: /
auth:
appUserToken:
enabled: true
appSystemToken:
enabled: true
# Scheduler + event trigger target: POST {baseUrl}/system/sync (see ForgeController).
- key: system-token-sync
remote: forge-remote
route:
path: /system/sync
auth:
appSystemToken:
enabled: true
resources:
- key: main
path: ../forge-connect/customUI
permissions:
scopes:
act-as-user:connect-jira:
allowImpersonation: false
read:connect-jira:
allowImpersonation: false
read:application-role:jira:
allowImpersonation: true
read:group:jira:
allowImpersonation: true
read:user:jira:
allowImpersonation: true
read:avatar:jira:
allowImpersonation: true
Outcome: you didn’t rewrite Spring - you changed manifest + frontend packaging.
Step 3 - Forge Containers as a Separate Application (The Path to RoA)
Forge Containers shouldn’t be treated as a direct upgrade to your existing Remote backend. Instead, think of it as building a separate Spring Boot application executable using the exact same core business logic.
If you extract your core logic into a separate Maven module that depends only on bridge-common, you can maintain two distinct entry points:
- One module bundled with
bridge-forge-connectfor your public Forge Remote backend. - A completely separate runtime module bundled with
bridge-connect-containerto run inside Atlassian’s isolated cloud. This setup allows you to theoretically achieve full RoA (Running on Atlassian) status with no public ingress at all.
However, to spin up this containerized flavor, you need to adjust three areas:
- Add a Container Build to your Frontend Layer: If you followed the shared frontend path (Option A from Step 2), you don’t need to rewrite your UI components. You simply introduce a third build target for containers:
- Create a
src/platform/container.tsfile that usesinvokeServiceinstead ofinvokeRemoteorwindow.AP. - Add a new target to your bundler (e.g.,
BUILD_TARGET=container) to compile a dedicated frontend asset bundle specifically for your Forge Container deployment. (This is exactly how thefrontend/module in the reference repository is set up via esbuild aliases).
- Replace the Data Layer: The isolated cloud environment does not have a public internet path to your traditional database, nor does it support the legacy Connect host table. You must move all persistence off classic JDBC/JPA over to native platform storage like Forge SQL or Forge KVS.
- Containerize and Update the Manifest: Package this specific Spring Boot build into a Docker image, push it to Atlassian’s ECR, and route your manifest
servicesto it. (Theforge-containersample in the repository documents the exact ECR deployment, manifest layout, and local sidecar proxy + tunnel setup).
Outcome: Your core business services remain untouched, but the runtime is now a fully platform-routed container utilizing the egress proxy instead of a public remotes.baseUrl.
Multi-Module Reference Architecture (All environments from one Core)
The reference repository isn’t just a collection of distinct examples - it is a single multi-module layout sharing one core business logic module. The same core service code runs across native Connect, Forge Remote, and Forge Containers:
core/- Your shared business logic.
Depends only onbridge-common(interfaces/adapters). Zero knowledge of whether it runs on Connect, Forge or inside a Container.forge-connect/- The Hybrid entry point. Bundlescore+bridge-forge-connect+ standard JPA to serve both old Connect traffic and incoming Forge Remote streams simultaneously.forge-remote/- The pure Forge Remote target descriptor layout (the post-migration state once legacyconnectModulesblock is completely retired from the manifest).forge-container/- The Isolated Cloud entry point. Bundles the exact samecore+bridge-connect-container. It automatically strips away heavy JPA autoconfigurations so you can target Forge SQL/KVS and achieve RoA status.frontend/- Single React codebase handling all three environments (window.APvs.@forge/bridgevs.invokeService) cleanly viaesbuildtarget aliases.
README migration section: GitHub - forge-sql-orm/atlassian-runtime-bridge: Spring Boot runtime bridge for Atlassian Forge Remote and Forge Containers with legacy Connect compatibility. · GitHub