Migrating a Connect + Spring Boot app to Forge Remote (and Containers) without rewriting your service layer

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.clientKey or FK into Connect’s host table.
  • Use cloudId (or installationId where needed) as your tenant key.
    :warning: Know the difference: cloudId is permanent for the tenant. installationId changes completely every time a customer uninstalls and reinstalls your app. If you tie your domain data to installationId, that data becomes orphaned on reinstall.
  • During transition, a small mapping table (cloud_id, client_key) is enough: resolve cloudId first, fall back to clientKey only 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 baseUrl via 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 Webpack or Vite, you can keep a single shared frontend and swap only the platform layer using a bundler alias:
    1. Create two platform files: src/platform/forge.ts (imports and uses @forge/bridge) and src/platform/connect.ts (uses standard fetch or classic window.AP.request).
    2. Set up your bundler alias (@platform) based on an environment variable (BUILD_TARGET=forge vs connect).
    3. 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).
  • 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:

  1. One module bundled with bridge-forge-connect for your public Forge Remote backend.
  2. A completely separate runtime module bundled with bridge-connect-container to 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:

  1. 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.ts file that uses invokeService instead of invokeRemote or window.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 the frontend/ module in the reference repository is set up via esbuild aliases).
  1. 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.
  2. 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 services to it. (The forge-container sample 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:

:link: atlassian-runtime-bridge/examples/atlassian-connect-forge-spring-boot-sample at main · forge-sql-orm/atlassian-runtime-bridge · GitHub

  • core/ - Your shared business logic.
    Depends only on bridge-common (interfaces/adapters). Zero knowledge of whether it runs on Connect, Forge or inside a Container.
  • forge-connect/ - The Hybrid entry point. Bundles core + 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 legacy connectModules block is completely retired from the manifest).
  • forge-container/ - The Isolated Cloud entry point. Bundles the exact same core + 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.AP vs. @forge/bridge vs. invokeService) cleanly via esbuild target 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

Please note that CloudID is actually the ID of the instance. If you have an app that targets multiple host products (i.e. jira and confluence), and store tenant information for each host app separately, you will need more than just the CloudID.

We have encountered this issue as our Figma for Jira and Figma for Confluence apps share the same infrastructure. They used clientKey, which was unique for Jira and for Confluence. However, Cloud ID is host product agnostic, so we couldn’t use this to identify the tenant, and caused serious data integrity issues if the same tenant had both Figma for Jira and Figma for Confluence installed.

Hi Remie,

You are absolutely right, and that’s a very important distinction! cloudId is indeed the tenant identifier at the instance level, defining the boundary of data access and isolation for that specific site.

In your specific case with Figma, if you are running two separate Marketplace applications (with two distinct app.ids) sharing the same backend infrastructure, a single flat cloudId will naturally cause collisions because both apps point to the same site. To enforce proper multi-tenancy isolation in this setup, using a composite tenant key like cloudId + app.id on your backend is usually the safest way to isolate data per app.

Alternatively, if you look at the new Multiple-app compatibility feature, Atlassian now allows combining Jira and Confluence modules under a single app.id using the app.compatibility block in the manifest. But honestly, I think that path is much better suited for brand-new apps than existing Marketplace products, because migrating licensing, deployments, and tenant handling there becomes significantly more complex.