Capacitor app distribution without an app store

Capacitor wraps your web app in a native shell, and the shell is what a tester installs — an .apk on Android, an .ipa on iOS. Your dist/ folder isn't an app, and no phone can run it. Build the native project, upload the binary, send one link.

Most people reading this came from React or Vue and have never signed a binary. Here's the chain end to end, the two places Ionic teams lose an afternoon, and the one rule Apple gives nobody a way around.

Why a Drive link can't install your app

A file host gives the phone a download; installing an app takes a handshake with the operating system. On iOS that handshake is itms-services, a URL scheme Safari understands: it points at a small plist manifest naming the bundle id, the version and the address of the .ipa, and iOS installs from there. Drive and email attachments serve the raw file, so tapping it does nothing. That is an OTA install — straight from a web link, no cable, no store.

Android stalls later. The .apk downloads, then the browser needs install-unknown-apps permission before the package installer opens, and a tester who taps Cancel has no obvious way back. An install page serves the manifest iOS demands and explains the prompt Android shows, which is the whole argument for sending an install link instead of the file.

The build chain, from npm run build to a link

npm run build           # writes dist/ or www/
npx cap sync            # copies web assets, updates native deps
npx cap open android    # or: npx cap open ios
  1. Build the web assets. Capacitor reads webDir from capacitor.config to find the folder your framework wrote: dist, www or build.
  2. Copy them across. npx cap sync copies the built assets into the Android and iOS projects and updates the native dependencies. For a web-only change, npx cap copy skips the dependency work.
  3. Open the native project. npx cap open ios opens ios/App/App.xcworkspace — the workspace, not the .xcodeproj, because CocoaPods resolves the plugin pods there. npx cap open android opens Android Studio.
  4. Build a release binary. Android Studio: Build → Generate Signed App Bundle or APK, choosing APK. Xcode: Product → Archive.
  5. Upload it and send the link. Drop the .apk on the APK upload page or the .ipa on the IPA upload page: files up to 500 MB, a link that defaults to 3 days, and testers who never make an account.

Android: the APK is not where npx cap run leaves it

npx cap run android compiles a debug variant and pushes it straight to a connected device, which is why plenty of teams have never seen the file. Gradle writes it regardless: debug at android/app/build/outputs/apk/debug/app-debug.apk, and the one you want at android/app/build/outputs/apk/release/app-release.apk. From a terminal, cd android && ./gradlew assembleRelease.

Two things bite. The signing wizard defaults to Android App Bundle, and an .aab is a Play upload format no phone installs, so pick APK. And keep the keystore it generates: Android rejects an update signed by a different certificate, which reaches the tester as a flat “App not installed”. Compare both files in the APK signature checker before you go hunting for a bug in your code.

iOS: the signing step nobody warns web developers about

Any .ipa you hand to a tester is code-signed against a paid Apple Developer Program membership. A free Apple ID gets you a personal team, which can install onto your own device from Xcode on a seven-day profile, but it cannot produce a build you can send anyone. A provisioning profile is the file saying which app, signed by which certificate, may run on which devices. For a tester round you want an ad hoc profile, which lists those devices one by one.

  1. Collect the UDIDs first. An ad hoc build installs only on devices whose UDID — one physical iPhone's unique identifier — is in the profile. Adding one later means a rebuild, so gather them with the UDID checker.
  2. Archive the App target. Choose a real device destination, then Product → Archive. A simulator destination gives you an archive you cannot distribute.
  3. Export for release testing. Distribute App → Release Testing, with the ad hoc profile that carries those UDIDs. Xcode 14 and earlier called it Ad Hoc; since Xcode 15 that label survives only under Custom.

Apple caps ad hoc registration at 100 devices per device type per membership year, and BetaDrop re-signs nothing, so a device missing from the profile fails to install wherever the file is hosted. When an install button does nothing, read the .mobileprovision in the provisioning profile decoder and check the expiry date and the device list.

The live-reload URL that follows your build to testers

This trap is Capacitor's own, and it is why the white-screen threads exist. Live reload points the WebView at a dev server on your machine instead of at the files inside the binary, through the server.url key:

