What is forge
com.atlassian.confluence.content.render.xhtml.migration.exceptions.UnknownMacroMigrationException: The macro 'html' is unknown.

What is forge

What is Atlassian Forge?

Atlassian Forge is Atlassian's cloud app development platform that allows developers to build apps hosted on infrastructure that is provisioned, managed, monitored, and scaled automatically by Atlassian. In simple terms you write the code, Atlassian handles everything else.

Before platforms like Forge existed, building an app for Jira or Confluence meant setting up your own servers, managing hosting, handling authentication, and worrying about scaling. Forge removes all of that. It gives developers a complete toolkit for extending Atlassian products with hosting, multiple development environments, and API authentication all built in. The goal is to let developers focus more on building and less on infrastructure.

Forge currently supports six Atlassian products: Jira, Jira Service Management, Confluence, Bitbucket, Compass, and Rovo.

Forge apps are written in JavaScript, using Node.js LTS versions 22.x or 24.x.

Apps run inside a secure environment on Atlassian's own infrastructure. A second security layer enforces tenancy isolation and data egress restriction by design meaning one customer's data cannot reach another's. Built-in storage options include a Key-Value Store, Entity Store, and SQL database. For the UI, developers can choose between UI Kit pre-built components that match Atlassian's design system or Custom UI which runs inside an iframe for more flexibility.

Adoption Statistics

The platform has seen strong adoption over 5,600 apps built, 73,000+ CLI downloads, and 13,000+ active developers in the community.

image-20260604-112418.png

A Brief History of How We Got to Forge

To understand why Forge exists, you need to understand what came before it. Atlassian's extensibility model went through four distinct eras, each solving the problems the previous one created.


The P1 Era: The Original Plugin System

Before P2, Atlassian products were extended using Plugin Framework 1, commonly known as P1. Plugins were installed simply by dropping a JAR file into the host application's WEB-INF/lib classpath directory.

P1 had two plugin types:

Plugin Type

How It Worked

Plugin Type

How It Worked

Static

Deployed to WEB-INF/lib, required a full application restart

Dynamic

Could be installed via the web UI, available only in Confluence

The fundamental problem with P1 was inconsistency. The capabilities and features available to a P1 plugin varied significantly across different Atlassian products. A plugin behaving one way in Jira could behave completely differently in Confluence. There was no standardised plugin layer that worked uniformly across the Atlassian ecosystem.


The P2 Era: On-Premise Plugins Done Right

To solve P1's inconsistency problem, Atlassian introduced Plugin Framework 2 (P2). P2 was built on OSGi and Spring Dynamic Modules, meaning every plugin now ran inside a standardised OSGi container regardless of which Atlassian product it was installed on.

P2 plugins were JAR files with special OSGi manifest entries. They ran directly inside the host application's JVM, giving them deep access to the application's internals. This made extensive customisation possible.

Plugin Type

Behaviour

Plugin Type

Behaviour

Static

Still required a full application restart, deployed to WEB-INF/lib

Dynamic

Loaded and activated without restarting the application

Each plugin was made up of one or more plugin modules. A single plugin could do many things, while each module represented one specific function.

The fundamental limitation of P2 was simple: it only worked for local, on-premise deployments. When Atlassian began moving to the cloud, P2 could not extend cloud-based Atlassian products at all. That is why Connect was introduced in 2014.


The Connect Era: Cloud Extensibility via External Hosting

In 2014, Atlassian introduced the Connect framework. At that time, Atlassian did not even have "apps." They had "add-ons" that installed into "JIRA OnDemand," the precursor to Jira Cloud.

Connect was a significant step forward. It allowed developers to build apps hosted externally on their own infrastructure, typically on services like Heroku, AWS, Azure, or Google Cloud Platform, and connect to Atlassian products via APIs. Authentication was handled through JWT tokens and UI ran inside iframes embedded in the Atlassian product.

Connect worked well for years and powered the Atlassian Marketplace through its early growth. But it came with a fundamental problem: Atlassian had limited control over how apps were built, secured, and maintained. Every Connect app lived on a different vendor's server, with different security practices, different data handling, and different compliance postures. For enterprise customers demanding stronger guarantees, this was unacceptable.


Why Atlassian Moved to Forge

As Atlassian grew, Connect began to show its limitations. Atlassian needed a platform where security, governance, and infrastructure were consistent across every app, not fragmented across thousands of vendor servers.

Forge was the answer. As Atlassian officially stated:

"Forge is Atlassian's modern cloud app development platform that replaces Connect. It lets you build secure, scalable apps for Jira and Confluence, running on Atlassian's infrastructure for easier deployment and management."

