# Build with remote Xcode
URL: /docs/ios/build-with-xcode
LLM index: /llms.txt
Description: Sync your source, run `xcodebuild` in the cloud, stream the logs back, and install the result on a remote simulator. Or ship a signed IPA.

# 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

| 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](/docs/reference#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.

<CodeGroup labels={["CLI","TypeScript","Python","Go"]}>
```bash
lim ios create --xcode --reuse-if-exists --label session=demo
```

```ts
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
# 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
// 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
```

</CodeGroup>

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](#auto-install-on-the-simulator)).

<CodeGroup labels={["CLI","TypeScript","Python"]}>
```bash
lim xcode create --reuse-if-exists --label session=ci-build
```

```ts
const xcodeInstance = await lim.xcodeInstances.create({
  wait: true,
  reuseIfExists: true,
  metadata: { labels: { session: 'ci-build' } },
});

const xcode = await lim.xcodeInstances.createClient({ instance: xcodeInstance });
```

```python
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.
```

</CodeGroup>

If you only have the URL and token from a different process (not the full instance object), open the client with the raw form:

```ts
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.

<CodeGroup labels={["CLI","TypeScript"]}>
```bash
# One-shot sync followed by build (the most common path)
lim xcode build .

# Continuous sync (watch mode) without building
lim xcode sync . --watch
```

```ts
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' },
  ],
});
```

</CodeGroup>

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

```bash
lim xcode build . --ignore '^Secrets/' --ignore '\.local\.json$'
```

```ts
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.

```ts
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](/docs/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.

<CodeGroup labels={["CLI","TypeScript"]}>
```bash
lim xcode build . --scheme MyApp --workspace MyApp.xcworkspace
```

```ts
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'
```

</CodeGroup>

### 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.

<CodeGroup labels={["CLI","TypeScript"]}>
```bash
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
```

```ts
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;
```

</CodeGroup>

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](/docs/ios/pr-previews) flow.

```bash
lim xcode build . --scheme MyApp --upload my-build.ipa
```

```ts
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.

```bash
lim xcode build . --scheme MyApp --signed-upload-url '<presigned-url>'
```

```ts
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:

```bash
lim xcode attach-simulator <ios_instance_id>
```

Or via the SDK:

```ts
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:

```yaml title=".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

<Cards>
  <Card title="Run an iOS Simulator" icon="smartphone" href="/docs/ios/run-simulator">
    Drive the simulator the build just installed onto: taps, screenshots, recordings, logs.
  </Card>
  <Card title="Automatic PR Previews" icon="git-pull-request" href="/docs/ios/pr-previews">
    Wire `lim xcode build --upload` into a GitHub workflow that posts a preview link on every PR.
  </Card>
  <Card title="Asset Storage" icon="package" href="/docs/platform/asset-storage">
    Manage uploaded build artifacts, pre-install apps at boot, and browse the Limrun App Store.
  </Card>
  <Card title="SDK Reference" icon="book-open" href="/docs/reference">
    Auth, errors, the instance state machine, every resource × CRUD.
  </Card>
</Cards>