How to publish a Flutter app to TestFlight without a Mac

Your Flutter app runs on Android and you want to test it on an iPhone or iPad too, but you work on Windows and don't own a Mac. That doesn't have to stop you. In this guide you build and sign the iOS version in the cloud with Codemagic, upload it through App Store Connect to TestFlight and install it on your own device. All without a Mac.

I wrote these steps down while publishing a real app (October 2026), including the pitfalls I actually ran into. Replace the example values (My App, com.yourdomain.myApp, src/my_app, โ€ฆ) with the ones from your own project.

Lees dit artikel in het Nederlands: Flutter-app naar TestFlight zonder Mac (stap voor stap).

Publishing the Android version too? Read How to publish a Flutter app to Google Play with GitHub Actions.

Legend: ๐Ÿ’ถ = costs money ยท โณ = waiting time ยท โš ๏ธ = pitfall we actually ran into

Contents

  1. Overview and requirements
  2. Decisions up front
  3. Apple Developer account
  4. Preparing the Flutter project
  5. AdMob for iOS (only if the app shows ads)
  6. Codemagic: first build without signing
  7. App Store Connect: API key, bundle ID and app record
  8. Code signing: certificate and provisioning profile
  9. Codemagic: signed build to TestFlight
  10. Building automatically on a release tag
  11. TestFlight: adding testers and installing
  12. Troubleshooting
  13. Quick checklist

0. Overview and requirements

Overview of the steps

What Why Cost
Apple ID with two-factor authentication Required for the Developer account free
Apple Developer Program Signing, TestFlight, App Store ๐Ÿ’ถ โ‚ฌ99/year
Codemagic account macOS build machine in the cloud free tier (500 min/month)
GitHub repo with the Flutter project Source for Codemagic free
iPhone or iPad Testing through the TestFlight app โ€”
Public privacy policy URL Required in App Store Connect and in the app โ€”

Total lead time the first time: about 2โ€“4 days, mostly โณ waiting on Apple (account activation, DSA verification). The actual work takes half a day.

1. Decisions up front

Settle these before you start; some of them can't be changed later.

Decision Example Note
Bundle ID com.yourdomain.myApp โš ๏ธ No underscores on iOS (Android allows them). Can't be changed after the first upload. Use your domain in reverse.
App name (App Store) My App Max. 30 characters, must be unique in the App Store.
Account type Individual Organization requires a D-U-N-S number and takes longer.
Devices iPhone + iPad iPad means extra screenshots and layout checks.
Orientation iPhone portrait only, iPad all โš ๏ธ iPad requires all 4 orientations or UIRequiresFullScreen.
SKU myapp-ios Internal code, only visible to you.
Privacy policy URL https://โ€ฆ/privacy/ Also add it as a link inside the app (Apple guideline 5.1.1).

2. Apple Developer account

2.1 Preparing your Apple ID

  1. On the iPhone: Settings โ†’ [your name] โ†’ Sign-In & Security โ†’ turn on Two-Factor Authentication.

2.2 Enrolling ๐Ÿ’ถ โณ

  1. Install the Apple Developer app on the iPhone (easiest route: payment through the App Store, identity check with your ID card). Alternative: https://developer.apple.com/programs/enroll/.
  2. Choose Individual, pay โ‚ฌ99.
  3. โณ Wait for the confirmation email ("Thank you for joining the Apple Developer Program"), usually 24โ€“48 hours.

Tip: steps 3 and 5 (preparing the project, unsigned build) can already be done while you wait.

2.3 Agreements in App Store Connect

  1. Go to App Store Connect โ†’ Business.
  2. Free Apps Agreement: becomes active once you accept the terms. Status must be Active.
  3. Paid Apps Agreement: only needed for paid apps or in-app purchases. Ad revenue goes through AdMob, so for a free app with ads this can stay on New.

2.4 EU Digital Services Act (DSA) โณ

Required to publish in the EU (TestFlight for internal testers already works without it).

  1. Business โ†’ Compliance โ†’ Digital Services Act.
  2. Choose Trader if you earn any income (ads count too). โš ๏ธ Your name, address, phone number and email are then shown publicly in the App Store.
  3. Apple asks for two documents (PDF/JPEG/PNG, max. 10 MB):
Document What works (Belgium) Where
Name Identification Document (business or legal document with your name) Extract from the KBO (Crossroads Bank for Enterprises) / certificate of registration KBO Public Search or your business counter
Address Identification Document KBO extract (if the address matches), certificate of residence (attest van woonst), recent energy/internet bill, bank statement Certificate of residence: Mijn Burgerprofiel or Mijn Dossier

