The constraint is real and it is not negotiable: Xcode, xcodebuild, and the iOS SDK run on macOS and nowhere else. There is no Linux port, no official Windows toolchain, and the Apple licence permits macOS only on Apple hardware — which is why the virtual-machine workarounds you will find on forums are both fragile and a licence violation.
But “you need a Mac” and “you need to own a Mac” are different claims, and only the first one is true. You need macOS compute for a few minutes per build, and you can rent that by the minute. If your codebase is Objective-C, this works better than most people expect, because a mature Objective-C project is exactly the kind of thing that builds cleanly from the command line.
This guide covers the two practical routes — Codemagic and a git-hosted macOS runner — and the code-signing problem that stops people long before the build does.
The build is easy. Signing is the hard part.
Compiling an Objective-C app on a cloud macOS machine is close to trivial. One xcodebuild invocation and you have an archive. The part that consumes an afternoon is convincing Apple the archive came from you, and it involves three artefacts that all have to exist before your first successful build:
- A distribution certificate, proving your identity as a developer.
- A provisioning profile, tying that certificate to a specific app ID and entitlements.
- An App Store Connect API key, so the upload step can authenticate without a human typing a two-factor code.
The chicken-and-egg problem is the certificate. Every tutorial tells you to generate a certificate signing request in Keychain Access — which is a Mac application, which is the thing you do not have. This is where most people conclude it is impossible and go buy a Mac Mini.
Generating the CSR without a Mac
A certificate signing request is not a proprietary Apple format. It is standard PKCS#10, and OpenSSL has produced them for decades. On Linux, or WSL, or anything with openssl installed:
# 1. Private key — guard this file, it is your identity
openssl genrsa -out ios_distribution.key 2048
# 2. The CSR you upload to developer.apple.com
openssl req -new -key ios_distribution.key \
-out CertificateSigningRequest.certSigningRequest \
-subj "/emailAddress=you@example.com, CN=Your Name, C=US"
Upload that CSR in the Certificates section of your Apple developer account, choose Apple Distribution, and download the resulting .cer. Apple hands back a DER-encoded certificate; the build machine needs a PKCS#12 bundle containing both the certificate and the private key you just generated:
# 3. DER to PEM
openssl x509 -inform DER -in distribution.cer -out distribution.pem
# 4. Combine certificate + key into the .p12 the runner imports
openssl pkcs12 -export \
-inkey ios_distribution.key \
-in distribution.pem \
-out ios_distribution.p12
# 5. Base64 so it can live in a CI secret
base64 -w0 ios_distribution.p12 > p12.b64
That is the whole trick, and it is the step every “no Mac required” article skips. Create the provisioning profile in the same web console, download the .mobileprovision, and base64 it the same way.
For the upload step, generate an App Store Connect API key under Users and Access rather than using your Apple ID. You get a .p8 file, an issuer ID, and a key ID. Password authentication with two-factor enabled does not work unattended, and app-specific passwords are a worse version of the same idea.
Before you build the pipeline
If you are starting a genuinely new app rather than maintaining an existing Objective-C codebase, read the cross-platform route first. Expo’s cloud build solves this problem by never making it your problem. The pipeline below is worth building when you already have Objective-C worth keeping — not as a way to avoid choosing a stack.
Option 1: Codemagic
Codemagic is CI built specifically for mobile, and its main advantage is that it treats code signing as a first-class feature rather than something you script. You upload the .p12 and profile once, reference them by name, and its CLI tools wire them into the build.
A codemagic.yaml for an Objective-C project using CocoaPods:
workflows:
ios-release:
name: iOS Release
instance_type: mac_mini_m2
environment:
xcode: 15.4
cocoapods: default
groups:
- appstore_credentials
ios_signing:
distribution_type: app_store
bundle_identifier: com.yourcompany.yourapp
scripts:
- name: Install pods
script: pod install
- name: Set up signing
script: xcode-project use-profiles
- name: Build IPA
script: |
xcode-project build-ipa \
--workspace MyApp.xcworkspace \
--scheme MyApp
artifacts:
- build/ios/ipa/*.ipa
publishing:
app_store_connect:
auth: integration
submit_to_testflight: true
xcode-project use-profiles is doing the work that would otherwise be twenty lines of security commands: it reads the profiles attached to the build, patches the project’s signing settings to match, and leaves xcodebuild with everything it needs.
The trade is the usual one for managed platforms. You move faster on day one and you are inside someone else’s abstraction on day one hundred — when a build breaks in a way the UI does not explain, you are debugging a system you did not build.
Option 2: A git-hosted macOS runner
GitHub Actions provides macOS runners with Xcode preinstalled, and GitLab offers the equivalent. Nothing here is mobile-specific, which means you do the signing plumbing yourself — roughly thirty lines, written once.
name: iOS Release
on:
push:
tags: ['v*']
jobs:
build:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Pin the Xcode version
run: sudo xcode-select -s /Applications/Xcode_15.4.app
- name: Cache CocoaPods
uses: actions/cache@v4
with:
path: Pods
key: pods-${{ hashFiles('Podfile.lock') }}
- name: Install pods
run: pod install --repo-update
- name: Import signing certificate
env:
P12_BASE64: ${{ secrets.IOS_P12_BASE64 }}
P12_PASSWORD: ${{ secrets.IOS_P12_PASSWORD }}
KC_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
echo "$P12_BASE64" | base64 --decode > cert.p12
security create-keychain -p "$KC_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KC_PASSWORD" build.keychain
security import cert.p12 -k build.keychain \
-P "$P12_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: \
-s -k "$KC_PASSWORD" build.keychain
- name: Install provisioning profile
env:
PROFILE_BASE64: ${{ secrets.IOS_PROFILE_BASE64 }}
run: |
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
echo "$PROFILE_BASE64" | base64 --decode \
> ~/Library/MobileDevice/Provisioning\ Profiles/app.mobileprovision
- name: Archive
run: |
xcodebuild -workspace MyApp.xcworkspace \
-scheme MyApp -configuration Release \
-archivePath build/MyApp.xcarchive \
CODE_SIGN_STYLE=Manual archive
- name: Export IPA
run: |
xcodebuild -exportArchive \
-archivePath build/MyApp.xcarchive \
-exportOptionsPlist ExportOptions.plist \
-exportPath build
Two lines in there are the ones people lose hours to. set-key-partition-list is required on modern macOS or codesign will block waiting for a keychain prompt that no one is there to answer — the build simply hangs until it times out. And CODE_SIGN_STYLE=Manual overrides the automatic signing that an Xcode project will otherwise try to perform, which fails on a machine that has never seen your Apple ID.
Watch the cost model on private repositories: macOS runner minutes bill at a significant multiple of Linux minutes on most hosted plans. For a project that tags a release weekly this is negligible. For one that builds on every push to every branch, it is a real line item, and restricting the workflow to tags is worth doing on day one.
The Objective-C specifics that bite
Most CI guidance assumes a recent Swift project. An older Objective-C codebase differs in ways that produce confusing failures:
- Build the workspace, not the project. If you use CocoaPods,
-workspace MyApp.xcworkspaceis mandatory. Passing-project MyApp.xcodeprojproduces a wall of missing-header errors, because the Pods targets are not in the project file. - Pin the Xcode version explicitly. Runner images update, and a newer Xcode can turn warnings your codebase has carried for years into errors.
xcode-selectagainst a specific version, and change it deliberately. - Check the deployment target before it checks you. Apple periodically raises the minimum SDK it will accept for submissions, and an old
IPHONEOS_DEPLOYMENT_TARGETis a rejection at upload time — after a green build. - Expect dropped architectures. Modern Xcode has no 32-bit iOS support and bitcode is gone. A project carrying
armv7in its architecture list or bitcode settings needs those removed, not worked around. - Commit
Podfile.lock. Without it,pod install --repo-updateresolves fresh versions on the runner and you get a build that differs from every local one — the worst class of CI bug, because it is invisible in the diff.
What you actually give up
Being honest about this matters more than the YAML, because the cost is not in the build — it is in the loop.
You lose the interactive iOS Simulator on your own machine. You can run unit and UI tests headlessly on the runner, and you should, but you cannot casually poke at a layout, resize a view, and watch what happens. For an Objective-C app with hand-built UIKit layouts, that is the single biggest loss, because UIKit constraint problems are precisely the kind of bug you find by looking rather than by reasoning.
Your device testing loop becomes TestFlight. Push a tag, wait for the build, wait for processing, install on a phone. That is minutes per iteration instead of seconds, and it changes how you work: you batch changes, you write more tests, and you think harder before shipping because the feedback is expensive. That is not entirely a bad discipline, but pretending it is free would be dishonest.
And you cannot debug interactively against a device from your Linux machine. When something crashes only on real hardware, you are reading crash logs and adding logging, not setting breakpoints.
You can get a simulator anyway
That last section is the standard list of losses, and it is worth qualifying, because “no Mac means no Simulator” is repeated far more often than it is true. The Simulator is part of Xcode, and Xcode is already sitting on the machine you are renting. What you lack is a screen attached to it — and that is a solvable problem, in three different ways depending on what you actually need.
Screenshots from CI, free
The cheapest option uses build minutes you are already spending. simctl drives the Simulator from the command line, so a job can boot a device, install the app, and capture what it looks like:
xcrun simctl boot "iPhone 15 Pro"
xcrun simctl install booted build/MyApp.app
xcrun simctl launch booted com.yourcompany.yourapp
xcrun simctl io booted screenshot iphone15pro.png
Loop that over several device names and you get a contact sheet of every screen size on every build, published as an artifact. It is not interactive, but it catches the class of bug you were most worried about — a constraint that only breaks on the small phone or the big one — and it catches it automatically rather than when a user reports it. fastlane snapshot automates the same idea across devices and languages if you would rather not write it yourself.
One detail makes this much easier than the rest of the pipeline: a Simulator build needs no code signing at all. Building for the simulator destination produces an unsigned .app, so you can stand this up before you have sorted out a single certificate.
A real Simulator in your browser
For genuinely interactive work, services such as Appetize.io stream a running iOS Simulator into a browser tab. You upload the unsigned .app your CI just built, open a link, and tap around the actual app from Linux or Windows. Device-farm services like BrowserStack and LambdaTest do the same thing against real hardware rather than a simulator, which costs more and tells you more.
This is the piece that changes the calculus most, because it restores the loop the previous section said you had lost. Push, wait for the build, click the link, use the app. Slower than a local Simulator, dramatically faster than a TestFlight round trip.
Renting the whole Mac
If you need Xcode itself — Interface Builder, the view hierarchy debugger, breakpoints in a running app — then rent a Mac rather than just a build. MacStadium, MacinCloud, Scaleway and AWS all rent macOS by the hour or month, and you connect over screen sharing.
Be clear-eyed about the arithmetic here. A few hours a month is trivially cheaper than buying hardware. Daily use for a year is not: at that point a monthly rental has quietly cost more than the Mac Mini it was avoiding, and it is slower to use. Rent for the occasional debugging session that genuinely needs a debugger; buy if that session is happening every week.
Which one to pick
Choose Codemagic if shipping is the goal and CI is not the interesting part of your work. Managed signing genuinely removes the worst afternoon in this process, and the free tier covers a low release cadence.
Choose a git-hosted runner if your CI already lives there, you want one system rather than two, or you need the build to do things a mobile-specific platform does not anticipate. You will spend an extra afternoon on keychain commands and then never think about it again.
Either way, the honest summary is this: the Mac requirement is a hardware-rental problem, not a wall. What you are really trading is iteration speed for capital expenditure — and for a mature Objective-C app in maintenance mode, shipping a handful of releases a year, that is often the right trade. For a UI-heavy app under active development, a used Mac Mini pays for itself in recovered afternoons faster than most people admit.
Building something and hitting a wall like this one? Tell us about it — we do this for a living, on stacks nobody chose on purpose.
