Build with remote Xcode
lim xcode build syncs your project to an Xcode sandbox, a Mac in the cloud, runs xcodebuild there, and streams the logs back. Your laptop or CI runner does not need to be a Mac. A successful build installs on an attached simulator, uploads to Asset Storage, or becomes a signed IPA.
If your app builds with Bazel, use Build with Bazel instead: Bazel keeps running on your side and there is no source sync.
The build loop has four steps:
- Provision an Xcode sandbox.
- Sync your source folder to it.
- Run
xcodebuildon the remote Mac while the logs stream back. - Install or ship. Successful builds install on an attached simulator, or you upload the artifact.
Pick your interface
The CLI covers the whole build surface. The SDKs cover it as follows:
| Surface | CLI | TypeScript | Python | Go |
|---|---|---|---|---|
| Provision an Xcode sandbox | ✓ | ✓ | ✓ | 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 provisions with xcode_instances.create (see Provision the sandbox yourself). Go provisions over REST (POST /v1/xcode_instances) or with lim xcode create. Then drive the build with lim xcode build. The SDK capability matrix lists every surface by language.
Build your project
Run the build from the project directory:
lim xcode build .The first run creates an Xcode sandbox, or reuses the one remembered for your workspace. It syncs the directory, runs xcodebuild remotely, and streams the logs. For workspaces or multi-scheme projects, name the scheme and the workspace or project:
lim xcode build . --scheme MyApp --workspace MyApp.xcworkspacelim xcode build is the only build command you need. It syncs before every build, so do not run a separate sync step or local xcodebuild.
Provision the sandbox yourself
lim xcode build creates a sandbox on demand. Create one explicitly when you want labels, a specific lifecycle, or the instance before the first build:
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 });from limrun_api import Limrun
lim = Limrun() # reads LIM_API_KEY
xcode_instance = lim.xcode_instances.create(
wait=True,
reuse_if_exists=True,
metadata={"labels": {"session": "demo"}},
)
print(xcode_instance.metadata.id, xcode_instance.status.state)The sandbox and any simulator you attach are separate instances with their own lifecycles; see Concepts.
TypeScript only: createClient opens a client for the sandbox. The xcode handle it returns is what the sync and build examples on this page use. The Python SDK has no sync or build client, so build on a Python-created sandbox with lim xcode build --id <sandbox-id>.
If another process created the instance and you only have its URL and token, open the client from those:
const xcode = await lim.xcodeInstances.createClient({ apiUrl, token });When you know you will test on a simulator right after the build, create both instances and attach them in one call with lim ios create --xcode or lim xcode create --ios. They stay separate instances; the flag saves the attach step described in Install on a simulator.
Reuse builds across instances
Create the sandbox with --snapshot-key myapp-main to restore and later save its build workspace, and run lim xcode delete --wait-snapshot after building to publish it for the next instance. See Disk snapshots for the full workflow, branch fallbacks, and SDK usage.
Install on a simulator
Attach a simulator to the Xcode sandbox at any time. The attach installs the latest successful build immediately, and every later successful build installs and launches on it. lim ios create --attach creates a 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 <instance-id>await xcode.attachSimulator(iosInstance);
// or with a raw URL and token:
await xcode.attachSimulator({ apiUrl: '...', token: '...' });
// or create a fresh simulator and attach it in one call:
const { simulator } = await xcode.attachNewSimulator();Python and Go have no attach call; attach with an HTTP call instead: POST {xcode status.apiUrl}/simulator with the Xcode sandbox's status.token as the bearer token and the body {"apiUrl": "<simulator status.apiUrl>", "token": "<simulator status.token>"}.
Attach a different simulator to test the same build on another device model. Run a simulator covers everything you can do with the running app.
Move off the embedded Xcode sandbox
Older clients created the Xcode sandbox inside the iOS instance with spec.sandbox.xcode.enabled and read its URL from status.sandbox.xcode.url. The TypeScript SDK (from 0.54.0) and the lim CLI (from 0.35.0) no longer offer that. The API still accepts it, so older SDK and CLI versions keep working.
After upgrading, you see one of these:
- TypeScript:
'sandbox' does not exist in type 'Spec'oniosInstances.create, orProperty 'sandbox' does not exist on type 'Status'where the code readsstatus.sandbox.xcode.url. - CLI:
Expected an Xcode instance (sandbox_...), got ios_...when anios_ID is passed to alim xcodecommand. A paired target the previous CLI remembered is ignored, so the nextlim xcode buildcreates a new Xcode sandbox.
Create the Xcode sandbox on its own and attach the simulator to it:
lim ios create --xcode # new simulator and Xcode sandbox, attached
lim xcode create --attach --simulator-id <ios-instance-id> # new Xcode sandbox for an existing simulatorconst xcodeInstance = await lim.xcodeInstances.create({ wait: true });
const xcode = await lim.xcodeInstances.createClient({ instance: xcodeInstance });
const { simulator } = await xcode.attachNewSimulator(); // or xcode.attachSimulator(existingIosInstance)Pass the Xcode sandbox's sandbox_ ID to lim xcode commands and the simulator's ios_ ID to lim ios commands. The two instances have separate lifecycles: delete each one, or let each time out on its own.
Sync your source
Every lim xcode build syncs first. The first sync uploads everything; later syncs send only what changed. For a continuous sync without building, for example while a tool watches the remote workspace, use watch mode:
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' },
],
});TypeScript only: sync keeps watching the folder by default (watch defaults to true) and installs after each sync (install also defaults to true). Pass watch: false for a one-shot sync.
What gets synced
The sync always skips .git, .DS_Store, and the basis cache directory. It also skips these paths at the project root (nested copies are left to your nested .gitignore files):
- Build outputs:
build/,.build/,DerivedData/,Index.noindex/,ModuleCache.noindex/,.index-build/. - Dependency caches:
.swiftpm/,Pods/,Carthage/Build/.
Anything under xcuserdata/ or ending in .dSYM/ is skipped at any depth.
Paths matched by your .gitignore files are skipped too, root and nested alike, with git's usual precedence. .xcconfig and .env files are synced even when .gitignore matches them, because the remote build needs them; exclude them explicitly with --ignore if you must. The force-include cannot reach a file whose parent directory is ignored, because the sync prunes that directory; use --include with a pattern that also matches the directory. If a file is both tracked by git and ignored, the sync warns you so you can fix the mismatch.
Symlinks sync as symlinks when their target is a relative path 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. Relative links that escape the synced folder fail the sync; pass --ignore to skip them instead.
Dependency caches do not need to ship. The sandbox detects Podfile, Package.swift, or Cartfile and resolves dependencies on its side before the build. Keep them in your local .gitignore as usual.
Exclude or force-include paths
To exclude something the sync does not already skip, pass a regular expression with --ignore. Repeat the flag for several patterns. --include works the other way: it force-syncs paths that .gitignore, a built-in rule, or --ignore would exclude. When a path matches both flags, --include wins. Use it when a local codegen step produces gitignored files the build needs:
# Skip secrets and local-only config
lim xcode build . --ignore '^Secrets/' --ignore '\.local\.json$'
# A generated local Swift package the build depends on
lim xcode build . --include '^ios/GeneratedKit/'
# Prebuilt Carthage frameworks you would rather ship than rebuild
lim xcode build . --include '^Carthage/Build/'await xcode.sync('./my-app', {
watch: false,
ignore: (relPath) =>
relPath.startsWith('Secrets/') || relPath.endsWith('.local.json'),
include: (relPath) => relPath.startsWith('ios/GeneratedKit/'),
});Both flags take regular expressions, not gitignore syntax; TypeScript takes predicates instead of patterns. An excluded parent directory prunes its whole subtree, so to reach files inside it the pattern must also match the directory path itself, as the --include examples do (^ios/, not GeneratedKit/). The basis cache is never included.
Tools that need a Git repository
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-initAdd files from outside the repository
When the build needs files that are not in your repository, such as a .netrc for private packages, a CI-only Config.swift, or a secrets file, sync them alongside the source. Each entry maps a local path to a remote path:
# --additional-file local=remote; repeat the flag for each file
lim xcode build . --additional-file ~/.netrc=~/.netrcawait xcode.sync('./my-app', {
watch: false,
additionalFiles: [
{ localPath: '/home/dev/.netrc', remotePath: '~/.netrc' },
{ localPath: './ci/Config.swift', remotePath: 'Config.swift' },
],
});Remote paths that start with ~/ expand to the sandbox's home directory.
Keep the delta cache on CI
The basis cache is the local copy of the last sync that lets later syncs send only changes. It defaults to your OS temp directory, which CI runners wipe between jobs. Pin it to a persistent path with --basis-cache-dir (basisCacheDir in the SDK), for example /var/cache/lim.
Generated Xcode projects
If XcodeGen generates your .xcodeproj and the project is gitignored, which is the recommended XcodeGen setup, there is nothing to configure. The sandbox generates the project from project.yml before each build:
lim xcode build .The spec can sit at the repository 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 another name or location, pin it. 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| Flag | What it does |
|---|---|
--xcodegen-spec <path> | The spec file. Default: project.yml at the root. |
--xcodegen-project <dir> | The directory the project is generated into. Default: the spec's directory. |
--xcodegen-project-root <dir> | 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. Without them:
- The sandbox generates only when you did not 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 works. No stale project lingers on a reused sandbox. - Codegen steps beyond
xcodegen generate, such as a Makefile that produces a local Swift package or config-derived sources, still run on your side before the sync. If their output is gitignored, force-sync it with--include. - The sandbox uses the selected XcodeGen tool. Select its compatibility line with
lim xcode use xcodegen@2, or pin an exact release with a sandbox mise override; see Xcode and tool versions. - If
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 a code generator. It syncs the current directory, then runs the command in the remote workspace:
lim xcode run -- make apiawait 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;The CLI runs the command after --. In TypeScript, call sync before run, as the example does.
The optional positional path sets the remote working directory, relative to the synced workspace. It defaults to .:
lim xcode run apps/api -- make generateAdd --no-sync when the sandbox already has the source state the command needs, and --env KEY=VALUE to set variables:
lim xcode run --no-sync -- make api
lim xcode run --env API_ENV=development -- npm run generateCommands are one-shot. They stream stdout and stderr, return the remote exit code, and do not provide an interactive terminal. --timeout (timeoutSeconds in TypeScript) sets the server-side limit in seconds: 3600 by default, 21600 at most.
Run xcodebuild from the SDK
The TypeScript SDK's xcodebuild starts the build on a synced sandbox and streams its output:
const 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'The call exposes three event-emitter channels while the build runs:
| Channel | Carries |
|---|---|
command | The full command string the sandbox ran. One event, then it closes. |
stdout | xcodebuild stdout as the build runs. |
stderr | xcodebuild stderr. |
Awaiting the call resolves to a result with exitCode, status (SUCCEEDED, FAILED, or CANCELLED), and a signedDownloadUrl when you asked for an upload.
The xcodebuild settings match what you would pass locally:
| Field | Type | Meaning |
|---|---|---|
workspace | string | Path to a .xcworkspace, such as MyApp.xcworkspace. |
project | string | Path to a .xcodeproj. Use this or workspace, not both. |
scheme | string | Required for multi-scheme apps. |
sdk | string | iphonesimulator (default), iphoneos, watchsimulator, watchos, appletvos, or xros. tvOS and visionOS are device builds only. |
To return before the build finishes, or to receive the result on a webhook, see Build logs and webhooks.
Pass build settings
Pass custom build settings with a repeated --build-setting KEY=VALUE, or with buildSettings in TypeScript. Keys must be uppercase letters, digits, and underscores (^[A-Z0-9_]+$), and the server reserves a few managed keys. A build takes at most 32 settings, 4096 bytes per value, and 64 KiB in total. Your values apply after Limrun's managed settings and replace a managed value with the same key, so a project that needs to can override a default such as ONLY_ACTIVE_ARCH. APP_CONFIG_* values are treated as app configuration and redacted from build logs.
This build passes a staging API URL and a compilation condition:
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',
},
},
);Build settings reach xcodebuild only. --env KEY=VALUE sets an environment variable for every remote build command instead: dependency installs, expo prebuild, pod install, and xcodebuild. Server-managed variables such as PATH cannot be overridden.
Upload the build artifact
Upload the artifact to Asset Storage when you want to install it on other simulators, open it as a PR preview, or keep it as a CI artifact:
lim xcode build . --scheme MyApp --upload my-build.zipxcode.xcodebuild(
{ scheme: 'MyApp' },
{ upload: { assetName: 'my-build.zip' } },
);Later creates can install it by name with lim ios create --install-asset my-build.zip. The uploaded asset expires 14 days after the last upload by default: each build upload pushes the expiry out again, so assets you keep rebuilding stay alive. Pass --upload-ttl with a Go duration (ttl in the SDK) such as 720h to change the window; 1d is not a valid Go duration. Assets uploaded with lim asset push never expire unless you give them a --ttl. Asset Storage covers the rest of the asset model.
To put the artifact in storage you already manage, pass a presigned S3, GCS, or R2 URL. The sandbox PUTs the artifact to it directly, so the bytes never travel through the machine that called lim xcode build. This also saves pulling a large build back through your CI runner:
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
The server finds the built .app on its own, including projects where the scheme and PRODUCT_NAME diverge, such as a scheme "MyApp Dev" that builds MyApp-dev.app. If an upload still fails with built artifact not found, name the bundle file, 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' } },
);Build flag reference
These are the lim xcode build flags. The flags every command shares (--api-key, --json, --quiet, --[no-]create, --[no-]daemon) are in the CLI reference. Run lim xcode build --help for the full text of each.
| Flag | What it does |
|---|---|
--scheme <name> | Xcode scheme to build. |
--workspace <path> | .xcworkspace to pass to xcodebuild. |
--project <path> | .xcodeproj to pass to xcodebuild, as an alternative to --workspace. |
--configuration Debug|Release | Xcode build configuration. |
--sdk <sdk> | iphonesimulator, iphoneos, watchsimulator, watchos, appletvos, or xros. tvOS and visionOS are device builds only. |
--xcode-version <major|major.minor> | Xcode for this one command. See Xcode and tool versions. |
--build-setting KEY=VALUE | Pass an xcodebuild build setting, replacing a Limrun default with the same key. Repeatable. |
--env KEY=VALUE | Set an environment variable for every remote build command. Repeatable. |
--log-processor xcbeautify|none | Format xcodebuild output with xcbeautify (default), or stream it unchanged. |
--xcodegen-spec, --xcodegen-project, --xcodegen-project-root | Pin XcodeGen inputs. See Generated Xcode projects. |
--git-init | Run git init in the synced workspace before the build. |
--ignore <regex> | Skip matching paths during sync. Repeatable. |
--include <regex> | Force-sync matching paths that would otherwise be excluded. Repeatable. |
--additional-file local=remote | Sync an extra file, such as ~/.netrc, on every build. Repeatable. |
--basis-cache-dir <dir> | Directory for the local delta sync cache. |
--id <xcode-instance-id> | Build on this sandbox instead of the most recent one for your workspace. |
--ios | Build on a sandbox with an attached iOS simulator, creating and attaching one if needed. |
--inactivity-timeout <duration> | Create a fresh sandbox for this build and delete it shortly after its last activity. Cannot be combined with --id. |
--upload <asset-name> | Save the build artifact to Asset Storage under this name. |
--upload-ttl <duration> | Expiry of the uploaded asset as a Go duration. Defaults to 336h (14 days), counted from each upload. Requires --upload. |
--upload-option key=value | App metadata to record on the uploaded asset (displayName, bundleIdentifier, shortVersion, buildVersion, deeplink). Values read from the built bundle take precedence. Repeatable. |
--artifact-name <App.app> | Filename of the built .app to upload, skipping artifact discovery. |
--signed-upload-url <url> | Upload the artifact to your own presigned URL. Defaults to LIM_SIGNED_UPLOAD_URL. |
--snapshot-key, --snapshot-restore-keys, --snapshot-paths | Save and restore the sandbox workspace as a disk snapshot. The older --cache-* names still work. |
--expo-app-dir <path> | Path to the Expo app inside a monorepo or ambiguous React Native workspace. |
--expo-force-prebuild | Run expo prebuild on the server even when the workspace already has an ios/ directory. |
--dev-server-url <url> | URL a Debug React Native or Expo build opens on the attached simulator after install. |
--certificate-p12, --certificate-password, --provisioning-profile | Sign with your own certificate and profiles. See Sign and distribute. |
--signing-method, --team-id, --entitlements | Use Apple cloud signing. Also requires --asc-key-id, --asc-issuer-id, and --asc-key. See Sign and distribute. |
--upload-to-appstore, --asc-key-id, --asc-issuer-id, --asc-key | Upload the signed IPA to App Store Connect. |
--asc-wait-timeout <seconds> | Watch App Store Connect's processing verdict for up to this long (default 0, max 1800). |
--auto-build-number | Set the build number to one more than the highest in App Store Connect, or 1 for a new app. Requires --upload-to-appstore. |
--detach | Return once the build is accepted. See Build logs and webhooks. |
--webhook-url, --webhook-header, --webhook-label | POST the terminal build result to your HTTPS endpoint. See Build logs and webhooks. |
Troubleshooting
| Symptom or message | Cause | Fix |
|---|---|---|
Upload fails with built artifact not found | The scheme name and the built product name differ and discovery missed the bundle. | Pass --artifact-name with the .app filename; see When the product name differs from the scheme. |
| The sync fails on a symlink | A relative symlink points outside the synced folder. | Pass --ignore with a pattern for the link to skip it. |
| The sync warns that a file is tracked and ignored | The file is tracked by git but also matched by .gitignore. | Remove the file from git or from the ignore rule so the two agree. |
| A generated, gitignored file is missing on the sandbox | .gitignore excludes it, or its parent directory is pruned. | Force-sync it with --include, using a pattern that also matches the parent directory. |
| A project tool refuses to run outside a Git repository | The sync excludes .git. | Pass --git-init. |
The build log warns about ${VAR} in project.yml | XcodeGen expands variables with the sandbox's environment. | Do not rely on client-only variables in the spec; hard-code the value or generate the project locally and force-sync it. |
Expected an Xcode instance (sandbox_...), got ios_... | An ios_ simulator ID was passed to a lim xcode command, a leftover of the embedded Xcode sandbox. | Pass the Xcode sandbox's sandbox_ ID; see Move off the embedded Xcode sandbox. |
lim xcode logs shows RUNNING | The build has not finished; a snapshot is not a result. | Follow it with lim xcode logs --follow; see Build logs and webhooks. |
Next steps
Xcode and tool versions
Pick the Xcode release and the Node, Ruby, CocoaPods, and other tool versions a build uses.
Sign and distribute
Produce signed device IPAs and upload them to App Store Connect and TestFlight.
Build logs and webhooks
Detach from a build, read its logs later, and receive the result on your endpoint.
GitHub Actions recipes
Build and sign on every pull request from a Linux runner.
Was this guide helpful?