---
title: Migrating Ionic Portals Live Updates to Capawesome Cloud
description: Ionic Appflow reaches end of life in December 2027. Here is how to move Ionic Portals live updates to Capawesome Cloud on iOS and Android.
date:
  created: 2026-09-16
  updated: 2026-09-22
authors:
  - robingenz
categories:
  - Cloud
  - Guides
---

# Migrating Ionic Portals Live Updates to Capawesome Cloud

Teams running Ionic Portals have until December 31, 2027 to find a new home for their live updates. Capawesome Cloud can take over that job through the [Ionic Live Update Provider SDK](https://github.com/ionic-team/live-update-provider-sdk){:target="_blank"}, the contract Ionic Portals uses to load web assets from any live update service. Your Portals, their `startDir` layout and your web builds stay as they are. What changes is the object that resolves the bundle and the place you upload it to. This guide walks through the migration on iOS and Android.

<!-- more -->

<div class="capawesome-z29o10a">
  <a href="https://capawesome.io/" target="_blank">
    <img alt="Build and deploy your Capacitor app with Capawesome Cloud" src="https://capawesome.io/assets/banners/cloud-build-and-deploy-capacitor-apps.png?t=1" />
  </a>
</div>

**Key takeaways:**

- Ionic Appflow reaches end of life on December 31, 2027, so Ionic Portals live updates need a new backend before then.
- The integration builds on Ionic Live Update Provider SDK 1.0.0, Ionic Portals 0.14.0, and version 8.5.0 of the Capacitor Live Update plugin on iOS and Android.
- Each Portal gets its own `LiveUpdateIonicManager` from the Capacitor Live Update plugin, configured with a `managerKey`, a Capawesome Cloud app ID and a channel.
- iOS attaches the manager with `liveUpdateSource: .provider(manager:)` and Android with `setLiveUpdateProviderManager(...)`, and both call `syncProvider()` per Portal on launch.
- Seed content moves from Appflow's `portals sync` command to an Xcode build phase on iOS and a Gradle copy task on Android.

!!! info "Reference app"

    A complete, working example is available at [`capawesome-team/ionic-portals-ecommerce-demo`](https://github.com/capawesome-team/ionic-portals-ecommerce-demo){:target="_blank"}, an Ionic Portals e-commerce app that delivers live updates to several Portals through Capawesome Cloud on both platforms. Every snippet in this guide is taken from it.

## How the integration works

Appflow attached a live update configuration to each Portal. On iOS that was `liveUpdateSource: .ionic(liveUpdateConfig: LiveUpdate(appId:channel:syncOnAdd:))`, on Android `setLiveUpdateConfig(context, LiveUpdate(appId, channel))`, and Appflow's SDK resolved the bundle behind it.

Ionic Live Update Provider SDK 1.0.0 replaces that with a single interface, `ProviderManager`. A manager syncs one configured app and exposes the result through `latestAppDirectory`, which Ionic Portals reads when it loads the web view. Portals constructs and uses a manager directly, so there is no provider lookup, no registry and no registration step in between.

The [Capacitor Live Update plugin](../../sdks/capacitor/live-update.md) ships that manager as `LiveUpdateIonicManager`. You construct one per Portal and pass it three values:

- **`managerKey`** scopes the persisted state, so every Portal keeps track of its own active bundle.
- **`appId`** selects the Capawesome Cloud app that hosts the bundle.
- **`channel`** selects the channel to sync.

On construction the manager reads back the bundle ID it stored under its `managerKey` (`UserDefaults` on iOS, `SharedPreferences` on Android) and points `latestAppDirectory` at that bundle, so a Portal serves the last downloaded version offline and before any network call. `syncProvider()` then asks Capawesome Cloud for the latest bundle of that app and channel, downloads it unless it is already on disk, and moves `latestAppDirectory` forward. The Portal picks it up on its next load.

The integration needs Ionic Portals 0.14.0 or later on both platforms, the release built against Live Update Provider SDK 1.0.0, and version 8.5.0 or later of the Capacitor Live Update plugin, which is the first release that ships the manager.

## Create Cloud apps

Capawesome Cloud needs one app per web bundle you ship. A Portals project usually has more than one, and the reference app is no exception with a shop web app in `web/` and a standalone component in `featured-component/`. Create the apps in the [Capawesome Cloud Console](https://console.cloud.capawesome.io){:target="_blank"} and note their app IDs, because you need them in the native code and in every upload command. The repository keeps that mapping in `capawesome.config.json`:

```json
{
  "cloud": {
    "apps": [
      { "appId": "<FEATURED_APP_ID>", "baseDir": "featured-component/" },
      { "appId": "<WEB_APP_ID>", "baseDir": "web/" }
    ]
  }
}
```

If you are new to the platform, the [Capawesome Cloud documentation](../../cloud/index.md) covers apps, channels and bundles in more detail.

## Install the plugin

A Portals host is laid out differently from a Capacitor app. The native hosts live in their own directories, next to the web apps that fill the Portals:

```
ionic-portals-ecommerce-demo/
├── android/PortalsEcommerce/  # native Android host
├── ios/Portals Ecommerce/     # native iOS host
├── featured-component/        # featured component web app
└── web/                       # shop web app
```

There is no `npx cap sync` in a native host, so nothing generates Podfile entries or Gradle includes for you. Both platforms resolve the plugin from a local `node_modules` instead, driven by a `package.json` next to the native project.

### iOS

On iOS, that `package.json` sits next to the `Podfile` and adds the plugin:

```json
{
  "name": "ionic-portals-ecommerce-demo",
  "private": true,
  "dependencies": {
    "@capawesome/capacitor-live-update": "^8.5.0"
  }
}
```

After `npm install`, reference both the base pod and the `IonicProvider` subspec in the `Podfile`, and pin Ionic Portals to a version that ships the provider API:

```ruby
def portals_pods
  pod 'IonicPortals', '0.14.0'
  pod 'CapawesomeCapacitorLiveUpdate', :path => 'node_modules/@capawesome/capacitor-live-update'
  pod 'CapawesomeCapacitorLiveUpdate/IonicProvider', :path => 'node_modules/@capawesome/capacitor-live-update'
end
```

The reference app keeps the other pods of the original demo alongside these and applies the function to every target. Remove an explicit `IonicLiveUpdates` pod from the `Podfile` if it is still there, because nothing in your code calls the Appflow SDK after the migration. Ionic Portals 0.14.0 still depends on it, so `pod install` keeps installing it on its own.

!!! note "Two pods and a local path"

    A Portals host has no `capacitor_pods` helper that would supply the base `CapawesomeCapacitorLiveUpdate` pod, and naming the `/IonicProvider` subspec alone does not pull in the default subspec, so you list both. The plugin is not published on CocoaPods trunk, so `:path` points CocoaPods at the local `node_modules`. The `IonicProvider` subspec adds the `LiveUpdateProvider` dependency and compiles the provider classes in behind the `CAPAWESOME_INCLUDE_IONIC_PROVIDER` flag.

Run `pod install` afterwards and open the `.xcworkspace` rather than the `.xcodeproj`.

### Android

Android uses the same approach with its own `package.json`, which adds Capacitor itself because the Gradle module of the plugin depends on it:

```json
{
  "name": "portals-ecommerce-android",
  "private": true,
  "dependencies": {
    "@capacitor/android": "8.2.0",
    "@capawesome/capacitor-live-update": "^8.5.0"
  }
}
```

Running `npm install` only fetches the Gradle module sources; it does not turn the host into a Capacitor app. Include both modules in `settings.gradle`:

```groovy
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('node_modules/@capacitor/android/capacitor')

include ':capawesome-capacitor-live-update'
project(':capawesome-capacitor-live-update').projectDir = new File('node_modules/@capawesome/capacitor-live-update/android')
```

In the `build.gradle` of the app, exclude the transitive Maven `com.capacitorjs:core` from Ionic Portals and from every other Maven-published Capacitor plugin, so the local `:capacitor-android` module stays the only Capacitor core on the classpath:

```groovy
dependencies {
    implementation('io.ionic:portals:0.14.0') {
        exclude group: 'com.capacitorjs', module: 'core'
    }
    implementation project(':capacitor-android')
    implementation project(':capawesome-capacitor-live-update')
}
```

No separate provider dependency and no Gradle opt-in property are needed. Ionic Portals brings `io.ionic:liveupdateprovider` transitively at runtime, which is what the plugin expects, because it declares the provider SDK as `compileOnly`. The provider SDK requires `minSdkVersion` 24 or higher; the reference app compiles against SDK 36 with Java 21. If your `build.gradle` still lists the Appflow `io.ionic:liveupdates` dependency, remove it. Ionic Portals 0.14.0 for Android does not depend on it.

## Wire the provider

Replacing the Appflow configuration means constructing a manager per Portal and attaching it where the Appflow live update config used to go.

### iOS

`AppDelegate.swift` builds the managers in a `Portal` extension, so every Portal definition can pick the one it needs:

```swift
import IonicPortals
import CapawesomeCapacitorLiveUpdate

extension Portal {
    private static let webAppId = "<WEB_APP_ID>"
    private static let featuredAppId = "<FEATURED_APP_ID>"
    private static let activeChannel = "default"

    private static func providerManager(for target: String) -> LiveUpdateIonicManager? {
        let config: [String: Any]
        switch target {
        case "webapp":
            config = [
                "managerKey": "portal-webapp",
                "appId": webAppId,
                "channel": activeChannel
            ]
        case "featured":
            config = [
                "managerKey": "portal-featured",
                "appId": featuredAppId,
                "channel": activeChannel
            ]
        default:
            return nil
        }

        return try? LiveUpdateIonicManager(configuration: config)
    }

    static let checkout = Self(
        name: "checkout",
        startDir: "portals/shopwebapp",
        plugins: [.type(LiveUpdatePlugin.self)],
        liveUpdateSource: providerManager(for: "webapp").map { .provider(manager: $0) }
    )

    static let featured = Self(
        name: "featured",
        startDir: "portals/featured",
        plugins: [.type(LiveUpdatePlugin.self)],
        liveUpdateSource: providerManager(for: "featured").map { .provider(manager: $0) }
    )
}
```

`liveUpdateSource` is the same property Appflow used, and only the case changes, from `.ionic(liveUpdateConfig:)` to `.provider(manager:)`. The initializer throws `ProviderError.invalidConfiguration` when `managerKey` is missing, which is why the helper returns an optional and `map` leaves `liveUpdateSource` unset if construction fails. Registering `LiveUpdatePlugin` with each Portal, as the reference app does, gives the web layer the JavaScript API of the plugin. The manager works without it, because it creates its own `LiveUpdate` instance rather than reaching for a running plugin.

### Android

`EcommerceApp.java` does the same in `onCreate()`. A small helper builds a manager for a given `managerKey`:

```java
import io.capawesome.capacitorjs.plugins.liveupdate.providers.ionic.LiveUpdateIonicManager;
import io.ionic.liveupdateprovider.ProviderError;

private static final String WEB_APP_ID = "<WEB_APP_ID>";
private static final String CHANNEL = "default";

private LiveUpdateIonicManager liveUpdateManager(String managerKey) {
    Map<String, Object> configuration = new HashMap<>();
    configuration.put("managerKey", managerKey);
    configuration.put("appId", WEB_APP_ID);
    configuration.put("channel", CHANNEL);
    try {
        return new LiveUpdateIonicManager(this, configuration);
    } catch (ProviderError.InvalidConfiguration error) {
        throw new IllegalStateException(error);
    }
}
```

Every Portal then receives its own manager through the builder:

```java
PortalManager.newPortal("checkout")
        .setStartDir("webapp")
        .setPlugins(Arrays.asList(ShopAPIPlugin.class, LiveUpdatePlugin.class))
        .setLiveUpdateProviderManager(liveUpdateManager("portal-checkout"))
        .create();
```

`setLiveUpdateProviderManager(...)` takes the place of Appflow's `setLiveUpdateConfig(context, LiveUpdate(appId, channel))`. The manager receives the `Context` in its own constructor, so the builder call passes the manager alone.

## Sync on launch

Because the managers are constructed directly, a sync can run as early as you want it to, and neither platform needs a delay or a retry loop around it.

### iOS

The app delegate starts one task in `didFinishLaunchingWithOptions`:

```swift
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    Task {
        await syncProviderPortals()
    }

    return true
}
```

That task walks through the Portals and syncs them one after another:

```swift
private func syncProviderPortals() async {
    for portal in [Portal.checkout, .featured] {
        do {
            _ = try await portal.syncProvider()
            print("Capawesome provider sync succeeded for portal '\(portal.name)'.")
        } catch {
            print("Capawesome provider sync failed for portal '\(portal.name)': \(error.localizedDescription)")
        }
    }
}
```

### Android

On Android, `onCreate()` triggers a sync for each Portal right after creating them:

```java
for (String portalName : PROVIDER_PORTALS) {
    syncProvider(portalName);
}
```

`syncProviderAsync()` hands back a `CompletableFuture`, which lets Java callers handle both outcomes without touching coroutines:

```java
private void syncProvider(String portalName) {
    Portal portal = PortalManager.getPortal(portalName);
    if (portal == null) {
        return;
    }
    portal.syncProviderAsync().whenComplete((result, throwable) -> {
        if (throwable == null) {
            Log.d(TAG, "Capawesome provider sync succeeded for portal '" + portalName + "'.");
        } else {
            Log.w(TAG, "Capawesome provider sync failed for portal '" + portalName + "'.", throwable);
        }
    });
}
```

A sync that finds no newer bundle finishes without changing anything. A sync that downloads one moves `latestAppDirectory` to the new bundle and persists its ID, so the Portal serves the new content the next time it loads instead of reloading under the user.

## Bundle seed content

Every Portal loads its initial web content from `startDir` inside the native app, which is what keeps it working offline and on first launch, before any update has been downloaded. Appflow's `portals sync` command fetched that content at build time. Without Appflow, you copy it from your local web build.

On iOS, `scripts/seed-portals.sh` builds both web apps, and a `Seed Portals Web Content` run-script build phase copies their output into the app bundle:

```sh
set -e
RES_DIR="${BUILT_PRODUCTS_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/portals"
seed() {
  if [ -d "$1" ]; then
    rm -rf "${RES_DIR}/$2"
    mkdir -p "${RES_DIR}/$2"
    cp -R "$1/." "${RES_DIR}/$2/"
  else
    echo "warning: $1 not found; skipping portals/$2 seed"
  fi
}
seed "${SRCROOT}/../../web/build" shopwebapp
seed "${SRCROOT}/../../featured-component/build" featured
```

The target directories match each Portal's `startDir`. A build without web output still succeeds, logs the warning and launches with empty Portals until the first sync completes. Capawesome Cloud builds run the script through the `dependencyInstallCommand` of the iOS app in `capawesome.config.json`:

```json
{
  "appId": "<IOS_APP_ID>",
  "baseDir": "ios/Portals Ecommerce/",
  "dependencyInstallCommand": "npm ci && bash ../../scripts/seed-portals.sh"
}
```

On Android, a Gradle `Copy` task does the same job and runs before every build:

```groovy
task CopyWebAssets(type: Copy) {
    def webBuildFolder = '../../../web/build'
    from webBuildFolder
    into layout.projectDirectory.dir("src/main/assets/webapp")
}

preBuild.dependsOn(CopyWebAssets)
```

Android packages `src/main/assets/` into the APK, and each Portal loads it through `setStartDir("webapp")`. Build the web apps first so there is something to copy.

## Publish your bundles

Uploading a bundle works the same way it does for any other app on Capawesome Cloud. Build each web app, then upload its output to the matching Cloud app with the [Capawesome CLI](../../cloud/cli/index.md):

```bash
npx @capawesome/cli apps:liveupdates:upload --app-id <WEB_APP_ID> --path ./web/build --channel default
npx @capawesome/cli apps:liveupdates:upload --app-id <FEATURED_APP_ID> --path ./featured-component/build --channel default
```

The `--channel` value has to match the `channel` of the corresponding manager, and the reference app uses `default` on both sides. From here on, your Portals participate in [Capawesome Cloud Live Updates](../../cloud/live-updates/index.md) like any other app, including channels, rollbacks and bundle limits.

The reference app also builds both native hosts and both web apps on Capawesome Cloud. Its workflow in `.github/workflows/capawesome.yml` calls one command per app on every push to `main`:

```bash
npx @capawesome/cli apps:builds:create --app-id <IOS_APP_ID> --platform ios --type simulator --git-ref <SHA> --yes
```

## Configuration keys

The configuration you hand to `LiveUpdateIonicManager` accepts three keys:

| Key          | Required             | Description                                                                                  |
| ------------ | -------------------- | -------------------------------------------------------------------------------------------- |
| `managerKey` | Yes                  | A stable, unique key per Portal that scopes the persisted bundle state.                        |
| `appId`      | Yes in a Portals host | The Capawesome Cloud app ID that hosts the bundle for this Portal.                            |
| `channel`    | Yes in a Portals host | The channel to sync, for example `default` or `production`.                                   |

The API treats `appId` and `channel` as optional because a Federated Capacitor app can inherit both from the plugin configuration. A Portals host has no running plugin to inherit from, since the manager builds its own instance with default values, so pass both explicitly. The same limit applies to the remaining plugin options, including `publicKey` for code signing, which this path does not read.

## Appflow mapping

Ionic officially recommends Capawesome as its preferred migration partner for Appflow (see [Announcing Our Official Partnership with Ionic](./announcing-partnership-with-ionic.md)).

Most Appflow concepts have a direct counterpart, which keeps the diff small:

| Appflow                                                            | Capawesome Cloud                                                        |
| ------------------------------------------------------------------ | ----------------------------------------------------------------------- |
| `.ionic(liveUpdateConfig: LiveUpdate(appId:channel:syncOnAdd:))`   | `.provider(manager: LiveUpdateIonicManager(configuration:))`            |
| `setLiveUpdateConfig(context, LiveUpdate(appId, channel))`         | `setLiveUpdateProviderManager(new LiveUpdateIonicManager(...))`         |
| Appflow app ID                                                      | Capawesome Cloud app ID (`appId`)                                       |
| Channel                                                             | Channel (`channel`)                                                     |
| `portals sync` seed step                                            | Xcode build phase on iOS, Gradle `Copy` task on Android                 |
| Appflow dashboard or CLI deploy                                     | `@capawesome/cli apps:liveupdates:upload`                               |

## Troubleshooting

- **The Portal stays empty on first launch.** No seed content is bundled at its `startDir`. See [Bundle seed content](#bundle-seed-content).
- **iOS does not find `LiveUpdateIonicManager`.** The `IonicProvider` subspec is missing from the `Podfile`. The provider classes compile only behind the `CAPAWESOME_INCLUDE_IONIC_PROVIDER` flag that this subspec sets.
- **Android fails with `Duplicate class com.getcapacitor...`.** Two Capacitor cores ended up on the classpath. Exclude the transitive `com.capacitorjs:core` from Ionic Portals and from every Maven-published Capacitor plugin, as shown in [Install the plugin](#install-the-plugin).
- **Android throws `NoClassDefFoundError` for `io.ionic.liveupdateprovider`.** The provider SDK is a compile-time dependency of the plugin and arrives through Ionic Portals at runtime, so do not exclude it from the `io.ionic:portals` dependency.
- **Construction fails with `ProviderError.InvalidConfiguration`.** The `managerKey` is missing or empty. Every manager needs one, and it has to be unique per Portal.
- **A sync succeeds but the Portal shows the old content.** Ionic Portals reads `latestAppDirectory` when it loads the web view, so the new bundle appears on the next load of that Portal.
- **iOS logs `Class _TtC13ZIPFoundation7Archive is implemented in both ...` at launch.** Ionic Portals 0.14.0 depends on the Appflow `IonicLiveUpdates` SDK, which ships its own copy of ZIPFoundation next to the one the plugin uses. Live updates worked in our tests with the warning present. Removing your own `IonicLiveUpdates` pod does not silence it, because Portals installs the SDK itself.

## Resources

- [Reference app on GitHub](https://github.com/capawesome-team/ionic-portals-ecommerce-demo){:target="_blank"}
- [Ionic Live Update Provider SDK](https://github.com/ionic-team/live-update-provider-sdk){:target="_blank"}
- [Capawesome Cloud Live Updates](../../cloud/live-updates/index.md)
- [Capacitor Live Update plugin](../../sdks/capacitor/live-update.md)
- [Capawesome CLI](../../cloud/cli/index.md)

## Get migration help

Portals hosts differ from one project to the next, and the wiring above is easier to review together than to guess at. Bring your setup to a migration call and we go through it with you.

[Book an Ionic Appflow Migration Demo](https://cal.com/team/capawesome/ionic-appflow-migration){ .md-button .md-button--primary }

## Conclusion

Migrate one Portal first. Point its manager at a Capawesome Cloud app, publish a bundle to a test channel, and confirm that the Portal serves it after a restart. Every other Portal is then the same three configuration values and one more manager.

Appflow also covered native builds and app store publishing, and [Ionic Appflow Is Shutting Down: Here's Your Migration Plan](./migrating-from-ionic-appflow-to-capawesome-cloud.md) walks through those parts, including the import command that recreates your Appflow configuration in Capawesome Cloud. Questions about a Portals migration are welcome in the [Capawesome Discord server](https://discord.gg/VCXxSVjefW){:target="_blank"}, and the [Capawesome newsletter](https://capawesome.io/newsletter/){:target="_blank"} keeps you posted on new plugin and Cloud releases.
