Ship a Capacitor iOS app to the App Store via Codemagic
This skill captures a full, battle-tested path from a Capacitor app to a TestFlight build, using Codemagic as the cloud-Mac build service. It exists because this journey has many non-obvious failure points, and each one costs a full build cycle to discover. Follow the phases in order; each ends in a checkpoint so you know it worked before moving on.
Why Codemagic: Apple's build+sign toolchain only runs on macOS. If the developer is on Windows/Linux (or just doesn't want to manage a Mac), a cloud CI with macOS runners is the standard answer. Codemagic has first-class Capacitor support and a free tier. Everything below is Codemagic-specific.
Prerequisites — verify these before starting
Check each one explicitly; a missing prerequisite wastes later phases.
- Active paid Apple Developer Program membership ($99/yr). An expired or organization-member-only account will block enrollment steps. (If the user is stuck behind an old employer's org account, the fix is Apple Developer Support detaching their Apple ID — that's out of scope here.)
- The app is a git repo on a remote Codemagic can read (GitHub/GitLab/ Bitbucket). Note the remote URL.
- The backend is publicly hosted, not
localhost. A shipped app has no local server. Grep the client for how it picks its API/WebSocket host (e.g.location.host, a hardcodedSERVER_HOST,wss://) and confirm the native build points at a public HTTPS/WSS URL, notlocalhost:PORT. This is the single most common "works in dev, broken in the store" trap. - Capacitor iOS platform already added (
ios/exists locally withios/App/App.xcodeproj). If not,npx cap add iosfirst.
Phase 1 — App Store Connect: bundle ID + app record
These live on two different Apple sites, which trips people up:
- developer.apple.com (Certificates, Identifiers & Profiles) — register the bundle ID here.
- appstoreconnect.apple.com — create the app record here.
Order matters: register the bundle ID FIRST, or it won't appear in the App Store Connect "New App" dropdown.
- Find the bundle ID in
capacitor.config.ts(appId, e.g.us.emojimatch.app). This is not a URL you need to own — it's a reverse-DNS identifier, only needs to be globally unique in Apple's system. - Register it: developer.apple.com → Account → Certificates, Identifiers &
Profiles → Identifiers → Register an App ID → App IDs → App →
Explicit bundle ID = the
appId, a description, no capabilities needed → Register. (Apple's portal is slow and deep-links error — navigate from the Identifiers list, not a direct/addURL.) - Create the app record: appstoreconnect.apple.com → Apps → + → New
App → iOS, pick the now-listed bundle ID, primary language, a globally
unique app name, and any SKU (internal-only). User Access: Full.
- App name collisions are common. Plain names ("Emoji Match") are usually taken. If rejected with "already being used," try a distinctive multi-word or coined name. The store name is editable until first release and does NOT need to match the bundle ID or in-app name.
- Note the numeric App Store Connect app ID from the URL
(
/apps/<APP_ID>/...) and the Team ID (shown top-right in the Developer portal) — you'll want both for the build config.
Checkpoint: the app record exists at "1.0 Prepare for Submission".
Driving Apple's web UI with browser automation? The New App form is a React SPA where
form_inputsilently no-ops — use real clicks+typing for text fields and focus→arrow-keys→Enter for the native<select>dropdowns. Its portal pages are also slow; wait and re-read rather than hammering.
Phase 2 — Prepare the repo for CI
Capacitor's defaults are hostile to CI in three specific ways. Fix all three or the build fails on the cloud Mac.
- Commit the
ios/project. Capacitor's root.gitignorelistsios/, so the native project isn't on the remote and Codemagic has nothing to build. Removeios/from the root.gitignore(keepnode_modules/). The nestedios/.gitignorestill excludes build artifacts (App/build,App/App/public, generatedcapacitor.config.json/config.xml) — CI regenerates those viacap sync. Result is ~20 tracked files including the app icon,Info.plist, and the SPM package. - Add a shared Xcode scheme. Capacitor projects have no shared scheme (the
Appscheme lives in gitignoredxcuserdata), but headlessxcodebuild archiverequires one. Createios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme— seereferences/app-scheme-template.xmland substitute the native target'sBlueprintIdentifier(find it: grepPBXNativeTargetinios/App/App.xcodeproj/project.pbxproj). - Enable
apple-genericversioning. So CI can bump the build number withagvtool. Capacitor setsCURRENT_PROJECT_VERSION/MARKETING_VERSIONbut notVERSIONING_SYSTEM. AddVERSIONING_SYSTEM = "apple-generic";to BOTH the Debug and ReleasebuildSettingsinproject.pbxproj.
Do this on a branch (e.g. codemagic-ios-build) and push it — Codemagic
builds from the remote, and a branch keeps main clean while you iterate.
Checkpoint: git ls-files ios/ | wc -l is ~20 and includes
xcshareddata/xcschemes/App.xcscheme; the branch is pushed.
Phase 3 — Write codemagic.yaml
Copy assets/codemagic.yaml to the repo root and fill in the placeholders
(<BUNDLE_ID>, <APP_STORE_APP_ID>, <ASC_INTEGRATION_NAME>). It already
encodes every fix below, so read the comments rather than trimming them.
Key facts baked into the template (all learned the hard way):
node: 22— Capacitor 8's CLI requires Node ≥22 (node: 20fails with[fatal] The Capacitor CLI requires NodeJS >=22.0.0). Match the app's Capacitor major version.- Modern Capacitor uses Swift Package Manager, not CocoaPods (tell:
ios/App/CapApp-SPMexists and there's noPodfile). So there's nopod installstep; justnpx cap sync iosthenxcodebuild -resolvePackageDependencies. - Signing is done explicitly (see Phase 4), NOT via the automatic
environment.ios_signingblock — that block only fetches existing profiles and fails on a fresh account.
Phase 4 — Codemagic account + signing (the hard part)
This is where most build cycles get spent. The root problem on a fresh account: there is no distribution certificate and no provisioning profile yet, and creating a certificate requires a private key that you must supply.
User-side steps (Claude cannot create accounts or handle the secret in-UI):
- App Store Connect API key. App Store Connect → Users and Access →
Integrations → App Store Connect API → Team Keys → generate with role
App Manager (App Manager was confirmed sufficient to create certs/
profiles once a private key is supplied; escalate to Admin only if you
hit a 403 on cert/profile creation). Download the
.p8(one-time), note the Issuer ID and Key ID. - Sign up at codemagic.io with GitHub, authorize the repo, add it as an application.
- Add the API key to Codemagic (Team settings → Integrations → App Store
Connect). Whatever name you give it must equal
<ASC_INTEGRATION_NAME>in the YAML — easiest to just match the YAML to the name they chose.
The certificate private key (the crux):
app-store-connect fetch-signing-files --create can create the provisioning
profile but cannot create the distribution certificate without a private key
(error: Cannot save Signing Certificates without certificate private key). So:
- Generate an RSA key once:
openssl genrsa -out cert_key.pem 2048. It's a secret — generate it OUTSIDE the repo (or delete after), never commit it.- If generating it for the user, do NOT print its contents into the
transcript; write it to a file and pipe to their clipboard
(
cat cert_key.pem | clip.exeon Windows/Git Bash).
- If generating it for the user, do NOT print its contents into the
transcript; write it to a file and pipe to their clipboard
(
- In Codemagic, add a secure environment variable
CERTIFICATE_PRIVATE_KEY= the full PEM contents, in a variable group namedios_signing(the YAML referencesgroups: [ios_signing]).
The YAML's signing step then runs:
keychain initialize → fetch-signing-files "$BUNDLE_ID" --type IOS_APP_STORE --certificate-key @env:CERTIFICATE_PRIVATE_KEY --create → keychain add-certificates → xcode-project use-profiles. It creates the cert+profile
once and reuses them on every later build.
Checkpoint: the "Set up code signing" step logs Created certificate… and
Created provisioning profile… and applies a profile to target App.
Phase 5 — First build, then TestFlight
Expect to iterate: treat build #1 as a smoke test. Each failure has a known
cause — consult references/troubleshooting.md, which maps every error we've
seen to its fix, before guessing.
- Run the workflow on the
codemagic-ios-buildbranch. - TestFlight — internal vs external:
- Internal (the developer + up to 100 people on the account): no beta
review, no test info. The build is usable the moment it finishes
processing. This is the fast path to get it on a device. Set
submit_to_testflight: falsein the YAML — the build still uploads and is auto-available to internal testers. - External (public beta): requires Beta App Info (feedback email) + Beta
App Review contact info + Apple's ~1-day review. Only then set
submit_to_testflight: trueand add externalbeta_groups.
- Internal (the developer + up to 100 people on the account): no beta
review, no test info. The build is usable the moment it finishes
processing. This is the fast path to get it on a device. Set
- Install via the TestFlight app on a real device and verify the real, hosted-backend gameplay/flow works — this is the end-to-end proof that the Phase-1 backend check (public URL, not localhost) actually held.
Once green and verified, merge codemagic-ios-build → main; subsequent pushes
produce TestFlight builds automatically.
Reference files
references/troubleshooting.md— every error → cause → fix, in the order you'll hit them. Read this the moment a build fails.references/app-scheme-template.xml— the sharedApp.xcschemeto drop in (Phase 2), with the one value to substitute.assets/codemagic.yaml— the full working workflow to copy into the repo.