As Atlassian put it: "When Connect reaches end of support, it will be considered 'use at your own risk.'"


The Forge Era: Atlassian Owns the Infrastructure

Forge fundamentally changed how apps are built for Atlassian products. Rather than vendors managing their own servers, Forge introduced a serverless, security-first environment where Atlassian owns and manages everything: hosting, runtime, scaling, and security.

At its core, Forge runs on AWS Lambda, a serverless FaaS platform provisioned, managed, monitored, and scaled automatically by Atlassian. Apps run inside sandboxed environments within dedicated AWS accounts, enforcing data egress restriction by design. Every external request an app makes must be declared in the app manifest. Nothing leaves Atlassian's infrastructure without explicit permission.

For developers, Forge introduced a complete toolkit:

Tool

Purpose

Tool

Purpose

Forge CLI

Creating, deploying, and managing apps across environments

UI Kit

Declarative framework for building interfaces with just a few lines of code

Custom UI

Building interfaces from scratch inside an isolated iframe

Bridge API

JavaScript API for secure frontend-to-backend communication

Built-in Storage

KVS, Entity Store, SQL, and Object Store, all scoped per installation

Debugging Support

Via IntelliJ and VS Code with tunneling support

Three principles define what Forge stands for:

  • Standardisation: every app follows consistent security practices, deployment methods, and permission models

  • Platform ownership: Atlassian manages infrastructure so vendors can focus on building features

  • Governance at scale: consistent performance and compliance across the entire ecosystem

How Forge Works Internally

This is the part most tutorials skip. Everyone tells you what Forge is but almost nobody explains how it actually works under the hood. All the technical details referenced below can be found in the Atlassian Forge developer documentation.


1. The Compute Layer: AWS Lambda Under the Hood

When your Forge function runs, it does not run on some generic Atlassian server. It runs on AWS Lambda, but in a carefully isolated setup described in Atlassian's security.

Atlassian does not run your code inside its own core AWS account alongside its databases and services. Instead, it provisions dedicated, low-privilege AWS accounts that are completely separated from Atlassian's operational infrastructure. Your code runs in one of these isolated accounts.

Why does this matter? If something goes wrong, such as a malicious app, a memory overflow, or a container escape, the damage is contained. The rogue process only has access to that isolated account, not to Atlassian's core systems or neighboring tenants' workloads.

Every function invocation happens inside one of these sandboxed Lambda containers, with outbound internet access blocked by default. Nothing leaves unless you explicitly declared it.


2. Runtime Evolution: From V8 Sandbox to Native Node.js

Forge did not always run on standard Node.js. Understanding the history here explains many quirks developers encounter today.

The Legacy V8 Sandbox (deprecated)

Originally, Forge isolated your code at the application interpreter level using a custom-engineered V8 JavaScript isolate sandbox. This worked like a custom runtime that mimicked Node.js 14. Every single invocation started fresh: the sandbox bootstrapped from a completely clean state, loaded your code from scratch, ran it, and discarded everything.

This sounds clean but it had serious problems:

  • Cold-start latency on every invocation with no warmup

  • 128 MB memory limit, not enough for real-world apps

  • Most standard npm packages did not work inside the custom isolate

  • Async timers were killed immediately when the function returned

Atlassian fully deprecated this runtime. Apps that had not migrated by October 29, 2024 stopped working. The legacy runtime was completely disabled on February 28, 2025. Full migration steps are available in the legacy runtime migration guide. If you still have snapshots: true in your manifest.yml, remove it and set app.runtime.name: nodejs24.x.

The Native Node.js Runtime (current)

Today, Forge runs your code inside real VM-level sandboxes on standard Node.js LTS, either Node 22 or Node 24. Isolation moved from the application interpreter to the virtual machine layer.

Parameter

Legacy V8

Native Node.js

Parameter

Legacy V8

Native Node.js

Node.js environment

Emulated Node.js 14

Real Node.js 22/24

Memory per invocation

128 MB

512 MB

Sandboxing layer

Custom V8 isolate

VM-level sandbox

Standard npm packages

Mostly blocked

Fully supported

Cold starts

Every invocation

Warm containers reused

Async timers

Killed on function return

Run in background post-return

Default Content-Type

Not set

application/json

Performance improved by 35 to 40 percent compared to the legacy runtime, a direct result of shifting isolation from interpreter to VM level and enabling warm container reuse.


3. The Tenant Isolation Problem and How Forge Solves It