// capacitor.config.ts — remove before you build for testers
server: {
  url: "http://192.168.1.68:8100",
  cleartext: true,
}

A LAN address means nothing on a tester's phone

The WebView asks for a host that does not exist on their network. No crash, no error dialog, no console you can read. Just white.

The Ionic CLI injects that block and strips it when the live-reload command exits, so the copy that survives is usually one somebody added by hand. Capacitor's own guide is blunt about it: “Be careful not to commit the server config to source control.” Check the config before every tester build, and check the copy that ships as well: sync writes it to android/app/src/main/assets/capacitor.config.json and to the matching file in the iOS project.

What needs a rebuild, and what doesn't

Your web assets are copied into the native project and compiled into the binary, so they freeze the moment you run cap copy. A one-character CSS fix means a new binary and a new link. That catches out everyone used to shipping a web app by pushing to main.

What you changedWhat you runNew build for testers?
A line of HTML, CSS or JavaScriptnpm run build && npx cap copyYes
A value in capacitor.confignpx cap copyYes
Added or upgraded a Capacitor pluginnpm install && npx cap syncYes
App icon, splash screen, permissionsedit the native project, rebuildYes
Data your app fetches from your own APInothingNo

The last row is the escape hatch: anything fetched at runtime from your own API changes without a rebuild, and plenty of teams lean on that during a beta. The web build of the same app can sit on free static site hosting from the same dist/ folder.

Publish it from CI in one step

Once the manual path has worked twice, stop doing it by hand. Add this after the job that already produced the artifact:

- name: Publish to BetaDrop
  uses: betadrop-app/upload-action@v1
  with:
    file: android/app/build/outputs/apk/release/app-release.apk
    token: ${{ secrets.BETADROP_TOKEN }}

A runner has no stored login, so the token comes from the environment: create one under Settings → Developer → API tokens and store it as a repository secret. The step exposes an install-url output for a PR comment or a Slack message. Anywhere else, npm install -g @betadrop/cli and betadrop publish <path> --ci print that URL and nothing else; the CLI reference has the flags.

When a store track beats an install link

If your iOS beta is open to strangers and runs for months, TestFlight is the better tool. Public sign-up links and automatic updates are worth the review wait once you stop knowing every tester by name. An install link wins when the group is known and the build has to be on their phones in ten minutes.

Weighing up stacks? The React Native distribution guide covers the Metro and keystore traps that one has instead. None of them apply to a WebView.

Frequently asked questions

How do I share an Ionic app with testers?

Build your web assets, run npx cap sync, then produce a release binary in Android Studio or Xcode and upload that .apk or .ipa. BetaDrop hands back an install link and a QR code; the tester opens it in the phone browser and installs over the air, with no account of their own. Uploads run to 500 MB.

Why can't my testers install the app from a Google Drive link?

Because a file host delivers a download, not an installation. An .ipa installs only when Safari opens an itms-services:// link backed by a manifest naming the bundle id, version and file URL, and Drive serves neither. On Android the .apk downloads, but the browser needs install-unknown-apps permission first.

Where does Android Studio put the APK for a Capacitor app?

A signed release build lands at android/app/build/outputs/apk/release/app-release.apk, and a debug build at android/app/build/outputs/apk/debug/app-debug.apk. The android folder sits in your web project root, created by npx cap add android. Pick the App Bundle option instead and you get an .aab, which Play accepts and a phone cannot install.

Why do my testers see a blank white screen when they open the app?

Check the server block in capacitor.config. Live reload sets server.url to a dev server on your machine's LAN address, and a build still carrying it asks the WebView for a host the tester's phone cannot reach: no crash, no error, just white. Delete the block, rebuild, sync, archive again.

Can I do Ionic app beta testing without the App Store on a real iPhone?

Yes, though the signing rules still apply. The .ipa has to be exported with an ad hoc provisioning profile listing each tester device's UDID, which needs a paid Apple Developer Program membership. What an install link removes is App Store review, Beta App Review for external TestFlight groups, and tester accounts.

Send the build you just archived

You have an app-release.apk, an ad hoc .ipa, or both. Drag one in and your testers are running it before the next stand-up.

iMobile Designs
Developed by iMobile Designs
Made with
in India