Build with remote Xcode
Limrun lets you compile iOS apps on a remote Mac. Your laptop or CI runner doesn't need to be one. After a successful build, you either run the 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 a paired 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 with their SDK and drive the build with lim xcode build. See the SDK capability matrix.
Two ways to use the Xcode sandbox
The right shape depends on whether you also need a simulator right now.
Limrun lets your agent compose its environment step-by-step: get an Xcode sandbox for the build, attach a simulator only when it's time to test, drop the simulator and go back to just Xcode (or swap to Android) when you're not. You only pay for what's currently running. Future services like Blender and Unity follow the same pattern.
Paired with an iOS simulator (most common)
When the agent is going to test on a simulator right after the build, create one iOS instance with the Xcode sandbox attached. Pass --xcode to the CLI, or set the sandbox config on the SDK call as shown below. After a successful build, the app auto-installs and auto-launches on the simulator.
lim ios create --xcode --reuse-if-exists --label session=demoimport Limrun from '@limrun/api';
const lim = new Limrun({ apiKey: process.env['LIM_API_KEY'] });
const instance = await lim.iosInstances.create({
wait: true,
reuseIfExists: true,
metadata: { labels: { session: 'demo' } },
spec: {
sandbox: { xcode: { enabled: true } },
},
});
const xcodeUrl = instance.status.sandbox!.xcode!.url!;
const xcode = await lim.xcodeInstances.createClient({
apiUrl: xcodeUrl,
token: instance.status.token,
});# Python provisions the paired instance; the build step itself is not in the
# Python SDK today. Drive the build with `lim xcode build` from your CI step.
instance = client.ios_instances.create(
wait=True,
reuse_if_exists=True,
metadata={"labels": {"session": "demo"}},
spec={"sandbox": {"xcode": {"enabled": True}}},
)
xcode_url = instance.status.sandbox.xcode.url// Go provisions the paired instance; the build step itself is not in the
// Go SDK today. Drive the build with `lim xcode build` from your CI step.
instance, err := lim.IosInstances.New(ctx, limrun.IosInstanceNewParams{
Wait: param.NewOpt(true),
ReuseIfExists: param.NewOpt(true),
Metadata: limrun.IosInstanceNewParamsMetadata{
Labels: map[string]string{"session": "demo"},
},
Spec: limrun.IosInstanceNewParamsSpec{
Sandbox: limrun.IosInstanceNewParamsSpecSandbox{
Xcode: limrun.IosInstanceNewParamsSpecSandboxXcode{
Enabled: param.NewOpt(true),
},
},
},
})
xcodeURL := instance.Status.Sandbox.Xcode.URLOn the SDK side, xcode is the handle you use in the sync and build sections below. On the CLI, the --xcode flag is what carries the sandbox attachment through.
Standalone, without a simulator
For pure CI builds, real-device IPA distribution, or build steps where the agent doesn't need a simulator yet, create a top-level Xcode instance. Attach a simulator later only when it's time to run the app (see Auto-install on the simulator).
lim xcode create --reuse-if-exists --label session=ci-buildconst xcodeInstance = await lim.xcodeInstances.create({
wait: true,
reuseIfExists: true,
metadata: { labels: { session: 'ci-build' } },
});
const xcode = await lim.xcodeInstances.createClient({ instance: xcodeInstance });xcode_instance = client.xcode_instances.create(
wait=True,
reuse_if_exists=True,
metadata={"labels": {"session": "ci-build"}},
)
# Drive the build with `lim xcode build` against this instance's ID.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, either provision an iOS instance with the Xcode sandbox attached (the paired pattern above), POST to /v1/xcode_instances directly, or call lim xcode create from your process.
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 (paired iOS 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', // or 'iphoneos', 'watchsimulator', 'watchos'
});
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://..."
}logsUrl is a time-limited link to the persisted plain-text build log. Read that log for build diagnostics when status is FAILED. The instance, console, log, timing, and exit-code fields are omitted when they are unavailable.
Webhook delivery follows these 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.
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 for the simulator), iphoneos for device builds, watchsimulator / watchos for watchOS. |
To pass a custom build setting, repeat --build-setting KEY=VALUE on the CLI or set buildSettings on the SDK call. Only allowlisted keys are accepted: any APP_CONFIG_* key, plus SWIFT_ACTIVE_COMPILATION_CONDITIONS. Other keys are rejected. 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',
},
},
);Settings outside the allowlist, like CURRENT_PROJECT_VERSION, can't be passed here. Bump those in the Xcode project itself.
Sign for real-device builds
To produce a signed IPA for distribution, switch the target SDK to iphoneos, pass a P12 certificate, and pass a provisioning profile. Add an upload target so the IPA lands in Asset Storage with a downloadable URL.
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 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' },
},
);Apps 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.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 device build (--sdk iphoneos 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' } },
);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>' } },
);Auto-install on the simulator
When the Xcode sandbox is paired with a simulator, every successful build auto-installs and auto-launches on it. No extra step.
For standalone Xcode instances, attach a simulator at any time:
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: '...' });After the attach call returns, the next successful build auto-installs on that simulator. 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?