+1 (415) 843-4662

Enterprise distribution for Ionic apps: MDM, managed app config and kiosk mode

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.

NeediOSAndroid
App for your own employeesApple 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 employeesABM custom app, distributed to their Organization IDManaged Google Play private app, published to their enterprise
Several clients, same codebaseOne custom app per client, or one app with managed configPrivate app shared to multiple enterprises
Pilot/testingTestFlight (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:

  1. 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.
  2. Never trust config for authorisation. showAdminTools: true from an MDM is a UI hint. The server still checks the token's claims. Managed config is configuration, not a permission system.
  3. React to changes. When serverUrl changes, 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 on bridge.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 Required with 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.