Remote iOS development environment for distributed teams | Limrun
Skip to content
← All posts

Remote iOS development environment for distributed teams

Limrun9 min read

A remote iOS development environment moves the two macOS-only parts of the toolchain, xcodebuild and the iOS Simulator, onto machines your team reaches over the network. Everyone keeps whatever laptop they already have. Builds run on a cloud Mac, simulators boot in the cloud, and a live device is a link you paste in a channel.

For a distributed team, the appeal is less about avoiding Apple hardware and more about everyone working from the same environment. A build that fails on one engineer's machine and passes on another's stops being a mystery when both builds ran in the same sandbox. A bug a contractor in another time zone cannot reproduce becomes a URL the reporter sends them.

This page covers setup, how access gets shared, the cloud Mac versus cloud simulator decision, and the failure modes worth knowing before you commit.

What this replaces

Most distributed mobile teams are running one of three setups, and each has a predictable cost.

A Mac per engineer. Works well and gets expensive fast, especially for people who touch iOS occasionally. A backend engineer who needs to run the app twice a month is carrying a machine that sits idle the rest of the time. Refresh cycles hit every three or four years whether the hardware was busy or not.

A shared Mac mini someone rents or racks. Cheaper on paper. In practice it becomes a machine with one Xcode version, one set of certificates, a queue, and an owner who did not sign up for the job. Rented Macs bill by wall-clock time, so a machine that builds for twenty minutes a day still bills for the other twenty-three hours and forty minutes unless someone remembers to release it.

macOS runners in CI. Fine for the merge queue, painful for iteration. Runner availability and pricing depend on the provider, and a CI job does not give reviewers an interactive simulator in their browser.

Remote infrastructure splits those jobs apart. The build runs where a Mac is required, the simulator runs where a Mac is required, and everything else stays on the machine the person already owns.

Setup

You can work from macOS, Windows, Linux, or the Linux development environment on ChromeOS. Install a supported Node.js LTS release. The examples below use a POSIX shell; on Windows, use WSL or adapt the environment-variable commands for PowerShell.

Install the CLI

npm install --global lim
# or: pnpm add --global lim
# or: bun add --global lim

Authenticate

On a machine with a browser:

lim login

That opens the console, finishes authentication in the browser, and writes the key to ~/.lim/config.yaml.

For a headless environment such as a CI runner, devcontainer, or cloud agent sandbox, create a token at console.limrun.com/settings under API Keys and export it:

export LIM_API_KEY=lim_...

The CLI also reads a .env file in the working directory.

Run a build

From the project root:

lim xcode build .

The first run provisions a Mac sandbox, syncs the working directory, and runs xcodebuild remotely. Later syncs send changed bytes only. Logs stream back as they happen. If project discovery is ambiguous, pass --scheme <name> and either --workspace <path> or --project <path>.

Attach a simulator

lim ios create --attach --reuse-if-exists --label owner=priya --label project=checkout

The attach installs and launches the build immediately, and every later successful build reinstalls on its own. The output carries a Signed Stream URL with its token in the URL fragment.

Share it

Send that URL. The recipient opens a live device in a browser tab with no install, no account, and no Mac. Anyone with the link can control the instance. Share it only with people who should have access, and keep one person driving at a time.

Clean up

lim ios delete
lim xcode delete

Without an ID, each delete removes the last instance of that type the CLI created for you.

Sharing access across a team

Four things make this work for more than one person.

Label everything by owner and task. Reuse requires at least one label and matches the label set exactly within the region. A label set that is too broad can hand two engineers the same device. Scope per person, per pull request, or per session:

lim ios create --reuse-if-exists --label owner=priya --label pr=482
lim ios list --label-selector "owner=priya,pr=482"

Namespace your build assets. Asset names are global to the organization, so teams sharing one account should prefix them:

lim asset push ./build/MyApp.app.tar.gz -n checkout/my-app-v1.2.3.tar.gz

Then query by prefix rather than scrolling a shared list. Build products uploaded through a build default to a 14 day expiry, extended each time you rebuild. Direct uploads do not expire unless you set --ttl.

Keep one key out of everyone's shell. An organization API key can create, list, and delete every instance you own. For CI and for agent sandboxes, create the instance outside and hand over only that instance's own credentials:

lim ios create --json > instance.json

export LIM_IOS_INSTANCE_URL=$(jq -r .status.apiUrl instance.json)
export LIM_IOS_INSTANCE_TOKEN=$(jq -r .status.token instance.json)

lim ios element-tree    # works with no LIM_API_KEY

Run the device commands in a separate environment with no organization API key or saved login. Instance credentials authorize device operations, but not organization management calls.

Set timeouts to terminate abandoned devices. People close laptops. Use --inactivity-timeout for idle cleanup, --hard-timeout as a ceiling, and --rm for one-shot runs.