Outside Belgium, use the equivalent from your own business register and municipality.

  • โš ๏ธ Name and address must match exactly what you entered at Apple.
  • On a bill or bank statement you may black out amounts; not your name, address and date.
  • The status becomes In Review; โณ a few days. You can carry on in the meantime.

3. Preparing the Flutter project

Everything happens in the ios/ folder of the Flutter project (in a monorepo e.g. src/<app>/ios/). This can all be done on Windows.

3.1 Bundle ID and minimum iOS version

In ios/Runner.xcodeproj/project.pbxproj (find and replace all):

PRODUCT_BUNDLE_IDENTIFIER = com.example.myApp;             โ†’ com.yourdomain.myApp;
PRODUCT_BUNDLE_IDENTIFIER = com.example.myApp.RunnerTests; โ†’ com.yourdomain.myApp.RunnerTests;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;                         โ†’ 13.0

If ios/Flutter/AppFrameworkInfo.plist contains a MinimumOSVersion, set it to 13.0 as well.

3.2 ios/Runner/Info.plist

Change or add inside <dict>:

<!-- Name below the icon on the home screen -->
<key>CFBundleDisplayName</key>
<string>My App</string>
<!-- Short internal name, max. 15 characters -->
<key>CFBundleName</key>
<string>My App</string>

<!-- No custom encryption: Apple won't ask about it on every upload -->
<key>ITSAppUsesNonExemptEncryption</key>
<false/>

<!-- iPhone: portrait only -->
<key>UISupportedInterfaceOrientations</key>
<array>
    <string>UIInterfaceOrientationPortrait</string>
</array>
<!-- iPad: all four (required for split view), keep the default list -->

If the app shows ads, also see step 4 for GADApplicationIdentifier, SKAdNetworkItems and NSUserTrackingUsageDescription.

3.3 App icons

โš ๏ธ The App Store rejects icons with transparency. Use a 1024ร—1024 PNG without an alpha channel.

  1. Add flutter_launcher_icons:

    flutter pub add --dev flutter_launcher_icons
  2. Create flutter_launcher_icons.yaml next to pubspec.yaml:

    flutter_launcher_icons:
      android: false        # Android icons already exist
      ios: true
      image_path: "assets/icon/app_icon.png"   # or the existing Android icon
      remove_alpha_ios: true
  3. Generate:

    dart run flutter_launcher_icons

3.4 Splash screen

If you use flutter_native_splash:

dart run flutter_native_splash:create

โš ๏ธ This also regenerates the Android and web files. If you only want to update iOS, revert those changes (git checkout -- android web) or set android: false / web: false in the config.

3.5 Inside the app

  • Link to the privacy policy (e.g. on the "About" screen or in the settings). Apple requires this.

  • Wide screens (iPad): limit the content to a readable width, e.g.:

    body: Center(
      child: ConstrainedBox(
        constraints: const BoxConstraints(maxWidth: 700),
        child: page,
      ),
    ),

3.6 Checking

dart format --set-exit-if-changed lib test
flutter analyze
flutter test

4. AdMob for iOS (only if the app shows ads)

