Build with remote Xcode
Limrun lets you compile iOS, watchOS, tvOS, and visionOS apps on a remote Mac. Your laptop or CI runner doesn't need to be one. After a successful build, you either run a supported app on a remote simulator or download a signed IPA to ship.
If your app builds with Bazel instead of xcodebuild, use remote build execution: no source sync, and Bazel keeps running on your side.
The flow:
- Provision an Xcode sandbox.
- Sync your source folder to it.
- Run
xcodebuildon the remote Mac. Logs stream back. - Install or ship. Successful builds auto-install on an attached simulator, or you upload the IPA to share.
SDK coverage by language
| Surface | TypeScript | Python | Go | CLI |
|---|---|---|---|---|
| Provision an Xcode instance | ✓ | ✓ | via REST | ✓ |
Sync source and run xcodebuild | ✓ | not in SDK | not in SDK | ✓ |
| Run one-shot project commands | ✓ | not in SDK | not in SDK | ✓ |
Python and Go users provision the instance over REST (POST /v1/xcode_instances) or with lim xcode create, and drive the build with lim xcode build. See the SDK capability matrix.
Provision the Xcode sandbox
The Xcode sandbox and the iOS simulator are separate instances with separate lifecycles. You create the sandbox, build on it, and attach a simulator only when it's time to run the app. This composability is the point: get an Xcode sandbox for the build, attach a simulator to test, drop the simulator and go back to just Xcode (or swap to Android) when you're not testing. You only pay for what's currently running.
Building before the simulator exists also matters practically: a simulator that sits idle during a long build can hit its inactivity timeout and disappear before the build finishes.
lim xcode create --reuse-if-exists --label session=demoimport Limrun from '@limrun/api';
const lim = new Limrun({ apiKey: process.env['LIM_API_KEY'] });
const xcodeInstance = await lim.xcodeInstances.create({
wait: true,
reuseIfExists: true,
metadata: { labels: { session: 'demo' } },
});
const xcode = await lim.xcodeInstances.createClient({ instance: xcodeInstance });On the SDK side, xcode is the handle you use in the sync and build sections below.
If you only have the URL and token from a different process (not the full instance object), open the client with the raw form:
const xcode = await lim.xcodeInstances.createClient({ apiUrl, token });The Go SDK doesn't yet have a standalone Xcode resource. From Go, POST to /v1/xcode_instances directly or call lim xcode create from your process.
When you already know you'll test on a simulator right after the build, the CLI can create both instances and attach them in one call: lim ios create --xcode (or lim xcode create --ios). The two instances are still separate; the flag just saves you the attach step described in Auto-install on the simulator.
Choose the Xcode version
A sandbox builds with its node's default Xcode unless you pick another installed major. Xcode 27 (beta) is available beside the default. Pick it once per workspace (your git repo, or a lim set-workspace-dir assignment), the way a version manager remembers a version:
lim xcode version list # versions the sandbox can build with; * marks the one in use, beta majors show their seed
lim xcode version set 27 # prefer 27 here; switches the remembered sandbox now
lim xcode build . # builds with 27, so do test, rbe and new sandboxes
lim xcode version # "27.0 (27A5252f)"
lim xcode build . --xcode-version 26 # one-off override; the preference is unchanged
lim xcode version unset # forget the preference; the sandbox goes back to the node defaultconst { bound, installed } = await xcode.getXcode();
if (bound.major !== '27') {
await xcode.setXcode('27');
}The sandbox itself has one Xcode selected at a time, and the workspace preference wins: when a build finds the sandbox on another major (a colleague switched it, or a fresh sandbox came up on the node default), it says so and switches before building. Every build prints the Xcode it runs with (Building with Xcode 27.0 (27A5252f)).
- Switching Xcode versions invalidates the build cache produced with the other version, so the next build starts cold.
- The switch is refused while a build, command, sync, or
lim xcode rbestack is running; stop them first (lim xcode rbe --stop).lim xcode version setkeeps the preference in that case and the next command retries the switch. - Asking for a major the node does not have fails with the available list, and
lim xcode version setdoes not record a preference for it. Only majors are selectable; the minor is whatever the fleet carries for that major. Beta majors show Apple's seed number inlim xcode version list(27.0 beta 6). - Simulators keep running the fleet's default runtime.
lim xcode testwith a non-default Xcode warns and proceeds: XCTest bundles built by Xcode 27 run against the simulator's Xcode 26 frameworks, which works for most suites but is not guaranteed. - Apple rejects App Store uploads built with a beta Xcode, so keep
--upload-to-appstoreon the default until the GM ships.
Sync your source
Syncing pushes your source to the sandbox. The first sync uploads everything; later ones only send what changed.
# One-shot sync followed by build (the most common path)
lim xcode build .
# Continuous sync (watch mode) without building
lim xcode sync . --watchawait xcode.sync('./my-app', {
watch: true, // re-sync on file changes
install: true, // install after each sync (attached simulator only)
additionalFiles: [
{ localPath: '/home/dev/.netrc', remotePath: '~/.netrc' },
],
});What gets synced
The SDK skips these paths regardless of .gitignore:
.git,.DS_Store, and the basis cache directory.- Build outputs:
build/,.build/,DerivedData/,Index.noindex/,ModuleCache.noindex/,.index-build/. - Dependency caches:
.swiftpm/,Pods/,Carthage/Build/. - Anything under
xcuserdata/or ending in.dSYM/.
On top of that, paths matched by your .gitignore files are skipped, root and nested ones alike, with git's usual precedence. One exception: .xcconfig files always sync because Xcode needs them on the remote. If a file is both tracked by git and ignored, the SDK warns you so you can fix the mismatch.
Symlinks sync as symlinks when their target is a relative path that stays inside the synced folder, so setups that link shared sources into an app directory build the same remotely as locally. Symlinks with absolute targets are skipped with a warning, and relative links that escape the synced folder fail the sync (pass --ignore to skip them instead).
Dependency caches (Pods/, .swiftpm/, Carthage/Build/) don't need to ship: the remote sandbox detects Podfile / Package.swift / Cartfile and resolves the dependencies on its side before the build runs. Keep them in your local .gitignore as usual.
Some project tools refuse to run outside a Git repository. The sync excludes .git, so pass --git-init to create a repository in the synced workspace before project generation, dependency resolution, and xcodebuild:
lim xcode build . --git-initTo exclude something the SDK doesn't already skip, pass a regex on the CLI or a predicate on the SDK:
lim xcode build . --ignore '^Secrets/' --ignore '\.local\.json$'await xcode.sync('./my-app', {
ignore: (relPath) =>
relPath.startsWith('Secrets/') || relPath.endsWith('.local.json'),
});The reverse exists too: --include force-syncs paths your .gitignore excludes. Use it when a local codegen step produces gitignored files the build needs.
# A generated local Swift package the build depends on
lim xcode build . --include '^ios/GeneratedKit/'
# Prebuilt Carthage frameworks you'd rather ship than rebuild
lim xcode build . --include '^Carthage/Build/'await xcode.sync('./my-app', {
include: (relPath) => relPath.startsWith('ios/GeneratedKit/'),
});Like --ignore, the pattern is a regular expression, not gitignore syntax. To reach files inside a directory that is ignored as a whole, the pattern must also match the directory path itself, as both examples do.
Additional files
If your build needs files that aren't in your repo (a .netrc for private package access, a CI-only Config.swift, a secrets file), pass them in alongside the sync.
await xcode.sync('./my-app', {
additionalFiles: [
{ localPath: '/home/dev/.netrc', remotePath: '~/.netrc' },
{ localPath: './ci/Config.swift', remotePath: 'Config.swift' },
],
});On the CLI, repeat --additional-file local=remote (see agents/cli). Paths on the remote side that start with ~/ expand to the sandbox's home directory.
Tune the delta sync
basisCacheDir is the local copy of the last sync that makes re-syncs fast. The default lives in your OS temp directory, which CI runners wipe between jobs; pin it to something persistent like /var/cache/lim in CI.
Generated Xcode projects
If XcodeGen generates your .xcodeproj and the project is gitignored (the recommended XcodeGen setup), there is nothing to configure: sync as usual, and the sandbox generates the project from your project.yml before the build.
lim xcode build .The spec can sit at the repo root or one directory down, like a monorepo's ios/. Both are found without flags. Try it on sample-xcodegen-app.
If your spec has a non-default name or location, pin it explicitly. The three flags mirror xcodegen generate --spec, --project, and --project-root, with paths relative to the synced folder root:
lim xcode build . --xcodegen-spec specs/app.yml --xcodegen-project ios--xcodegen-spec names the spec file (default: project.yml at the root). --xcodegen-project sets the directory the project is generated into (default: the spec's directory). --xcodegen-project-root sets the directory the spec's relative paths resolve against (default: the spec's directory). Passing any of these flags always regenerates the project, even when the sync supplied one.
What to know:
- Without the
--xcodegen-*flags, the sandbox generates only when you didn't supply the project. A committed.xcodeproj, or one you force-sync, always wins and is never modified. - The project regenerates on every build, so editing
project.ymllocally and rebuilding just works. No stale project lingers on a reused sandbox. - Codegen steps beyond
xcodegen generateitself (a Makefile that produces a local Swift package, config-derived sources) still run on your side before the sync. If their output is gitignored, force-sync it with--includeas shown above. - The sandbox runs a pinned XcodeGen version, currently 2.45.4, which satisfies any
minimumXcodeGenVersionup to it. If your project must be generated by one exact version, keep generating locally and force-sync the result with--include '\.xcodeproj'. - If your
project.ymlexpands environment variables (${VAR}), the build log warns you: expansion uses the sandbox's environment, not yours.
Run project commands
Use lim xcode run when the repository needs a macOS command such as a Make target or code generator. The command syncs the current directory, then runs after -- in the remote workspace:
lim xcode run -- make apiThe optional positional path changes the remote working directory. It is relative to the synced workspace and defaults to .:
lim xcode run apps/api -- make generateAdd --no-sync when the instance already has the source state the command needs:
lim xcode run --no-sync -- make apiThe sandbox includes mise for project tool versions. Its downloads and installed tools stay in the instance's sandbox home:
lim xcode run -- 'mise trust && mise install'
lim xcode run -- mise run generateCommands are one-shot. They stream stdout and stderr, return the remote exit code, and do not provide an interactive terminal.
TypeScript clients can use the same execution path after syncing:
await xcode.sync('./my-app', { watch: false });
const command = xcode.run('make api', {
cwd: '.',
timeoutSeconds: 1800,
});
command.stdout.on('data', (line) => process.stdout.write(line));
command.stderr.on('data', (line) => process.stderr.write(line));
const { exitCode } = await command;Run xcodebuild
Trigger a build. Logs stream back as it runs.
lim xcode build . --scheme MyApp --workspace MyApp.xcworkspaceconst build = xcode.xcodebuild({
workspace: 'MyApp.xcworkspace',
scheme: 'MyApp',
sdk: 'iphonesimulator', // also iphoneos, watchsimulator, watchos, appletvos, xros
});
build.command.on('data', (line) => process.stdout.write(line));
build.stdout.on('data', (line) => process.stdout.write(line));
build.stderr.on('data', (line) => process.stderr.write(line));
const { exitCode, status } = await build;
// status: 'SUCCEEDED' | 'FAILED' | 'CANCELLED'Build streams
While the build runs, the call exposes three event-emitter channels you can subscribe to:
| Channel | Carries |
|---|---|
command | The full command string the sandbox executed. One event, then closes. |
stdout | xcodebuild stdout as the build runs. |
stderr | xcodebuild stderr. |
Awaiting the call resolves to a result object with exitCode, status (SUCCEEDED, FAILED, or CANCELLED), and a signedDownloadUrl when you asked for an upload. Share that URL with a teammate or reviewer.
Build-completion webhooks and detached builds
Pass a webhook when a CI job, agent, or backend needs the terminal result without relying on the live log stream. Limrun calls the endpoint when the build reaches SUCCEEDED, FAILED, or CANCELLED. The CLI and TypeScript SDK accept the same URL and optional authentication headers:
lim xcode build . \
--webhook-url https://ci.example.com/hooks/limrun \
--webhook-header Authorization="Bearer $HOOK_SECRET"const build = xcode.xcodebuild(
{ workspace: 'MyApp.xcworkspace', scheme: 'MyApp' },
{
webhook: {
url: 'https://ci.example.com/hooks/limrun',
headers: { Authorization: 'Bearer <your-webhook-secret>' },
},
},
);The webhook is sent whether the caller keeps streaming or detaches. Limrun POSTs a JSON payload with the build result and debugging links:
{
"execId": "build-1700000000000000000",
"command": "xcodebuild",
"status": "SUCCEEDED",
"exitCode": 0,
"startedAt": "2026-07-29T12:00:00Z",
"finishedAt": "2026-07-29T12:03:25Z",
"buildDurationMs": 205000,
"instanceId": "xcode_usw1_...",
"consoleUrl": "https://console.limrun.com/builds/xcode_usw1_...",
"logsUrl": "https://...",
"assetId": "asset_01h455vb4pex5vsknk084sn02q",
"bundleIdentifier": "com.example.myapp",
"shortVersion": "1.2.3",
"buildVersion": "42",
"displayName": "MyApp",
"deeplink": "myapp",
"iconUrl": "https://..."
}logsUrl is a time-limited link to the persisted plain-text build log. Read that log for build diagnostics when status is FAILED. There is no error field: xcodebuild doesn't expose one deterministic diagnostic, so the log is the answer. The instance, console, log, timing, and exit-code fields are omitted when they are unavailable.
App identity in the payload
The payload also describes the app the build produced, read from the built bundle's Info.plist:
| Field | Carries |
|---|---|
bundleIdentifier | CFBundleIdentifier, e.g. com.example.myapp. |
shortVersion | CFBundleShortVersionString, e.g. 1.2.3. |
buildVersion | CFBundleVersion, e.g. 42. |
displayName | CFBundleDisplayName, falling back to CFBundleName. |
deeplink | The app's primary URL scheme from CFBundleURLTypes, e.g. myapp for myapp:// links. |
assetId | The Asset Storage asset the artifact was uploaded to. |
iconUrl | A time-limited link to the app icon PNG stored next to the asset. |
This is what lets a receiver tell builds apart without downloading and opening the artifact. In a monorepo that ships several apps, key your own bookkeeping on bundleIdentifier, and use assetId to install exactly the build that a given webhook reported. The Android equivalents are documented in Build with Gradle.
These fields are set on successful builds that produced one app bundle. A lim xcode test run builds for testing, producing an xctestrun tree rather than a single app, so it carries none. assetId and iconUrl additionally need --upload, which is what creates the asset; --signed-upload-url puts the artifact in your own bucket and mints no asset. Reading the bundle is best-effort: a bundle Limrun couldn't read leaves the fields out rather than failing a build that compiled and signed fine.
Delivery rules
- The URL must use HTTPS on a public DNS host. IP literals and private or cluster-internal targets are rejected when the build request is submitted.
- Repeat
--webhook-header NAME=VALUEto send multiple headers. At most 16 are accepted.Host,Content-Length,Transfer-Encoding, andConnectioncannot be overridden. - Any 2xx response counts as delivered. Other responses and connection failures are retried up to two more times.
- Delivery is best-effort. Exhausting retries does not change the build result.
- Cancelled builds send a callback too. A new build on the same sandbox cancels the active one.
Detached builds
Add --detach when the caller should return as soon as limbuild accepts the build instead of holding the SSE stream open:
lim xcode build . \
--detach \
--inactivity-timeout 3s \
--webhook-url https://ci.example.com/hooks/limrun \
--webhook-header Authorization="Bearer $HOOK_SECRET"Once the build is accepted, the CLI prints the instance ID, the console page where progress and logs appear, and the webhook target. Pass --json to receive one object with instanceId, execId, consoleUrl, and webhookUrl fields.
--detach requires --webhook-url. --inactivity-timeout skips the cached Xcode target and creates a fresh instance with the requested lifecycle, so it cannot be combined with --id. Active builds continually report activity; the short timeout begins expiring only after build and upload work stops. The inactivity controller checks about every 15 seconds, so a 3s timeout usually tears the instance down 3 to 18 seconds after its last activity rather than at exactly three seconds.
The TypeScript process can detach after the build request is accepted:
const execId = await build.detach();Build settings
Match what you'd pass to local xcodebuild:
| Field | Use |
|---|---|
workspace | Path to a .xcworkspace, e.g. MyApp.xcworkspace. |
project | Path to a .xcodeproj. Use this or workspace, not both. |
scheme | Required for multi-scheme apps. |
sdk | iphonesimulator (default), iphoneos, watchsimulator, watchos, appletvos, or xros. tvOS and visionOS are device-build only. |
To pass a custom build setting, repeat --build-setting KEY=VALUE on the CLI or set buildSettings on the SDK call. Any environment-style key (letters, digits, underscores) is accepted. Request values are applied after Limrun's managed settings and replace a managed value with the same key, so a project that needs it can override a default such as ONLY_ACTIVE_ARCH. APP_CONFIG_* values are treated as app configuration and redacted from build logs.
lim xcode build . --scheme MyApp \
--build-setting APP_CONFIG_API_URL=https://staging.example.com \
--build-setting SWIFT_ACTIVE_COMPILATION_CONDITIONS=STAGINGxcode.xcodebuild(
{ scheme: 'MyApp' },
{
buildSettings: {
APP_CONFIG_API_URL: 'https://staging.example.com',
SWIFT_ACTIVE_COMPILATION_CONDITIONS: 'STAGING',
},
},
);Device build defaults
Device builds (iphoneos, watchos, appletvos, xros) compile every target for its standard architectures and turn code coverage instrumentation off, matching what Xcode's archive action produces. An embedded watch app keeps the arm64_32 slice App Store Connect requires, and no binary carries coverage sections, so the IPA passes App Store validation. Projects that pin an unsupported architecture such as armv7 in ARCHS fail as they do in Xcode. Simulator builds compile only the host architecture for speed.
tvOS and visionOS
Pass the device SDK and scheme explicitly for tvOS and visionOS:
lim xcode build . --scheme TVApp --sdk appletvos
lim xcode build . --scheme VisionApp --sdk xrosThese builds use the same unsigned IPA, manual-signing, cloud-signing, and upload paths as iOS device builds. tvOS and visionOS simulator builds, simulator attachment, and XCTest execution are not supported yet.
Sign for real-device builds
To produce a signed IPA for distribution, select a device SDK, pass a P12 certificate, and pass a provisioning profile. Add an upload target so the IPA lands in Asset Storage with a downloadable URL. If you omit the SDK, signing defaults to iphoneos; pass watchos, appletvos, or xros explicitly for another platform.
lim xcode build . \
--sdk iphoneos \
--scheme MyApp \
--certificate-p12 ./signing/dist.p12 \
--certificate-password "$P12_PASSWORD" \
--provisioning-profile ./signing/MyApp.mobileprovision \
--upload my-app-pr-42.ipaimport fs from 'node:fs';
const build = xcode.xcodebuild(
{ scheme: 'MyApp', sdk: 'iphoneos' },
{
signing: {
certificateP12Base64: fs.readFileSync('./dist.p12').toString('base64'),
certificatePassword: process.env['P12_PASSWORD'],
provisioningProfilesBase64: [fs.readFileSync('./MyApp.mobileprovision').toString('base64')],
},
upload: { assetName: 'my-app-pr-42.ipa' },
},
);
const { exitCode, signedDownloadUrl } = await build;Limrun verifies the signed app with Apple's code-signing verifier before uploading it. A signature that Apple's tooling would reject fails the build with a clear error instead of producing a broken IPA, so a signed build that succeeds has already passed Apple's verification.
Cloud signing
Cloud signing produces a signed IPA without a p12 or provisioning profile. Xcode authenticates with an App Store Connect team API key, then Apple creates or reuses a cloud-managed certificate and the required profiles during export.
lim xcode build . \
--sdk iphoneos \
--configuration Release \
--scheme MyApp \
--signing-method release-testing \
--team-id VMBY3VYW4U \
--asc-key-id 2X9R4HXF34 \
--asc-issuer-id "$ASC_ISSUER_ID" \
--asc-key ./signing/AuthKey_2X9R4HXF34.p8 \
--upload my-app-pr-42.ipaChoose the method for the IPA you need:
| Method | Use |
|---|---|
debugging | Development-signed IPA for devices registered to the team. |
release-testing | Distribution-signed IPA for registered test devices. |
app-store-connect | Distribution-signed IPA for App Store Connect. |
Cloud signing works for device SDK builds: --sdk iphoneos, --sdk watchos, --sdk appletvos, and --sdk xros. It requires a team API key, its issuer ID, and a --team-id that matches the key's Apple Developer team. For release-testing and app-store-connect, the key must be an Admin key or have Access to cloud-managed distribution certificates enabled in App Store Connect.
The TypeScript SDK accepts the same configuration:
import fs from 'node:fs';
const build = xcode.xcodebuild(
{ scheme: 'MyApp', sdk: 'iphoneos', configuration: 'Release' },
{
cloudSigning: {
method: 'release-testing',
teamId: 'VMBY3VYW4U',
apiKeyId: '2X9R4HXF34',
apiIssuerId: process.env['ASC_ISSUER_ID'],
apiPrivateKeyBase64: fs.readFileSync('./AuthKey_2X9R4HXF34.p8').toString('base64'),
},
upload: { assetName: 'my-app-pr-42.ipa' },
},
);Preserving entitlements
Cloud signing archives your app unsigned, and Apple's export carries entitlements into the signed IPA only from an existing code signature, so capability entitlements from your project's .entitlements file (HealthKit, CloudKit, app groups, push) do not survive on their own. Pass them explicitly with --entitlements; the build server embeds them into the archive so the export preserves them. A bare path applies to the app; use <bundleId>=<path> for embedded bundles such as widgets or a watch app, repeating the flag per bundle.
lim xcode build . \
--sdk iphoneos \
--configuration Release \
--scheme MyApp \
--signing-method release-testing \
--team-id VMBY3VYW4U \
--asc-key-id 2X9R4HXF34 \
--asc-issuer-id "$ASC_ISSUER_ID" \
--asc-key ./signing/AuthKey_2X9R4HXF34.p8 \
--entitlements ./MyApp/MyApp.entitlements \
--entitlements com.example.myapp.widgets=./Widgets/Widgets.entitlements \
--upload my-app-pr-42.ipacloudSigning: {
method: 'release-testing',
teamId: 'VMBY3VYW4U',
apiKeyId: '2X9R4HXF34',
apiIssuerId: process.env['ASC_ISSUER_ID'],
apiPrivateKeyBase64: fs.readFileSync('./AuthKey_2X9R4HXF34.p8').toString('base64'),
// Keyed by bundle id; the empty key targets the app itself.
entitlements: {
'': fs.readFileSync('./MyApp/MyApp.entitlements').toString('base64'),
'com.example.myapp.widgets': fs.readFileSync('./Widgets/Widgets.entitlements').toString('base64'),
},
},Three rules apply. Every capability must be enabled on your App ID; the export auto-registers toggleable capabilities (HealthKit, push) there for you, but capabilities needing extra configuration (app group assignments, special-agreement entitlements) must be set up in the developer portal first or the export fails naming them. Values must be fully expanded: build-setting references like $(AppIdentifierPrefix) are rejected, so write the concrete team prefix instead. And keys the export manages itself (application-identifier, com.apple.developer.team-identifier, get-task-allow, beta-reports-active) must be omitted; the export sets them from the generated profile and distribution method.
Verify what landed in the IPA:
unzip -q my-app.ipa -d out && codesign -d --entitlements - out/Payload/MyApp.appApps with extensions or a watch app
App Store signing requires a distinct provisioning profile for every bundle the app embeds: the app itself plus each app extension (a WidgetKit widget, share sheet, intents extension) and watch app, all issued for the same distribution certificate. Repeat --provisioning-profile (or pass provisioningProfilesBase64 in the SDK) with one profile per bundle. Each profile is matched to its bundle by the application-identifier it carries, so the order doesn't matter, but every profile must name an explicit (non-wildcard) bundle id.
lim xcode build . \
--sdk iphoneos \
--scheme MyApp \
--certificate-p12 ./signing/dist.p12 \
--certificate-password "$P12_PASSWORD" \
--provisioning-profile ./signing/MyApp.mobileprovision \
--provisioning-profile ./signing/MyAppWidgets.mobileprovision \
--upload my-app-pr-42.ipaimport fs from 'node:fs';
const build = xcode.xcodebuild(
{ scheme: 'MyApp', sdk: 'iphoneos' },
{
signing: {
certificateP12Base64: fs.readFileSync('./dist.p12').toString('base64'),
certificatePassword: process.env['P12_PASSWORD'],
provisioningProfilesBase64: [
fs.readFileSync('./MyApp.mobileprovision').toString('base64'),
fs.readFileSync('./MyAppWidgets.mobileprovision').toString('base64'),
],
},
upload: { assetName: 'my-app-pr-42.ipa' },
},
);Before signing, Limrun checks that every embedded bundle in the built app has a matching profile and fails the build with the uncovered bundle id otherwise, so a missing profile surfaces immediately instead of as an App Store validation rejection after upload.
Export your p12 with its certificate chain included, so signing works regardless of which Apple WWDR intermediate issued your certificate. Keychain Access exports include the chain; with OpenSSL, pass the chain via -certfile:
openssl pkcs12 -export -inkey dist.key -in dist.pem -certfile wwdr.pem -out dist-chain.p12The build result includes a signed download URL when you've configured an upload. Store it in your PR comment or pipeline output. Anyone with the URL can download the IPA without a Limrun API key. The signature expires after 15 minutes; if you need it later, re-fetch a fresh URL with lim asset list --name <asset> --download-url.
Troubleshooting signing failures
| Build output | What it means | Fix |
|---|---|---|
Unknown issuer hash | The certificate's issuing CA isn't recognized and the p12 carries no CA chain. | Re-export the p12 with the certificate chain included (see the OpenSSL command above). |
code signature verification failed | The produced signature failed Apple's verifier. | Retry the build; contact support if it persists. |
MAC verification failed or other password errors | The certificate password doesn't match the p12. | Check the value you pass as the certificate password. |
signing preflight failed: no provisioning profile covers ... | An embedded bundle (extension, watch app) has no matching provisioning profile. | Pass one --provisioning-profile per bundle id named in the error. |
Cloud signing permission error | The API key cannot use cloud-managed distribution certificates. | Use an Admin key or enable Access to cloud-managed distribution certificates for the key. |
No Account for Team | --team-id does not match the API key's team. | Pass the Apple Developer team ID associated with the API key. |
Failed Registering Bundle Identifier | The bundle ID cannot be registered to this team, usually because another team owns it. | Use a bundle ID already owned by the team or one that is available to register. |
Upload to App Store Connect
A cloud-signed build can use the same API key for signing and upload. Use the app-store-connect method with --upload-to-appstore:
lim xcode build . \
--sdk iphoneos \
--configuration Release \
--scheme MyApp \
--signing-method app-store-connect \
--team-id VMBY3VYW4U \
--upload-to-appstore \
--asc-key-id 2X9R4HXF34 \
--asc-issuer-id "$ASC_ISSUER_ID" \
--asc-key ./signing/AuthKey_2X9R4HXF34.p8import fs from 'node:fs';
const build = xcode.xcodebuild(
{ scheme: 'MyApp', sdk: 'iphoneos', configuration: 'Release' },
{
cloudSigning: {
method: 'app-store-connect',
teamId: 'VMBY3VYW4U',
apiKeyId: '2X9R4HXF34',
apiIssuerId: process.env['ASC_ISSUER_ID'],
apiPrivateKeyBase64: fs.readFileSync('./AuthKey_2X9R4HXF34.p8').toString('base64'),
},
appstore: {
apiKeyId: '2X9R4HXF34',
apiIssuerId: process.env['ASC_ISSUER_ID'],
apiPrivateKeyBase64: fs.readFileSync('./AuthKey_2X9R4HXF34.p8').toString('base64'),
},
},
);
const { exitCode, appstore } = await build;
// appstore.state: 'uploading' | 'processing' | 'accepted' | 'failed' | 'unknown'.
// The field is absent when the instance predates the feature.App Store Connect upload supports iphoneos, appletvos, and xros. Replace the SDK and scheme in the examples above to deliver a tvOS or visionOS build. Existing watchOS upload behavior is unchanged.
The build log streams the upload progress. By default the build succeeds as soon as the upload commits: Apple's processing routinely takes many minutes, so the verdict is left to App Store Connect, and the upload ID is printed so you can check it there. To watch for the verdict instead, pass --asc-wait-timeout <seconds> (waitTimeoutSeconds in the SDK, up to 1800): a rejection within that window (a reused build number, a missing entitlement) fails the build with Apple's own error text, and expiry without a verdict is still a success with the build processing on Apple's side. Combine with --upload if you also want the IPA in Asset Storage.
Every upload needs a CFBundleVersion higher than the last one for that version. Pass --auto-build-number (autoIncrementBuildNumber: true in the SDK's appstore options) to set it to one more than the highest build number already in App Store Connect, or 1 for a new app. The lookup runs server-side with the same API key used for the upload. Manual signing requires Xcode-standard versioning (CFBundleVersion = $(CURRENT_PROJECT_VERSION)), including modern Xcode templates and Expo prebuilds. Cloud signing updates the unsigned archive before export.
Your API key travels with the build request over TLS. For cloud signing, limbuild writes it with owner-only permissions in the disposable sandbox directory for the export command, then deletes it. The sandbox directory is discarded with the instance.
One-time App Store Connect setup
Three App Store Connect settings make TestFlight delivery fully hands-off:
-
Create an App Store Connect team API key. In App Store Connect, go to Users and Access, open the Integrations tab, select App Store Connect API, and generate a Team Key. A Developer key is sufficient for upload with manual signing. Cloud distribution signing requires an Admin key or Access to cloud-managed distribution certificates enabled for the key. One team key serves all your apps. Collect the three values the build flags need:
--asc-key-id: the Key ID shown next to the new key.--asc-issuer-id: the Issuer ID shown at the top of the Integrations page (it belongs to the team, not to the key).--asc-key: the.p8file from the key's Download link. Download it right away and store it like a password: Apple keeps no copy, and the link disappears once you leave the page.
-
Answer the encryption question at build time, or every build stalls in App Store Connect with "Missing Compliance" until someone answers manually. If your app only uses exempt encryption (HTTPS and the like), declare it in your project:
// Expo: app.json { "expo": { "ios": { "config": { "usesNonExemptEncryption": false } } } }<!-- Native: Info.plist --> <key>ITSAppUsesNonExemptEncryption</key> <false/>If your app does use non-exempt encryption, answer the compliance questions in App Store Connect instead.
-
Enable automatic distribution on an internal beta group. In your app's TestFlight tab, create an internal group and choose automatic distribution (the option that gives the group access to all builds). Members then receive every new build with no per-build assignment. The setting is create-only, so if your existing group lacks it, create a new group with it enabled.
With these in place, one build command puts the app on your testers' devices as soon as Apple finishes processing.
Requirements and troubleshooting
TestFlight delivery requires a signed iphoneos, appletvos, or xros build plus manual or cloud signing and an existing app record in App Store Connect for your bundle ID; Apple's API cannot create app records. Each upload needs a CFBundleVersion higher than the last one for that version.
| Build output | What it means | Fix |
|---|---|---|
bundle version must be higher (or similar Apple text) | The CFBundleVersion was already used by an earlier upload. | Rebuild with --auto-build-number, or bump the build number manually. |
HTTP 401 from App Store Connect | The key ID, issuer ID, and .p8 don't belong together, or the key was revoked. | Re-check the trio in App Store Connect; team keys need the issuer ID, individual keys must omit it. |
HTTP 403 from App Store Connect | The key's role can't upload builds. | Use a key with the Developer role or higher. |
no App Store Connect app with bundle id ... | No app record exists for the bundle ID you built. | Create the app record in App Store Connect first (Apps, then the plus button). |
| Build stuck in "Missing Compliance" | The binary doesn't answer the export-compliance question. | Set ITSAppUsesNonExemptEncryption in Info.plist as described above. |
Upload the build artifact
Two options for distributing a build artifact.
Upload to Asset Storage. The artifact lives in Limrun's managed storage and can be referenced by name in later lim ios create --install-asset <name> calls. Asset Storage also backs the PR Previews flow.
lim xcode build . --scheme MyApp --upload my-build.ipaxcode.xcodebuild(
{ scheme: 'MyApp' },
{ upload: { assetName: 'my-build.ipa' } },
);The uploaded asset expires 14 days after the last upload by default: each build upload pushes the expiry out again, so actively rebuilt assets stay alive. Pass --upload-ttl with a Go duration (ttl in the SDK; e.g. 720h, 1d is invalid) to change the window. Assets uploaded with lim asset push are unaffected; they never expire unless given a --ttl.
Upload to your own bucket. Pass a pre-signed S3, GCS, or R2 URL and the artifact PUTs directly to it from the sandbox. The build bytes never travel through your client; the sandbox handles the upload itself, which avoids a round-trip through whatever machine called lim xcode build. Use this when you want the artifact in storage you already manage, or when the build is large enough that pulling it back through your CI runner would be wasteful.
lim xcode build . --scheme MyApp --signed-upload-url '<presigned-url>'xcode.xcodebuild(
{ scheme: 'MyApp' },
{ upload: { signedUploadUrl: '<presigned-url>' } },
);When the product name differs from the scheme name. The server locates the built .app on its own, including projects where the scheme and PRODUCT_NAME diverge (a scheme "MyApp Dev" building MyApp-dev.app). If an upload still fails with built artifact not found, pin the bundle filename explicitly (including the .app extension) — the server then takes that name from the build products verbatim and skips discovery:
lim xcode build . --scheme "MyApp Dev" --upload myapp-dev-build --artifact-name MyApp-dev.appxcode.xcodebuild(
{ scheme: 'MyApp Dev', artifactName: 'MyApp-dev.app' },
{ upload: { assetName: 'myapp-dev-build' } },
);Auto-install on the simulator
Attach a simulator to the Xcode sandbox at any time. The attach installs the latest successful build immediately, and every later successful build auto-installs and auto-launches on it. lim ios create --attach creates a fresh simulator and attaches it in one step; lim xcode attach-simulator attaches one that already exists:
# Create a simulator and attach it (installs the latest successful build)
lim ios create --attach
# Or attach an existing simulator
lim xcode attach-simulator <ios_instance_id>Or via the SDK:
await xcode.attachSimulator(iosInstance);
// or with raw URL/token:
await xcode.attachSimulator({ apiUrl: '...', token: '...' });
// or create a fresh simulator and attach it in one call:
const { simulator } = await xcode.attachNewSimulator();From Python or Go, attach with a direct HTTP call: POST {xcode status.apiUrl}/simulator with the Xcode instance's status.token as bearer and body {"apiUrl": "<simulator status.apiUrl>", "token": "<simulator status.token>"}.
Re-attach to a different simulator if you want to test the same build across multiple device models.
Full example: build a signed IPA on every PR
A complete Linux CI job that builds, signs, uploads, and posts a download link:
name: iOS build
on: { pull_request: { types: [opened, synchronize] } }
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm install --global lim
- name: Build
env:
LIM_API_KEY: ${{ secrets.LIM_API_KEY }}
P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
run: |
echo "${{ secrets.P12_BASE64 }}" | base64 -d > /tmp/dist.p12
echo "${{ secrets.PROFILE_BASE64 }}" | base64 -d > /tmp/MyApp.mobileprovision
lim xcode create --reuse-if-exists --label pr=${{ github.event.number }}
lim xcode build . \
--scheme MyApp \
--sdk iphoneos \
--certificate-p12 /tmp/dist.p12 \
--certificate-password "$P12_PASSWORD" \
--provisioning-profile /tmp/MyApp.mobileprovision \
--upload my-app-pr-${{ github.event.number }}.ipaWhat this workflow does, step by step:
- Checks out the PR's code on an Ubuntu runner.
- Installs the Limrun CLI globally with
npm. - Decodes the signing certificate and provisioning profile from base64-encoded GitHub secrets into the runner's filesystem.
- Provisions an Xcode sandbox labelled with the PR number, so re-pushes to the same PR reuse the same sandbox instead of spawning new ones.
- Runs a signed device build (
--sdk iphoneosplus signing flags) and uploads the IPA to Asset Storage under a name tied to the PR number. The CLI prints the signed download URL to the runner log.
No runs-on: macos-latest. No Apple-licensed CI runner. The build happens on Limrun's Mac fleet; the runner only needs to call lim.
Next steps
Run an iOS Simulator
Drive the simulator the build just installed onto: taps, screenshots, recordings, logs.
Automatic PR Previews
Wire lim xcode build --upload into a GitHub workflow that posts a preview link on every PR.
Asset Storage
Manage uploaded build artifacts, pre-install apps at boot, and browse the Limrun App Store.
SDK Reference
Auth, errors, the instance state machine, every resource × CRUD.
Was this guide helpful?