Llim.run

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:

  1. Provision an Xcode sandbox.
  2. Sync your source folder to it.
  3. Run xcodebuild on the remote Mac while the logs stream back.
  4. 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:

SurfaceCLITypeScriptPythonGo
Provision an Xcode sandbox✓✓✓via REST
Sync source and run xcodebuild✓✓not in SDKnot in SDK
Run one-shot project commands✓✓not in SDKnot 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.xcworkspace

lim 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=demo
import 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:

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 simulator
const 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 . --watch
await 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):

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-init

Add 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=~/.netrc
await 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
FlagWhat 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:

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 api
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;

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 generate

Add --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 generate

Commands 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:

ChannelCarries
commandThe full command string the sandbox ran. One event, then it closes.
stdoutxcodebuild stdout as the build runs.
stderrxcodebuild 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:

FieldTypeMeaning
workspacestringPath to a .xcworkspace, such as MyApp.xcworkspace.
projectstringPath to a .xcodeproj. Use this or workspace, not both.
schemestringRequired for multi-scheme apps.
sdkstringiphonesimulator (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=STAGING
xcode.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.zip
xcode.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.app
xcode.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.

FlagWhat 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|ReleaseXcode 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=VALUEPass an xcodebuild build setting, replacing a Limrun default with the same key. Repeatable.
--env KEY=VALUESet an environment variable for every remote build command. Repeatable.
--log-processor xcbeautify|noneFormat xcodebuild output with xcbeautify (default), or stream it unchanged.
--xcodegen-spec, --xcodegen-project, --xcodegen-project-rootPin XcodeGen inputs. See Generated Xcode projects.
--git-initRun 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=remoteSync 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.
--iosBuild 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=valueApp 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-pathsSave 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-prebuildRun 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-profileSign with your own certificate and profiles. See Sign and distribute.
--signing-method, --team-id, --entitlementsUse 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-keyUpload 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-numberSet the build number to one more than the highest in App Store Connect, or 1 for a new app. Requires --upload-to-appstore.
--detachReturn once the build is accepted. See Build logs and webhooks.
--webhook-url, --webhook-header, --webhook-labelPOST the terminal build result to your HTTPS endpoint. See Build logs and webhooks.

Troubleshooting

Symptom or messageCauseFix
Upload fails with built artifact not foundThe 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 symlinkA 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 ignoredThe 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 repositoryThe sync excludes .git.Pass --git-init.
The build log warns about ${VAR} in project.ymlXcodeGen 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 RUNNINGThe build has not finished; a snapshot is not a result.Follow it with lim xcode logs --follow; see Build logs and webhooks.

Next steps

layers

Xcode and tool versions

Pick the Xcode release and the Node, Ruby, CocoaPods, and other tool versions a build uses.

key-round

Sign and distribute

Produce signed device IPAs and upload them to App Store Connect and TestFlight.

webhook

Build logs and webhooks

Detach from a build, read its logs later, and receive the result on your endpoint.

workflow

GitHub Actions recipes

Build and sign on every pull request from a Linux runner.