How we built a version adaptive mobile client for self hosted, cloud, and air-gapped deployments
A single mobile codebase that adapts to backend version at login, enabling feature compatibility across deployment types without multiple builds, version locks, or forced upgrades.
A single mobile codebase that adapts to backend version at login, enabling feature compatibility across deployment types without multiple builds, version locks, or forced upgrades.
.png&w=3840&q=75&dpl=dpl_AEAsrQKBYPLKG1Kq2ojCuQqbTpeq)
.png&w=3840&q=75&dpl=dpl_AEAsrQKBYPLKG1Kq2ojCuQqbTpeq)
When your backend is self-hosted, your mobile app has a version problem that web applications never face.
Plane ships self-hosted releases at least monthly. Each release can introduce new API endpoints, modified data structures, and entirely new features. Our cloud platform always runs the latest version. Our self-hosted customers do not, and that is by design. Some upgrade immediately, some delay for weeks to validate internally, and some operate under compliance regimes that pin them to specific versions for months. A growing number run air-gapped deployments where outbound internet access does not exist at all.
This creates a concrete architectural question: how do you build a single mobile app that works reliably across dozens of backend versions, spanning cloud, self-hosted, and air-gapped deployments, without fracturing your codebase or your release pipeline?
Why the conventional approaches fall short
Before arriving at our current architecture, we evaluated the approaches most teams reach for first. Each one breaks down under the realities of self-hosted distribution.
Separate builds per backend version is the most intuitive and the least sustainable. You end up maintaining n backend versions multiplied by m platforms. Security patches have to be coordinated across every active build. App store listings become difficult to manage. The maintenance burden grows with every release and never contracts.
Strict version coupling, requiring mobile app version X to only connect to backend version X, is cleaner in theory. In practice, it forces customers to upgrade their self-hosted instance just to continue using mobile. That directly undermines the autonomy that self-hosting is meant to provide. It also makes air-gapped environments nearly impossible to support, since those deployments operate on deliberately slow update cycles.
Targeting the lowest common denominator avoids version conflicts by restricting the app to features available in the oldest supported backend. But it constrains every user to the pace of the slowest. Cloud customers receive an artificially limited experience. There is no clear mechanism to sunset old versions. Bug fixes and performance improvements cannot reach the users who need them most.
None of these approaches hold up at scale. We needed a different foundation.
Solution: Runtime version adaptation
The architecture we arrived at is built around a single principle: the mobile app makes no assumptions about the backend it connects to. It discovers the backend's identity and capabilities at runtime and adapts accordingly.
One build. One codebase. Every deployment model.
.png&w=3840&q=75&dpl=dpl_AEAsrQKBYPLKG1Kq2ojCuQqbTpeq)
The system is composed of three layers that execute sequentially at login and inform every interaction for the duration of the session:
- Environment and instance discovery: determining where the app is connected and what the backend supports.
- A version compatibility engine: enforcing support boundaries and enabling features declaratively.
- Version-aware API behavior: selecting the correct endpoints and the appropriate request shapes based on actual backend capabilities.
Each layer is deliberately simple in isolation. The value comes from their composition.
Environment and instance discovery
The first decision the app makes is identifying what kind of backend it is communicating with.
Environment selection is explicit. The user chooses between cloud and self-hosted at login. For self-hosted deployments, they provide their instance domain, which becomes the API base URL. There is no auto-detection or DNS inference. Keeping this intentional eliminates an entire class of misrouting issues.
enum AppEnvironment { cloud, selfHosted }
void setSelfHostedEnvironment(String domain)
{
environment = AppEnvironment.selfHosted;
apiClient.baseUrl = domain;
}
After authentication, the app makes a single discovery call to fetch instance metadata: backend version, instance configuration, deployment characteristics.
Future<void> afterLogin() async {
final response = await apiClient.get('/api/instances/');
final metadata = InstanceMetadata.fromJson(response.data);
instanceStore.update(metadata);
localStore.saveInstanceMetadata(metadata);
}This metadata is cached locally and reused across app restarts until logout. Discovery happens once per session, not on every interaction. The cost is negligible. The information it provides drives every subsequent decision the app makes.
The version compatibility engine
With the backend version known, the app enforces a hard floor: a minimum supported backend version, defined in each mobile release.
The rule is binary. A backend version is either at or above the minimum and fully supported, or below it and blocked entirely. There is no deprecation phase, partial support, or degraded mode.
enum VersionStatus { supported, unsupported }
VersionStatus evaluateVersionSupport(
String backendVersion,
String minimumSupportedVersion,
) {
return compareVersions(backendVersion, minimumSupportedVersion) < 0
? VersionStatus.unsupported
: VersionStatus.supported;
}If the backend is unsupported, the app blocks access immediately with a clear upgrade message rather than presenting a partially functional or silently broken experience.
This binary model was a deliberate choice. Deprecation phases sound reasonable in the abstract, but they introduce uncertainty for users who cannot tell whether their experience is degraded, and for engineers who have to maintain code paths for states that are "mostly working." A hard boundary is easier to reason about, easier to communicate, and easier to maintain.
Progressive feature enablement
Within the supported version range, not every feature is available on every backend. A feature introduced in version 0.22 should not appear when the app is connected to a 0.20 backend.
Each feature declares the minimum backend version it requires. This declaration lives in a central registry, not scattered across UI components or API calls.
class AppFeatureInfo {
final AppFeatures feature;
final String? minSupportedVersion;
const AppFeatureInfo({
required this.feature,
this.minSupportedVersion,
});
}A single helper evaluates whether a feature is available for the current session:
bool isFeatureEnabled(AppFeatures feature) {
final info = appFeatureInfos.firstWhereOrNull(
(f) => f.feature == feature,
);
if (info == null) return false;
if (info.minSupportedVersion == null) return true;
return compareVersions(
instanceVersion,
info.minSupportedVersion!,
) >= 0;
}If a feature is not declared, it is disabled. If it has no minimum version constraint, it is available everywhere. Otherwise, the version comparison decides. UI components and business logic never perform version checks directly. They query the compatibility engine.
Feature flags layer on top of this when additional control is needed for gradual rollouts or experimentation, but the majority of features rely solely on version compatibility. The result is predictable: if you know the backend version, you know exactly what the app will do.
Version-aware API behavior
The compatibility engine does not only gate UI elements. It drives API behavior.
Rather than maintaining parallel API clients or versioned service layers, the app selects endpoints and request shapes dynamically based on feature availability.
Future<List<Issue>> fetchIssues(String projectId) {
if (appFeatureHelper.isFeatureEnabled(AppFeatures.advancedFilters)) {
return apiClient.get('/api/v2/projects/$projectId/issues');
}
return apiClient.get('/api/v1/projects/$projectId/issues');
}When a newer API is not available, the app falls back to a stable alternative without runtime failures or forced upgrades. The user receives the best experience their backend can support, and the app handles the routing silently.
This pattern keeps API evolution decoupled from mobile releases. A new endpoint can ship on the backend, and the mobile app can adopt it on its own schedule, without breaking compatibility with older backends that do not yet support it.
.png&w=3840&q=75&dpl=dpl_AEAsrQKBYPLKG1Kq2ojCuQqbTpeq)
Air-gapped deployments
One of the less expected benefits of runtime version adaptation is that it enables first-class support for air-gapped deployments without introducing special cases or separate mobile builds.
Air-gapped environments are common in security-sensitive organizations: Defense, Government, Finance, critical infrastructure. These systems operate inside a restricted network perimeter where outbound internet access is intentionally blocked. Supporting them is typically treated as a distinct engineering effort that involves custom builds, alternate distribution channels, and stripped-down feature sets.
In our case, air-gapped support fell out of the existing architecture with almost no additional work.
The air-gap challenge
In air-gapped environments, several assumptions that mobile apps routinely depend on do not hold.
The device cannot access public internet services. External analytics and crash reporting endpoints are unreachable. Backend upgrades happen on controlled, infrequent schedules dictated by organizational policy. All communication must remain within a secure internal network.
A mobile app that presumes constant internet access or relies on external service dependencies will either fail to function or violate the security expectations that motivated the air gap in the first place.
How we support air-gapped deployments
Air-gapped support is achieved by reusing the same runtime adaptation mechanisms already present in the app. No new abstractions were introduced.
During login and instance discovery, the backend exposes whether the instance operates in an air-gapped environment. This information becomes part of the app's instance context, alongside backend version and environment type.
When an instance is identified as air-gapped, the app adjusts its behavior automatically. All communication is limited to the internal backend endpoint. No external analytics or crash data is collected. Features and APIs are enabled only if supported by the backend version.
This requires no special configuration, custom builds, or alternate code paths.
Runtime flow in an air-gapped setup
User Device (on secure corporate network)
│
│ HTTPS over internal connectivity
▼
Air-Gapped Plane Instance
│
│ Backend capability and policy awareness
▼
Mobile App enables supported features
In this flow, the mobile app operates entirely within the secure network. The backend provides version and policy signals, and the app enables only the features and behaviors that are explicitly supported.
There is no dependency on public internet access at any point in the lifecycle.
Security considerations
Security is the primary motivation for air-gapped deployments. In these environments, the mobile app must guarantee that no data leaves the secure network boundary.
When an instance is marked as air-gapped, the app enforces a strict security policy that disables all external communication and data collection.
class SecurityPolicy {
final bool allowExternalNetworkCalls;
final bool enableAnalytics;
final bool enableCrashReporting;
const SecurityPolicy({
required this.allowExternalNetworkCalls,
required this.enableAnalytics,
required this.enableCrashReporting,
});
}
// Applied automatically for air-gapped instances
const SecurityPolicy airGappedSecurityPolicy = SecurityPolicy(
allowExternalNetworkCalls: false,
enableAnalytics: false,
enableCrashReporting: false,
);
This ensures that all network traffic is restricted to the internal backend, no analytics or crash data is transmitted outside the environment, and no external services are contacted at runtime.
Core application assets are bundled with the app. All runtime data is fetched exclusively from the internal backend.
Version lifecycle management
Every mobile release defines a minimum supported backend version. When that minimum is raised in a subsequent release, older backend versions become unsupported automatically. Legacy compatibility logic can be removed. The codebase stays lean.
There are no deprecation warnings, grace periods, or sunset timelines to manage. Forward compatibility is the default: any backend version at or above the minimum works without requiring mobile app changes.
For users on unsupported backends, the app communicates clearly that an upgrade is required rather than presenting a partially functional interface.
Testing against version boundaries
Runtime adaptation increases the number of valid mobile-backend combinations. Exhaustive coverage across all of them is neither practical nor necessary.
Manual testing targets the two most common self-hosted configurations: the latest release and its immediate predecessor. These cover the vast majority of active deployments.
Unit and widget tests handle the rest. They simulate different backend versions and instance configurations to verify feature availability, UI adaptation, and API selection without requiring real backend instances.
test('disables bulk actions on unsupported backend version', () {
final helper = AppFeatureHelper(instanceVersion: '0.19.0');
expect(helper.isFeatureEnabled(AppFeatures.issueBulkActions), false);
});Testing concentrates on version boundaries: below the minimum, exactly at the minimum, and above it. These are the only points where behavior changes, so they are the only points that require validation. Everything between boundaries is, by design, identical.
The test suite remains fast, deterministic, and tightly aligned with the rules the app enforces in production.
Lessons learned during implementation
Building a single mobile app that adapts at runtime across cloud, self-hosted, and air-gapped deployments surfaced several practical lessons, both in what worked and in what we would approach differently.
What worked well
Declarative feature rules made the system easier to reason about from the beginning. By defining feature availability centrally and tying it to backend version support, we avoided scattering version checks across the codebase. When a new feature ships, it is declared once in the registry with its minimum backend version. Every downstream decision, from UI rendering to API routing, flows from that single declaration. This made the system auditable and predictable within the first few releases.
The minimum supported backend version provided clear and enforceable compatibility boundaries. Any backend version at or above the minimum worked automatically, giving us forward compatibility without frequent mobile app updates. A hard floor is easier to reason about than a deprecation spectrum, and it eliminated the ambiguity that partial support introduces for both users and engineers.
Conservative defaults proved critical. Treating unknown or undeclared features as unavailable meant the app always failed safely. A missing declaration results in a hidden feature, not a crash. This matters most in air-gapped environments where debugging is constrained and tolerance for breakage is low.
Instance metadata caching eliminated performance concerns. Backend version and environment information are discovered once during login and reused across sessions. Runtime checks against this cached context are lightweight, and there is no repeated overhead from discovery calls on every interaction.
What we would do differently
Relying primarily on backend version numbers worked well, but starting earlier with explicit capability signals would have further reduced coupling between features and version numbers. Version numbers are a proxy. Having the backend advertise its own capabilities directly, rather than requiring the mobile app to infer them from a version string, would make the system more resilient to non-linear release patterns and reduce the assumptions encoded in the mobile app.
Earlier investment in internal visibility tooling for supported versions and feature boundaries would have simplified validation and communication across teams. As the number of features and supported versions grew, having a shared dashboard or reference showing which features are active for which version range would have reduced coordination overhead between mobile, backend, and QA.
Automating parts of the feature compatibility definitions from backend contracts could also reduce long-term maintenance effort. Today, feature declarations in the mobile app are maintained manually. Generating them from backend API contracts or capability manifests would keep the two in sync with less ongoing effort.
Trade-offs we accepted
We accepted additional conditional logic in the mobile app in exchange for maintaining a single build across all deployment models. The codebase carries branching paths for features and API behavior that a version-locked app would not need.
The app size increased slightly to support this conditional behavior, since code paths for older and newer API patterns coexist. However, this avoided fragmented releases and version-locked builds, which would have created far greater operational burden.
Testing version compatibility adds complexity. Validating that features enable and disable correctly across version boundaries requires dedicated test coverage. Limiting manual testing to the latest self-hosted versions and relying on unit and widget tests for boundary validation keeps this manageable without exhaustive matrix testing.
These trade-offs proved worthwhile. The cost of conditional logic, modest size increase, and targeted test investment is substantially lower than the cost of maintaining multiple builds, coordinating fragmented releases, or locking customers into upgrade cycles.
Future directions
While the current runtime adaptation model is stable and effective, there are clear opportunities to evolve it further as the product and deployment landscape grows.
Backend-driven feature compatibility
Today, feature compatibility rules are primarily defined within the mobile app. Each feature declares its minimum backend version, and the compatibility engine evaluates availability locally.
In the future, this responsibility can shift more towards the backend. The backend can expose feature support and compatibility information dynamically, allowing the mobile app to discover which features are supported at runtime rather than relying only on locally maintained rules.
In this model, the backend becomes the primary source of truth for feature support. The compatibility rules defined in the mobile app act as a fallback for cases where backend information is unavailable, such as during network interruptions or when connecting to older backend versions that do not yet expose capability metadata. Caching ensures that discovered rules are preserved across sessions, maintaining consistency even when the backend is temporarily unreachable.
This approach reduces the need for frequent mobile updates to accommodate new features while keeping behavior consistent and resilient.
Telemetry for air-gapped environments
Collecting analytics and crash information is inherently challenging in air-gapped environments due to the absence of external connectivity. Today, when the app operates in an air-gapped deployment, no telemetry is collected at all. This is the correct default for security, but it limits the organization's ability to diagnose issues and understand usage patterns.
A future direction is to support organization-managed telemetry, where crash logs are stored in the organization's own database, usage analytics are captured locally within the secure environment, and all data remains fully inside the network perimeter.
This would allow organizations to debug mobile app issues more effectively, gain insight into how the mobile app is used within their environment, and share relevant diagnostic information with the product team when they choose to, all without violating air-gapped security constraints.
Conclusion
Building a version-adaptive mobile app resolved a fundamental tension in our self-hosted product: how does one innovate rapidly without breaking trust with customers who operate on their own timelines?
The answer was not multiple apps, forced updates, or lowest-common-denominator features. It was a system built on a single idea: the mobile app discovers and respects server capabilities rather than encoding assumptions about them.
The engineering investment is real: conditional logic, a compatibility engine, version-aware API routing, and boundary-focused testing all represent deliberate complexity. But the return is a single codebase and a single release pipeline that works across cloud, self-hosted, and air-gapped deployments without compromise.
Recommended for you