This is the most important thing to understand about Forge's runtime, and it is also where developers make the most dangerous mistakes. Atlassian's full guidance is in the tenant data isolation documentation.

When the native Node.js runtime reuses a warm Lambda container for a new invocation, the Node.js module cache is not cleared. Any variable declared at module scope, outside your handler function, retains its value from the previous call.

Consider this scenario:

Tenant A's request runs → stores data in global cache → Lambda stays warm Tenant B's request arrives → reuses the same warm container → reads Tenant A's cached data

This is a cross-tenant data leak. It is silent. No error, no warning, just wrong data served to the wrong customer.

The tricky part: Jira issue keys like "ABC-123" are not globally unique. The same key can exist in two different tenants' Jira instances. So if you cache by issue key at module scope, you have no safe way to detect a tenant mismatch.

Atlassian guarantees that Tenant A cannot deliberately invoke Tenant B's app instance. But Atlassian does not guarantee that a warm container previously serving Tenant A will not be reused for Tenant B. That responsibility belongs to the developer.

Safe patterns verified from Atlassian docs:

Option 1: Compute everything fresh inside the handler Do not cache anything at module scope. Fetch and compute all tenant data inside the invocation handler function. Slower, but always safe.

Option 2: Partition in-memory caches by cloudId or installationId If you need module-scope caching for performance, key every cache entry by the tenant's cloudId or installationId, not by Jira issue keys or page IDs, which are not globally unique.

// Unsafe const cache = {}; cache['ABC-123'] = issueData; // Safe const cache = {}; const tenantKey = `${cloudId}:ABC-123`; cache[tenantKey] = issueData;

Option 3: Use Forge Storage (recommended) The KVS, Entity Store, and SQL modules are automatically scoped per app installation at the platform level. You cannot accidentally cross tenant boundaries through Forge Storage.

Option 4: bindInvocationContext When asynchronous operations lose their execution metadata and throw a "Forge runtime metadata not found" error, this API re-binds the async callback to the correct request context, ensuring tenant isolation is preserved during background async work.


4. Frontend Architecture: UI Kit 2 vs Custom UI

Forge gives you two ways to build UI, with fundamentally different architectures.

UI Kit 2: Declarative, Browser-Native, No ReactDOM

UI Kit 2 renders directly inside the Atlassian product's browser session. It uses @forge/react components and a custom ForgeReconciler instead of standard ReactDOM.

ForgeReconciler.render(<App />); // NOT: ReactDOM.render() or ReactDOM.createRoot()

The reconciler bridges React's component tree directly into Atlassian's product UI with no iframe boundary and no DOM isolation. UI renders faster because clicking a button does not trigger a serverless Lambda call.

What is not supported due to the absence of a standard DOM:

  • Custom HTML elements

  • React portals

  • Ref forwarding to DOM nodes

  • External scripts

UI Kit 2 provides a rich component library including Stack, Inline, Box, Text, Button, native Comments, Interactive Charts, and platform-specific components for Jira and Confluence. It also handles internationalization across 26 languages automatically.

Custom UI: Full Flexibility Inside a Secure Iframe

Custom UI renders your compiled static assets inside an iframe hosted on an Atlassian-managed domain, completely isolated from the host product's DOM. You get full React freedom: hooks, portals, custom HTML, third-party component libraries. But there are strict rules:

  • All assets must be bundled locally within the app's resource path, no CDN loading

  • The iframe cannot load external scripts or make direct HTTP requests to third-party servers

  • Inline CSS styles are blocked by default unless you explicitly declare 'unsafe-inline' in permissions.content.styles in manifest.yml

  • Every external API call must go through a backend Forge resolver via @forge/bridge

Hybrid Views: Embedding Custom UI Inside UI Kit

You can embed a Custom UI frame inside a UI Kit view using the <Frame> component. Since these two environments run in completely separate window contexts, they cannot share JavaScript references. Communication goes through the @forge/bridge Events API, which passes serialized payloads across the iframe boundary via the browser's secure PostMessage mechanism.

// From inside Custom UI iframe import { events } from '@forge/bridge'; events.emit('USER_SELECTED', { userId: 'abc123' }); // From UI Kit parent import { events } from '@forge/bridge'; events.on('USER_SELECTED', (payload) => { console.log(payload.userId); });

events.on returns a Subscription object so you can unsubscribe and prevent memory leaks.


5. Calling Backend Functions: The Bridge

To call backend logic, write to storage, or hit external APIs, frontend code calls backend FaaS resolvers using invoke() from @forge/bridge.

Here is the full request path when your frontend calls invoke('fetchLabels', { projectKey: 'PROJ' }):

