Llim.run

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:

  1. Provision an Xcode sandbox.
  2. Sync your source folder to it.
  3. Run xcodebuild on the remote Mac. Logs stream back.
  4. Install or ship. Successful builds auto-install on a paired simulator, or you upload the IPA to share.

SDK coverage by language

SurfaceTypeScriptPythonGoCLI
Provision an Xcode instancevia REST
Sync source and run xcodebuildnot in SDKnot 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=demo
import 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.URL

On 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-build
const 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 . --watch
await 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:

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.

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.xcworkspace
const 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:

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

FieldUse
workspacePath to a .xcworkspace, e.g. MyApp.xcworkspace.
projectPath to a .xcodeproj. Use this or workspace, not both.
schemeRequired for multi-scheme apps.
sdkiphonesimulator (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.ipa
import 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.ipa
xcode.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:

.github/workflows/build.yml
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 }}.ipa

What this workflow does, step by step:

  1. Checks out the PR's code on an Ubuntu runner.
  2. Installs the Limrun CLI globally with npm.
  3. Decodes the signing certificate and provisioning profile from base64-encoded GitHub secrets into the runner's filesystem.
  4. 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.
  5. Runs a signed device build (--sdk iphoneos plus 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

smartphone

Run an iOS Simulator

Drive the simulator the build just installed onto: taps, screenshots, recordings, logs.

git-pull-request

Automatic PR Previews

Wire lim xcode build --upload into a GitHub workflow that posts a preview link on every PR.

package

Asset Storage

Manage uploaded build artifacts, pre-install apps at boot, and browse the Limrun App Store.

book-open

SDK Reference

Auth, errors, the instance state machine, every resource × CRUD.