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.
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 | ✓ |
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 project's root .gitignore are skipped, with 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.
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.
To 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'),
});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
maxPatchBytes caps how big a single file's diff can grow before the SDK gives up on a diff and uploads the file in full. Default 4 MiB.
- Lower it on a slow connection, where a big diff takes longer to compute and upload than just sending the whole file.
- Raise it when one file routinely changes by tens of megabytes.
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.
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 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. |
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'],
provisioningProfileBase64: fs.readFileSync('./MyApp.mobileprovision').toString('base64'),
},
upload: { assetName: 'my-app-pr-42.ipa' },
},
);
const { exitCode, signedDownloadUrl } = await build;The 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.
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 @limrun/cli
- 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?