Frontend (iframe or UI Kit browser context) @forge/bridge bundles function key and payload Serializes across iframe PostMessage boundary Atlassian parent window receives it Routes to Forge Gateway Forge Gateway invokes the corresponding Lambda resolver Response bubbles back through the same chain

For REST API calls, @forge/bridge 2.0 provides requestJira and requestConfluence, allowing Custom UI and UI Kit apps to call Jira and Confluence REST APIs directly from the browser. These calls execute under the current logged-in user's OAuth 2.0 context.

Important: if a user does not have permission to access a Jira endpoint, requestJira returns 403 Forbidden even if your app's manifest has the correct scopes. The user's own permission level is the binding constraint, not your app's.

For any admin-level operation, background job, or action the user should not have to authorize, you must delegate to a backend resolver using api.asApp() from @forge/api.


6. Hosted Storage: Four Engines, One Namespace

Forge provides four built-in storage options, all automatically partitioned per installation. The storage namespace is constructed by combining your app's unique ID, the deployment environment, the customer installation ID, and the host product.

Code running in a Jira context cannot access the same app's data stored in a Confluence context, even on the same tenant. Development data is physically isolated from production tables. Two different apps can never read each other's data.

Key-Value Store (@forge/kvs)

For simple key-value persistence: user preferences, app configuration, counters, feature flags.

The consistency model is split depending on how you read:

  • kvs.get(key): strictly consistent, always queries the primary write master, you always get the latest value

  • kvs.query(...): eventually consistent, reads from read replicas, data may be slightly behind

If you write a value and immediately query for it using kvs.query, you might not see the new value. Use kvs.get when freshness matters. The legacy storage module from @forge/api was deprecated as of March 17, 2025. All apps must migrate to @forge/kvs.

Custom Entity Store

For structured data with relationships, filtering, and custom querying. More powerful than raw KVS, less overhead than SQL.

Forge SQL (@forge/sql)

For complex relational data models requiring transactions, joins, and structured queries. Forge SQL runs on a self-hosted TiDB cluster configured in MySQL dialect compatibility mode. Each app installation gets its own dedicated database instance.

Resource

Limit

Resource

Limit

Storage

1 GiB

Tables

200

DML requests/second

150

Per-query memory

16 MiB

SELECT timeout

5 seconds

INSERT / UPDATE / DELETE timeout

10 seconds

Critical constraints developers frequently hit:

  • No foreign key enforcement: ON DELETE CASCADE will not work, cascading deletes must be handled in application code

  • Single query per SQL call: you cannot send multiple statements in one call

  • No AUTO_INCREMENT: use AUTO_RANDOM(S,R) to distribute writes evenly across TiDB shards

  • Store UUIDs as BINARY(16), not as strings, to optimize storage footprint and index performance

Object Store (Early Access)

For binary data, files, and media. Designed for large unstructured content.


7. Security: Zero-Trust Networking by Default

Forge operates on a zero-trust network model. Your app cannot make any outbound network request by default. Everything is blocked at the firewall.

To allow external communication, you must explicitly declare target domains in permissions.external in manifest.yml. At runtime, all outbound requests go through a mandatory egress proxy that inspects the destination hostname, headers, and protocol before allowing the connection.

Plain HTTP is restricted. The proxy enforces HTTPS termination. Only a predefined set of ports are allowed for outbound traffic: 80, 8080, 443, 8443, 8444, 7990, 8089, 8090, 8085, 8060.

For Custom UI, the iframe is governed by strict Content Security Policies. External scripts cannot be loaded from CDNs. Third-party tools like Google Analytics or Sentry cannot be injected. All assets must be bundled locally.

For enterprise customers with dynamic egress requirements, Forge supports Customer-Managed Egress. When permissions.external.configurable.enabled: true is set, administrators can approve specific egress routes through an approval modal without the developer needing to redeploy.


8. Forge Remote: When You Need an External Backend

For apps that need to integrate with external databases, proprietary ML models, or enterprise systems that cannot move into Forge's serverless model, Forge Remote provides a secure bridge.

There are two distinct ways to call a remote backend:

Feature

invokeRemote

requestRemote

Feature

invokeRemote

requestRemote

Proxied through Forge Gateway

Yes

No

OAuth tokens injected

Yes

No

FIT token included

Yes

Yes

Metrics tracked

Yes

No

File uploads supported

No

Yes

Latency

Higher

Lower

When using requestRemote, your backend must verify the Forge Invocation Token (FIT), a cryptographically signed JWT included in the Authorization header. For standard commercial environments you verify it against Atlassian's public JWKS endpoint. For Atlassian Isolated Clouds, you must dynamically construct the JWKS URL by extracting the icLabel from the unverified token and injecting it into a pre-configured URI template.