4.1 Creating the app in AdMob

  1. AdMob โ†’ Apps โ†’ Add app.
  2. Platform iOS.
  3. โš ๏ธ Question "Is the app listed on a supported app store?" โ†’ No. (If you choose Yes, you have to search for the app in the App Store, and it doesn't exist there yet.)
  4. Enter the name โ†’ Add app โ†’ note the app ID ca-app-pub-XXXXXXXXXXXXXXXX~XXXXXXXXXX.
  5. Ad units โ†’ Add ad unit โ†’ Banner โ†’ note the ID ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX.
  6. After publishing in the App Store: App settings โ†’ Add app store details to link them. Until then AdMob shows few or no real ads (that's normal).

4.2 Privacy & messaging

In AdMob โ†’ Privacy & messaging, for the iOS app:

  • Create and publish a GDPR message (EEA/UK/CH).
  • Create an IDFA explainer message (shown before Apple's ATT popup).

4.3 Info.plist

<key>GADApplicationIdentifier</key>
<string>ca-app-pub-XXXXXXXXXXXXXXXX~XXXXXXXXXX</string>

<!-- Text in the ATT popup ("App Tracking Transparency") -->
<key>NSUserTrackingUsageDescription</key>
<string>This identifier will be used to deliver personalized ads to you.</string>

<!-- At least Google's own network ID -->
<key>SKAdNetworkItems</key>
<array>
    <dict>
        <key>SKAdNetworkIdentifier</key>
        <string>cstr6suwn9.skadnetwork</string>
    </dict>
</array>
  • โš ๏ธ Without GADApplicationIdentifier the app crashes on launch. If you don't have your own app ID yet, temporarily use Google's test app ID ca-app-pub-3940256099942544~1458002511.
  • The full SKAdNetwork list (third-party buyers) is in the Google Mobile Ads iOS documentation.

4.4 Test IDs in debug

Always use Google's test banner IDs in debug and profile builds; real IDs only in release:

static String get bannerAdUnitId {
  if (kReleaseMode) {
    return Platform.isAndroid ? _androidBannerAdUnitId : _iosBannerAdUnitId;
  }
  return Platform.isAndroid
      ? 'ca-app-pub-3940256099942544/6300978111'   // Android test banner
      : 'ca-app-pub-3940256099942544/2934735716';  // iOS test banner
}

โš ๏ธ TestFlight installs a release build โ†’ real ads. Never click your own ads.

Use the UMP SDK (included in google_mobile_ads): request consent after the first frame and only start Mobile Ads once ConsentInformation.instance.canRequestAds() is true. With an IDFA message in AdMob, UMP then shows the ATT popup by itself; no extra code needed.

5. Codemagic: first build without signing

Proves that the app compiles for iOS. Can be done before the Developer account is active.

5.1 Account and repo

  1. Go to https://codemagic.io โ†’ Sign up with GitHub.
  2. Add application โ†’ pick the GitHub repo โ†’ project type Flutter App (via codemagic.yaml).

5.2 codemagic.yaml in the root of the repo

workflows:
  ios-unsigned:
    name: iOS unsigned build
    instance_type: mac_mini_m2
    max_build_duration: 60
    working_directory: src/my_app          # leave out if the Flutter project is in the root
    environment:
      flutter: 3.47.3                       # same version as locally/CI
      xcode: latest
      cocoapods: default
    cache:
      cache_paths:
        - $HOME/.pub-cache
        - $HOME/Library/Caches/CocoaPods
    scripts:
      - name: Get packages
        script: flutter pub get
      - name: Run tests
        script: flutter test
      - name: Build iOS (no code signing)
        script: flutter build ios --release --no-codesign
    artifacts:
      - build/ios/iphoneos/*.app            # relative to working_directory
      - /tmp/xcodebuild_logs/*.log
  • You don't need a Podfile in the repo: Flutter creates it during the build on macOS.
  • Paths under artifacts are relative to working_directory.

5.3 Starting

  1. Commit and push codemagic.yaml.
  2. Codemagic โ†’ app โ†’ Start new build โ†’ workflow iOS unsigned build โ†’ branch โ†’ Start.
  3. Result: all steps green and a Runner.app.zip artifact. You can't install this file (it isn't signed); it only proves the build works.

6. App Store Connect: API key, bundle ID and app record

From here on you need an active Developer account.

6.1 App Store Connect API key (once per account, reusable for all apps)

  1. App Store Connect โ†’ Users and Access โ†’ Integrations โ†’ App Store Connect API โ†’ Team Keys. (First time: Request Access and accept the terms.)
  2. Click + โ†’ Name Codemagic โ†’ Access App Manager โ†’ Generate.
  3. Note the Issuer ID (above the list) and the Key ID.
  4. Download API Key โ†’ .p8 file. โš ๏ธ This works only once; store it in your password manager right away.

6.2 API key in Codemagic

  1. Codemagic โ†’ Settings (Personal Account) or Team settings โ†’ Integrations โ†’ Developer Portal โ†’ Connect / Manage keys.
  2. App Store Connect API key name: e.g. Codemagic โ† this name goes into codemagic.yaml.
  3. Fill in the Issuer ID, Key ID and the .p8 file โ†’ Save. Green dot = it works.

The Issuer ID and Key ID aren't secrets. The .p8 file is: never put it in git or a chat.

6.3 Registering the bundle ID

  1. Certificates, Identifiers & Profiles โ†’ Identifiers โ†’ +.
  2. App IDs โ†’ App โ†’ Continue.
  3. Description: app name (no special characters). Bundle ID: Explicit โ†’ your bundle ID.
  4. Capabilities: don't tick anything unless the app really uses it (push, Sign in with Apple, โ€ฆ). AdMob and ATT need no capability. You can always add them later.
  5. Continue โ†’ Register.

6.4 Creating the app record

  1. App Store Connect โ†’ Apps โ†’ + โ†’ New App.
  2. Fill in:
    • Platforms: iOS
    • Name: the app name (โš ๏ธ must be unique in the App Store)
    • Primary Language: e.g. English (U.S.)
    • Bundle ID: pick the registered ID from the list
    • SKU: e.g. myapp-ios
    • User Access: Full Access
  3. Create. Note the app's Apple ID (a number, shown under App Information and in the URL).
  4. โš ๏ธ Don't click Add for Review; that's for the App Store submission, not for TestFlight.

7. Code signing: certificate and provisioning profile

This is where we got stuck the longest. Codemagic does create a certificate, but not a provisioning profile. Follow exactly this order.

7.1 Distribution certificate (once per account, reusable)

  1. Codemagic โ†’ Settings / Team settings โ†’ codemagic.yaml settings โ†’ Code signing identities โ†’ iOS certificates.
  2. Generate certificate โ†’ API key Codemagic โ†’ type Apple Distribution.
  3. โš ๏ธ Store the displayed .p12 password in your password manager.

Apple only allows a few distribution certificates per account. Reuse this certificate for all your apps; don't create a new one per app.

7.2 Provisioning profile (one per app)

  1. Certificates, Identifiers & Profiles โ†’ Profiles โ†’ +.
  2. Under Distribution: App Store Connect (not iOS App Development, not Ad Hoc) โ†’ Continue.
  3. App ID: the app's bundle ID โ†’ Continue.
  4. Certificate: the Apple Distribution certificate from 7.1 โ†’ Continue.
  5. Name: e.g. My App App Store โ†’ Generate. No need to download it.

7.3 Fetching the profile in Codemagic

  1. Codemagic โ†’ Code signing identities โ†’ iOS provisioning profiles โ†’ Fetch profiles.
  2. Tick the profile โ†’ Download selected.
  3. Check the list: type App Store, the right bundle ID, and a check mark next to the certificate.

โš ๏ธ "No profiles to add" when fetching means the profile doesn't exist at Apple yet โ†’ do 7.2.

8. Codemagic: signed build to TestFlight

8.1 Adding the workflow to codemagic.yaml

  ios-release:
    name: iOS release to TestFlight
    instance_type: mac_mini_m2
    max_build_duration: 60
    working_directory: src/my_app
    integrations:
      app_store_connect: Codemagic          # name from step 6.2
    environment:
      flutter: 3.47.3
      xcode: latest
      cocoapods: default
      ios_signing:
        distribution_type: app_store
        bundle_identifier: com.yourdomain.myApp
    triggering:                             # see step 9
      events:
        - tag
      tag_patterns:
        - pattern: "v*"
          include: true
    cache:
      cache_paths:
        - $HOME/.pub-cache
        - $HOME/Library/Caches/CocoaPods
    scripts:
      - name: Apply code signing settings to the Xcode project
        script: xcode-project use-profiles
      - name: Get packages
        script: flutter pub get
      - name: Run tests
        script: flutter test
      - name: Build signed IPA
        script: flutter build ipa --release --export-options-plist=/Users/builder/export_options.plist
    artifacts:
      - build/ios/ipa/*.ipa
      - /tmp/xcodebuild_logs/*.log
    publishing:
      app_store_connect:
        auth: integration
        submit_to_testflight: false         # true = also to external testers (with Beta App Review)

8.2 Version and build number

  • They come from pubspec.yaml: version: 1.2.0+12 โ†’ version 1.2.0, build number 12.
  • โš ๏ธ Apple rejects an upload with a build number that already exists for that version. Increase the build number for every new upload, by hand or automatically with a release tool such as release-please.
  • A failed build that didn't upload anything can be restarted with the same build number.

8.3 Starting and following up

  1. Start new build โ†’ workflow iOS release to TestFlight โ†’ pick a tag (e.g. v1.2.0) or branch โ†’ Start. Duration: about 5โ€“10 minutes.
  2. After Publishing: App Store Connect โ†’ TestFlight โ†’ Builds. Status Processing โ†’ โณ 15โ€“30 minutes โ†’ Ready to Test. You'll get an email from Apple.

9. Building automatically on a release tag

The triggering section only works if Codemagic has a webhook in the GitHub repo.

  1. Codemagic โ†’ app โ†’ Webhooks โ†’ Update webhook (or Create webhook). Give Codemagic access to the repo if GitHub asks for it.
  2. Check in GitHub: Settings โ†’ Webhooks โ†’ URL https://api.codemagic.io/hooks/โ€ฆ with a green check mark on the last delivery.
  3. The next v* tag starts the build by itself. โš ๏ธ A tag that already existed before the webhook triggers nothing; build that one by hand (step 8.3).
  4. Using release-please? The tag is created through the GitHub API. Webhooks to Codemagic still fire (only GitHub Actions workflows aren't triggered by GITHUB_TOKEN). Check it on the first release.

10. TestFlight: adding testers and installing

10.1 Internal testers (max. 100, no review)

They must be users in your App Store Connect team.

  1. Invite someone: Users and Access โ†’ People โ†’ + โ†’ name, email, role Marketing or Customer Support (not Admin), Access to Apps โ†’ only this app.
  2. Once accepted: app โ†’ TestFlight โ†’ Internal Testing โ†’ + (group, e.g. Family) โ†’ add testers.
  3. Keep Automatic Distribution on: every new build then goes straight to the group.
  4. This can be done before the first build.

10.2 External testers (max. 10,000, with Beta App Review)

No access to your account needed; ideal for friends and family.

  1. First fill in TestFlight โ†’ Test Information: description, feedback email, contact details, privacy policy URL.
  2. The External Testing section only appears after the first build. Create a group then.
  3. Add testers by email or through a Public Link (with an optional limit).
  4. Add the build to the group โ†’ โณ Beta App Review (first build per version, usually < 1 day).
  5. Automatically from Codemagic: submit_to_testflight: true and beta_groups: [Name of the group] under publishing.app_store_connect.

10.3 Installing on the device

  1. Install TestFlight from the App Store.
  2. Sign in with the Apple ID that was invited as a tester.
  3. Open the invitation email or the public link โ†’ Install.
  4. A TestFlight build stays usable for 90 days.

10.4 Test checklist

  • [ ] Icon and splash screen
  • [ ] First launch (onboarding), and not again afterwards
  • [ ] Core functionality (same results as Android)
  • [ ] Settings are kept after a restart
  • [ ] GDPR consent, then the ATT popup
  • [ ] Ads (or none, as long as AdMob isn't linked)
  • [ ] Notch / home indicator / safe area
  • [ ] Light and dark theme
  • [ ] All links (mail, website, privacy policy)
  • [ ] iPad: layout on a wide screen, rotation, split view

11. Troubleshooting

Message / symptom Cause Fix
AdMob: you can only search for the iOS app in the App Store Wrong answer to "is the app listed on a store?" Back โ†’ choose No
App crashes right at launch GADApplicationIdentifier missing from Info.plist Add it (a test ID is fine for now)
No matching profiles found for bundle identifier "โ€ฆ" and distribution type "app_store" No App Store profile in Codemagic Steps 7.2 and 7.3
Fetch profiles: No profiles to add Profile doesn't exist at Apple yet Step 7.2
Profile found, but certificate missing Profile linked to a different certificate Edit the profile at Apple โ†’ choose the certificate from 7.1 โ†’ fetch again
Tag created, but no build in Codemagic No webhook Step 9; build the existing tag by hand
Upload rejected: build already exists Build number already used Increase the build number in pubspec.yaml
App Store Connect: icon rejected Transparency in the icon remove_alpha_ios: true and regenerate
DSA asks for a "Name Identification Document" Trader verification Business register extract, e.g. KBO in Belgium (step 2.4)
No ads in TestFlight AdMob app not yet linked to the App Store Normal; link it after publishing (step 4.1)

12. Quick checklist

Once per Apple account (skip for your next app):

  • [ ] Apple ID with two-factor authentication
  • [ ] Apple Developer Program ๐Ÿ’ถ โณ
  • [ ] Free Apps Agreement active
  • [ ] DSA trader status โณ
  • [ ] App Store Connect API key + .p8 stored safely
  • [ ] API key in Codemagic
  • [ ] Apple Distribution certificate in Codemagic

Per app:

  • [ ] Bundle ID, name, SKU, privacy policy URL chosen
  • [ ] project.pbxproj: bundle ID + iOS 13.0
  • [ ] Info.plist: names, encryption, orientation (+ AdMob keys)
  • [ ] Icons without transparency, splash screen
  • [ ] Privacy link in the app
  • [ ] (AdMob) iOS app, banner ID, GDPR and IDFA message
  • [ ] codemagic.yaml: ios-unsigned โ†’ green build
  • [ ] Bundle ID registered at Apple
  • [ ] App record in App Store Connect
  • [ ] App Store provisioning profile at Apple + fetched in Codemagic
  • [ ] codemagic.yaml: ios-release โ†’ green build
  • [ ] Webhook in Codemagic
  • [ ] TestFlight group + testers โ†’ install and test

Questions, or did you get stuck somewhere else? Let me know and I'll add it to this guide.