Cloud Mac or cloud simulator

These are different products and teams routinely buy the wrong one. The short version: a cloud Mac is for compiling, a cloud simulator is for looking at the result. Most teams need both, and the question is really who holds which.

You need a Mac sandbox any time source has to become an app bundle. That covers compiling with xcodebuild, running XCTest, signing an archive, and uploading to App Store Connect. No amount of simulator access substitutes for it.

You need a simulator to run the build, tap through it, record it, or show it to someone. A simulator on its own is enough when the build already exists, which is more often than people expect: QA verifying a nightly, a designer checking a layout, a support engineer reproducing a customer report, an agent driving a regression pass.

Renting a whole Mac makes sense when you need persistent state on that machine, a specific Xcode version pinned for months, or tooling Limrun does not run. It is a machine, and you administer it like one.

Renting a whole Mac stops making sense when the workload is bursty. Ten engineers who each build a few times a day do not need ten idle machines, and a rented Mac charges for the hours nobody touched it. The structural difference with Limrun is that the Xcode service bills idle and build time rather than a reserved machine, so occasional builders cost roughly what they use. Check the console for current pricing details.

Team needWhat to useNotes
Solo engineer iterating on a featureXcode sandbox plus one attached simulator--attach reinstalls on every successful build
QA verifying a build they did not compileSimulator with --install-assetNo Mac sandbox needed
Design or product reviewing a flowSigned Stream URL, or a recorded walkthroughBrowser only, no install
Pull request reviewBuild, --upload, preview link in the PRReviewer taps the actual branch
CI on every mergeubuntu-latest runner plus lim xcode build and lim xcode testNo macOS runner in the pipeline
Agent-driven regressionPer-instance credentials, labels per sessionAgent holds one device, not the org key
A device inside your own product<RemoteControl /> from @limrun/uiBackend keeps the API key

Working across time zones

The part distributed teams feel most is review latency. Three shapes cover almost all of it.

A live link for anything synchronous. The Signed Stream URL streams the running device into a browser, and the person on the other end can drive it themselves.

A recording for anything asynchronous:

lim ios record start
# drive the flow
lim ios record stop -o /tmp/checkout-regression.mp4

The --quality flag on record start accepts integers 5 through 10 and defaults to 5. Pass --presigned-url instead of -o to push the file straight to your own bucket, which is the usual choice when it is going to be attached to a ticket.

A preview link on the pull request. Build, upload, and post the URL:

lim xcode build . --scheme MyApp --upload my-app-pr-482.zip

Reviewers open https://console.limrun.com/preview?asset=<name>&platform=ios and tap through the branch instead of reading a screenshot in a comment thread. Full walkthrough in PR previews.

If review should happen inside your own tool rather than a Limrun URL, <RemoteControl /> from @limrun/ui renders a live iOS or Android device in a web app. Your backend holds the API key and the browser only ever sees a per-instance URL and token. See Embed a simulator.

Troubleshooting

reuse-if-exists keeps creating new instances. Reuse returns an instance whose labels match exactly, looked up in the region handling the create. Reuse can miss when labels are absent, differ by one value, or a retry reaches a different region. Scope labels per person or per job, and add --jurisdiction us|eu|as when the instance has to stay in one geography.

Someone else is driving my device. Two people using the same stream control the same device. Scope one instance per person with a label.

The stream link stopped working. The signed stream URL works only while the instance runs. If an inactivity or hard timeout terminated it, create a new instance and share its new URL.

Interaction feels slightly off. Input travels over the network, so a browser stream is not identical to a local simulator. For latency-sensitive work like gesture tuning, test on hardware.

A generated file is missing from the build. Sync skips anything in .gitignore. Force-include it:

lim xcode build . --include '^ios/GeneratedKit/'

The build cannot find the .xcodeproj. If the project is gitignored and a project.yml exists, the sandbox runs XcodeGen automatically. A spec at the repo root or one directory down is found without flags. Pin a non-default one with --xcodegen-spec and --xcodegen-project.

An instance disappeared mid-session. Check --inactivity-timeout. An open idle tunnel does not count as activity.

Glossary

  • Simulator: a macOS process running a build of your app compiled for the simulator SDK. Fast and scriptable, not real hardware.
  • Emulator: the Android equivalent, running a virtualized Android system image.
  • Mac sandbox: a remote Mac that runs xcodebuild, signs archives, and uploads to App Store Connect.
  • Remote build: running xcodebuild on a machine other than the one holding your source, with source synced and logs streamed back.
  • Live preview: a browser-streamed view of a running instance that the viewer can also control.
  • Label: a key-value tag on an instance that drives reuse, lookup, and cleanup.

Next