Not every Ionic app belongs in the public App Store. Warehouse scanners, field service tools, clinical tablets, driver apps, branch-staff apps — these ship to a known set of devices owned or managed by a customer, and they are configured by an IT admin rather than by the person holding the phone. Every quarter we get asked the same question by a team two weeks from a pilot: "the app works, how do we actually get it onto 400 managed devices with the right server URL already filled in?"
This tutorial covers the whole path for an Ionic + Capacitor app: choosing a distribution channel on each platform, reading managed configuration pushed by an MDM, locking the device into kiosk mode, and keeping it all testable.
1. Pick the distribution channel first
The channel decides your signing, your review timeline and your update story. Decide before you write plugin code.
| Need | iOS | Android |
|---|---|---|
| App for your own employees | Apple Business Manager + custom app, or Apple Developer Enterprise Program (in-house) | Managed Google Play private app, or direct APK via EMM |
| App for a named client's employees | ABM custom app, distributed to their Organization ID | Managed Google Play private app, published to their enterprise |
| Several clients, same codebase | One custom app per client, or one app with managed config | Private app shared to multiple enterprises |
| Pilot/testing | TestFlight (90-day builds) | Internal app sharing / closed track |
Two traps worth stating plainly:
- The Apple Developer Enterprise Program is not a shortcut. It is for apps distributed to your own employees only. Using it to ship to a client's staff is a revocation risk, and a revoked enterprise certificate kills every app you signed with it, instantly, on every device. For client deployments use ABM custom apps.
- Custom apps still go through App Review. They skip discoverability, not review. Budget the same turnaround, and keep a demo account in the review notes — reviewers cannot get past your SSO screen otherwise.
On Android, a "private app" in Managed Google Play is a normal Play listing with visibility restricted to one or more enterprise IDs. Play's review is lighter and faster, but target SDK requirements still apply.
2. Managed app configuration: the part teams forget
Managed configuration lets the customer's IT admin set values — server URL, tenant ID, feature toggles, whether the camera is allowed — in their MDM console, and the OS delivers them to your app. No build per client, no setup wizard for the end user, no shared secrets in a QR code.
iOS: the managed app config dictionary
iOS exposes it at NSUserDefaults key com.apple.configuration.managed. No
entitlement, no capability; if the app was installed by an MDM and the admin
pushed a config, the dictionary is there.
Android: RestrictionsManager
Android reads a restrictions schema you declare in XML and exposes the admin's
values through RestrictionsManager.getApplicationRestrictions().
Declare the schema in android/app/src/main/res/xml/app_restrictions.xml:
<restrictions xmlns:android="http://schemas.android.com/apk/res/android">
<restriction
android:key="serverUrl"
android:title="Server URL"
android:description="Base URL of the API this app talks to"
android:restrictionType="string"
android:defaultValue="https://api.example.com" />
<restriction
android:key="tenantId"
android:restrictionType="string"
android:title="Tenant ID" />
<restriction
android:key="offlineCaptureEnabled"
android:restrictionType="bool"
android:title="Allow offline capture"
android:defaultValue="true" />
</restrictions>
Reference it from the manifest, inside <application>:
<meta-data
android:name="android.content.APP_RESTRICTIONS"
android:resource="@xml/app_restrictions" />
Whatever keys you declare appear as form fields in the admin's EMM console. Keep the key names identical to your iOS keys — that is what lets you have one TypeScript interface on the app side.
3. A Capacitor plugin that reads both
There is no first-party plugin for this, and the community ones tend to cover one platform. It is about sixty lines; write your own and own it.
Plugin interface (src/definitions.ts):
export interface ManagedConfigPlugin {
getConfig(): Promise<{ config: Record<string, string | number | boolean> }>;
addListener(
event: 'configChanged',
cb: (data: { config: Record<string, unknown> }) => void,
): Promise<PluginListenerHandle>;
}
iOS (ManagedConfigPlugin.swift):
import Capacitor
@objc(ManagedConfigPlugin)
public class ManagedConfigPlugin: CAPPlugin {
private let key = "com.apple.configuration.managed"
override public func load() {
NotificationCenter.default.addObserver(
self, selector: #selector(defaultsChanged),
name: UserDefaults.didChangeNotification, object: nil)
}
@objc func getConfig(_ call: CAPPluginCall) {
let dict = UserDefaults.standard.dictionary(forKey: key) ?? [:]
call.resolve(["config": dict])
}
@objc private func defaultsChanged() {
let dict = UserDefaults.standard.dictionary(forKey: key) ?? [:]
notifyListeners("configChanged", data: ["config": dict])
}
}
Android (ManagedConfigPlugin.kt):
@CapacitorPlugin(name = "ManagedConfig")
class ManagedConfigPlugin : Plugin() {
private var receiver: BroadcastReceiver? = null
override fun load() {
receiver = object : BroadcastReceiver() {
override fun onReceive(c: Context, i: Intent) {
notifyListeners("configChanged", JSObject().put("config", read()))
}
}
context.registerReceiver(
receiver,
IntentFilter(Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED),
Context.RECEIVER_NOT_EXPORTED,
)
}
@PluginMethod
fun getConfig(call: PluginCall) = call.resolve(JSObject().put("config", read()))
private fun read(): JSObject {
val rm = context.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
val b = rm.applicationRestrictions
val out = JSObject()
for (k in b.keySet()) out.put(k, b.get(k))
return out
}
override fun handleOnDestroy() { receiver?.let { context.unregisterReceiver(it) } }
}
Note ACTION_APPLICATION_RESTRICTIONS_CHANGED: on Android the admin can change
config while your app is running, and you will be handed new values without a
restart. On iOS the same is true via the defaults-changed notification. Treat
config as a live stream, not a boot-time constant.
4. Consuming config in the app
Resolve config before the first API call. The cleanest shape is a small typed store with a strict precedence order:
import { registerPlugin } from '@capacitor/core';
const ManagedConfig = registerPlugin<ManagedConfigPlugin>('ManagedConfig');
export interface AppConfig {
serverUrl: string;
tenantId?: string;
offlineCaptureEnabled: boolean;
}
const defaults: AppConfig = {
serverUrl: import.meta.env.VITE_API_URL,
offlineCaptureEnabled: true,
};
let current: AppConfig = defaults;
const subscribers = new Set<(c: AppConfig) => void>();
function normalise(raw: Record<string, unknown>): AppConfig {
const url = typeof raw.serverUrl === 'string' ? raw.serverUrl.trim() : '';
return {
serverUrl: /^https:\/\//.test(url) ? url.replace(/\/$/, '') : defaults.serverUrl,
tenantId: typeof raw.tenantId === 'string' && raw.tenantId ? raw.tenantId : undefined,
offlineCaptureEnabled: raw.offlineCaptureEnabled !== false,
};
}
export async function initConfig(): Promise<AppConfig> {
const { config } = await ManagedConfig.getConfig();
current = normalise(config);
ManagedConfig.addListener('configChanged', ({ config }) => {
current = normalise(config);
subscribers.forEach((fn) => fn(current));
});
return current;
}
export const getConfig = () => current;
export const onConfigChange = (fn: (c: AppConfig) => void) => {
subscribers.add(fn);
return () => subscribers.delete(fn);
};
Three rules we apply on every managed build:
- Validate everything. The values come from a human typing into a console. Reject non-HTTPS URLs, unknown enum values and out-of-range numbers, and fall back to the default rather than crashing on launch.
- Never trust config for authorisation.
showAdminTools: truefrom an MDM is a UI hint. The server still checks the token's claims. Managed config is configuration, not a permission system. - React to changes. When
serverUrlchanges, clear cached data and re-authenticate — the device has effectively been moved to another tenant. A silent switch with a warm cache mixes two customers' data.
5. Kiosk / single-app mode
Scanners and clinical tablets usually run locked to one app.
- Android — the EMM provisions the device as fully managed and sets your
app as the lock task package; call
startLockTask()from an activity. In a Capacitor app the simplest route is a tiny plugin method that calls it onbridge.getActivity(). You can also ask the EMM to pin the app so it starts on boot. Test the hardware back and home keys, and give your support team a documented exit gesture plus a PIN. - iOS — Autonomous Single App Mode. The MDM authorises your bundle ID, and
the app calls
UIAccessibility.requestGuidedAccessSession(enabled:). Without the MDM payload the call silently fails, which is the single most common "kiosk doesn't work" support ticket.
In kiosk mode you own everything the OS normally handles: keep the screen
awake deliberately (@capacitor/keep-awake), handle the device rebooting
mid-shift, and make sure an expired session shows a sign-in screen rather than
a blank WebView the user cannot escape.
6. Updates without Appflow
Managed deployments make web-layer updates tempting, and store rules are narrower than people assume: pushing JavaScript that changes the app's purpose or adds features is not allowed, even in a private app. For config-level change use managed config and server-driven flags. For real code change ship a build.
Practical setup for a managed fleet:
- CI builds signed artefacts on tag — fastlane for both platforms works fine post-Appflow; see our CI/CD guide for the pipeline.
- Upload the IPA to App Store Connect (custom app) and the AAB to the private Play listing; both can be automated.
- Ask the admin to enable auto-update in their EMM, then verify rollout with your own telemetry: report app version on every API call and build an "devices below minimum version" dashboard.
- Implement a minimum supported version check server-side. The API returns
426 Upgrade Requiredwith a message, and the app shows a blocking screen. In a managed fleet you cannot link to the public store page, so the message should tell the user to contact their IT helpdesk.
7. Testing it without an MDM in the room
You will not get a customer's Intune tenant for daily development. Test locally instead.
Android, via the Test DPC app or ADB on a debuggable build:
adb shell dumpsys device_policy # confirm the owner app
# with Test DPC installed as device owner, set restrictions from its UI,
# or in an instrumentation test:
adb shell am broadcast -a android.intent.action.APPLICATION_RESTRICTIONS_CHANGED
iOS — in a debug build, seed the dictionary yourself so the rest of the app behaves identically:
#if DEBUG
UserDefaults.standard.set(
["serverUrl": "https://staging.example.com", "tenantId": "acme"],
forKey: "com.apple.configuration.managed")
#endif
Then add a hidden diagnostics screen — seven taps on the version label — that dumps the resolved config, the raw dictionary and the active server URL. It turns a two-day email thread with a customer's IT team into a screenshot.
Finally, write the admin documentation as part of the deliverable: one page per key, with type, example value, and what breaks if it is wrong. The IT admin configuring your app has never met you and will not read your source.
Checklist
- Distribution channel chosen per platform, ABM/Managed Play accounts in place
- Restrictions schema declared on Android, matching keys documented for iOS
- Managed config plugin reads both platforms and listens for changes
- Config validated, defaults safe, tenant switch clears cached data
- Kiosk mode provisioned by the EMM, exit path documented
- Signed CI builds, auto-update enabled, minimum-version gate live
- Debug seeding plus a diagnostics screen for field support
- Admin-facing config documentation shipped with the build
Enterprise distribution is usually where an otherwise finished Ionic app stalls for a month. We do it as a scoped engagement: channel selection, the managed config plugin, kiosk provisioning with your customer's EMM team, and the release pipeline that keeps the fleet current. Get in touch if you have a pilot date and a fleet of devices waiting.