9. Event-Driven Execution: Async Triggers and Lifecycle Hooks

Forge supports three trigger types declared in the manifest:

  • trigger: fires on product events such as issue created or page updated

  • scheduled-trigger: runs on a cron schedule

  • webtrigger: exposes an HTTP endpoint that external systems can call

All async triggers execute under the identity of the App System User, not any logged-in human. If a Jira project or Confluence space is restricted to specific user groups, your event handler will fail unless the App System User has been explicitly granted access. All REST API calls inside event handlers must use api.asApp().

The Cascading Delete Problem

Forge does not emit events for child entities when a parent is deleted. If an admin deletes a Jira project, Forge emits exactly one event: avi:jira:deleted:project. It does not emit individual events for every issue, comment, or attachment inside that project.

If your app stores data keyed by issue ID, you will accumulate orphaned records forever unless you handle this yourself:

  1. Maintain a local map of project to issue IDs in your persistent storage

  2. Listen for the top-level avi:jira:deleted:project event

  3. Query your local map for all issue IDs in that project

  4. Programmatically delete your local records for each one


10. Data Residency: Where Your Data Physically Lives

Forge Storage automatically inherits Atlassian's data residency capabilities. When an enterprise administrator pins their Jira or Confluence instance to a specific region such as EU, US, or AU, the Forge platform automatically pins your app's storage to that same region. Eleven regions are currently supported. If the customer later migrates to a different region, Forge migrates your app's stored data automatically.

For Forge Remote, you maintain compliance by declaring region-specific base URLs in your manifest:

remotes: - key: remote-backend baseUrl: https://global.backend.com regionBaseUrls: us: https://us.backend.com eu: https://eu.backend.com au: https://au.backend.com

The Forge routing layer reads this at installation time and routes all outbound calls to the correct regional endpoint based on the tenant's data residency setting. This keeps your app eligible for the PINNED compliance badge in the Atlassian administration interface.

Forge vs Connect : What Actually Changed

Feature

Connect

Forge

Feature

Connect

Forge

Hosting

Vendor managed

Atlassian managed

Authentication

JWT (developer managed)

OAuth 2.0 (Atlassian managed)

Data location

Vendor servers

Atlassian cloud

Security

Vendor responsibility

Atlassian enforced

UI flexibility

Higher

Limited (UI Kit)

Infrastructure control

More

Less

Updates

Manual, slower

Automatic, minutes

Where Forge Doesn't Measure Up

Forge is a development platform, but using it on a daily basis helps uncover where it falls short. The constraints that have the greatest impact on real-world development are discussed below. All numerical limits referenced here are documented on the Forge platform limits page.


Your Functions Have a Clock Tick-Tocking

Each Forge function has a hard timeout:

  • Standard functions: 25 seconds

  • Web triggers: 55 seconds

  • Async events and scheduled triggers: 15 minutes (900 seconds)

  • Forge Remote events: 5 seconds

  • Single outgoing request timeout (async events): 180 seconds

This seems quite generous at first, until you are trying to loop through and process tens of thousands of Jira issues or make several API calls per record. Your timer continues ticking away regardless of how quickly your API calls complete, and when the clock stops, so does your function. This pushes you to design with checkpoints from the beginning.


API Calls Add Up Much Quicker Than You Might Think

External egress requests are limited to 100 per runtime minute per invocation, rounded up to the nearest minute. When you are building applications that touch numerous Jira issues, such as a label transfer or a bulk update, each issue may require multiple API calls. Hit this limit and your app will begin throttling.

Calls to Jira and Confluence using requestJira and requestConfluence are counted as internal calls and are excluded from this budget, but calls to any external service count against this limit immediately.


Rate Limits Hit Hard at Scale

Rate limits apply at three levels:

Scope

Limit

Scope

Limit

Per user on a single installation

1,200 invocations per minute

Per installation across all users

5,000 invocations per minute

Per app across all installations in an environment

30,000 invocations per minute

While this works fine for a small team, applications serving large organizations where many users are running operations simultaneously will find these limits become a bottleneck that needs to be addressed by design, with mechanisms like batching and queuing.


Storage Has Limits Too

The KVS documentation outlines the following per-installation limits:

Resource

Limit

Resource

Limit

Value size per key

240 KiB

Key length

500 characters

Read operations per minute

4,000 (at 10 KB request sizing)

Write operations per minute

4,000 (at 10 KB request sizing)