GitHub Actions iOS Build: Build an iOS .ipa on a Linux Runner
Last updated
A GitHub Actions iOS build doesn’t need per-minute macOS runners. Install KXBuild on your own Linux machine, sign in once, register it as a self-hosted GitHub runner, set runs-on: [self-hosted, linux] in the workflow, and run kxbuild build -c Release --export app-store to get a signed .ipa. The certificate and profile live in Secrets as base64, are decoded into a temporary folder before the build, and are deleted afterwards. Below are the complete workflow file and fixes for common failures.
Before you start, have these ready.
- A Linux machine, physical or a cloud server, with access to GitHub.
- A project that builds Release locally with KXBuild, in a private GitHub repository.
- An Apple Distribution certificate (.p12) with its password, and an App Store provisioning profile (.mobileprovision). See Certificates and signing for how to get them, and Get an iOS certificate, p12 and provisioning profile on Windows for the full steps.
- If the project uses Swift packages, Package.resolved committed to the repository.
How many steps does a GitHub Actions iOS build take?
| Step | Where | Once or every run |
|---|---|---|
| 1. Install KXBuild and sign in | Linux build machine | Once |
2. Run kxbuild doctor |
Linux build machine | Once, and again after updates |
| 3. Register a self-hosted runner and install it as a service | GitHub repository settings and the build machine | Once |
| 4. Store the certificate and profile in Secrets | GitHub repository settings | Once, and again when the certificate changes |
| 5. Decode the certificate, build the .ipa, upload the artifact, clean up the certificate | Workflow | Every run |
What does the Linux build machine need?
Install KXBuild and sign in
On the build machine, log in as the user that will run the runner, then install KXBuild and sign in to your account by following the install page. On Linux, install with the install.sh one-liner.
1curl -fsSL https://www.kxbuild.net/install.sh | bash
After installing, open a new terminal and run doctor. When every check passes, you’re ready.
1kxbuild doctor
The install script writes the install folder into the environment of the current user. The runner service must run as the same user, or the workflow can’t find kxbuild.
Also set the environment variable KXBUILD_NO_UPDATE_CHECK=1 for this user to turn off new-version prompts and background checks. Update by hand with kxbuild doctor during maintenance, not as part of every build, so two builds never use different toolchain versions. See Folders and environment variables for the full list.
How do you register a self-hosted runner?
Add the runner to the repository
- Open the GitHub repository and click Settings.
- In the sidebar, click Actions, then Runners.
- Click New self-hosted runner.
- Choose Linux as the operating system, and x64 or ARM64 to match the build machine.
- The page lists a set of commands. On the build machine, open a terminal as the user that installed KXBuild and run them in order.
These commands download and extract the runner, then run ./config.sh to register the machine with the repository. Registration asks a few questions. Press Enter to use the host name as the runner name, and keep the default labels. The defaults already include self-hosted and linux, which is what the workflow uses to select this runner. See GitHub’s Adding self-hosted runners for the full steps.
Install it as a service
The last command on the page, ./run.sh, runs the runner in the foreground, and it disconnects when you close the terminal. Once you’ve confirmed it connects, stop it with Ctrl+C and install it as a system service in the runner folder so it starts at boot.
1sudo ./svc.sh install
2sudo ./svc.sh start
3sudo ./svc.sh status
The service must run as the user that installed KXBuild. Run the commands above as that user and the service runs as that user. To use a different user, add the name after install, for example sudo ./svc.sh install builder. See GitHub’s Configuring the self-hosted runner application as a service for the service commands.
Once the service is running, the runner shows as Idle in Settings > Actions > Runners and is ready for jobs.
Let the workflow find kxbuild
A runner running as a service doesn’t always read the user’s shell profile, so the workflow may not find kxbuild. The reliable approach is to write the install folder and its bin folder to GITHUB_ENV and GITHUB_PATH in the first step of the workflow, so every later step can call kxbuild. The default install folder is ~/kxbuild. If you installed elsewhere, use your actual path.
1echo "KXBUILD_HOME=$HOME/kxbuild" >> "$GITHUB_ENV"
2echo "$HOME/kxbuild/bin" >> "$GITHUB_PATH"
GITHUB_PATH takes effect from the next step, so run kxbuild version in the next step. If it prints a version number, the path is right.
Put the certificate and profile in Secrets
Signing needs three things, the .p12 certificate, its password and the App Store provisioning profile. GitHub Secrets only store text, and .p12 and .mobileprovision are binary files, so convert them to base64 strings first. A single secret can be up to 48 KB, and the base64 of a certificate or profile is usually a few KB to a dozen KB, so they fit.
| Secret name | Content |
|---|---|
| IOS_P12_BASE64 | Base64 of the Apple Distribution certificate .p12 |
| IOS_P12_PASSWORD | Password of the .p12 |
| IOS_PROFILE_BASE64 | Base64 of the App Store provisioning profile .mobileprovision |
If the build machine is dedicated to one team, you can instead put the certificates in ~/kxbuild/keychain on the build machine once, shared by all projects, and skip certificate handling in the workflow. The trade-off is that anyone who can run a workflow on this runner can get the private key.
Generate base64
On Windows, generate it with PowerShell. The result is a single line with no line breaks.
1[Convert]::ToBase64String([IO.File]::ReadAllBytes("$PWD\distribution.p12")) | Set-Content -NoNewline p12.b64.txt
2[Convert]::ToBase64String([IO.File]::ReadAllBytes("$PWD\MyApp_AppStore.mobileprovision")) | Set-Content -NoNewline profile.b64.txt
On Linux or WSL, use the base64 command. -w0 turns off line wrapping.
1base64 -w0 distribution.p12 > p12.b64.txt
2base64 -w0 MyApp_AppStore.mobileprovision > profile.b64.txt
Don’t use certutil -encode on Windows. It adds BEGIN and END marker lines that break decoding.
Add them to the repository
Open Settings > Secrets and variables > Actions in the repository, click New repository secret, and add each one from the table above. The value is the whole content of the txt file. With the GitHub CLI installed, you can run these three commands in the repository folder instead.
1gh secret set IOS_P12_BASE64 < p12.b64.txt
2gh secret set IOS_PROFILE_BASE64 < profile.b64.txt
3gh secret set IOS_P12_PASSWORD
The last one prompts for the password. Afterwards, delete the local txt files and never commit them.
Restore them in the workflow
Before building, decode the two files into a temporary certificate folder and write a password.properties. The layout matches the certificate folder in Certificates and signing. Each line of password.properties is filename=password, the file name must match the .p12 exactly, and the password has no quotes.
1KC="$RUNNER_TEMP/ci-keychain"
2mkdir -p "$KC"
3printf '%s' "$IOS_P12_BASE64" | base64 -d > "$KC/distribution.p12"
4printf '%s' "$IOS_PROFILE_BASE64" | base64 -d > "$KC/MyApp_AppStore.mobileprovision"
5printf 'distribution.p12=%s\n' "$IOS_P12_PASSWORD" > "$KC/password.properties"
At build time, point the CODE_SIGN_KEYCHAIN build setting at this folder and KXBuild picks the certificate and profile from there. A self-hosted runner keeps its disk between runs, so always delete this folder after the build. The last step of the workflow below does that.
Which command builds the .ipa?
The build command matches the CI usage in the kxbuild command reference.
1kxbuild build ./MyApp.xcworkspace -s MyApp -c Release --export app-store --clean --derivedDataDir ./out --skip-package-updates --ipa ./out/MyApp.ipa -D CODE_SIGN_KEYCHAIN="$KC" -D CURRENT_PROJECT_VERSION="$GITHUB_RUN_NUMBER"
| Option | What it does |
|---|---|
-s MyApp -c Release |
Builds the Release configuration of the MyApp scheme |
--export app-store |
Signs with the Apple Distribution certificate and App Store provisioning profile, whatever the project’s signing settings say |
--clean --derivedDataDir ./out |
Puts build output in the out folder of the workspace and starts clean every time |
--skip-package-updates |
Uses exactly the Swift package versions in Package.resolved, without contacting remote repositories |
--ipa ./out/MyApp.ipa |
Where the .ipa goes. MyApp.AppStoreInfo.plist for uploading is written next to it |
-D CODE_SIGN_KEYCHAIN |
Points at the temporary certificate folder |
-D CURRENT_PROJECT_VERSION |
Uses the GitHub run number as the build number |
The command exits with 0 on success and non-zero on failure, and Actions marks the step as failed accordingly. For a project with only an .xcodeproj, change the first argument to ./MyApp.xcodeproj. For the full rules on build numbers, see Auto-increment build numbers.
Other frameworks
- CocoaPods. Run
pod installin the project folder before building, and build the .xcworkspace. See CocoaPods. - Flutter, React Native, Expo. Run doctor once with the project folder first, for example
kxbuild doctor ./my_flutter_app. It checks the framework environment and prepares the iOS project. Then install dependencies and build the project in the ios folder as described on the page for that framework. See Flutter, React Native and Expo. - Existing xcodebuild scripts. KXBuild includes xcodebuild and xcrun commands with the same names, so your build scripts keep working, including
-exportArchivefor exporting the .ipa. A new pipeline can use a singlekxbuild build -c Release --export app-store. See xcodebuild and xcrun compatibility.
The complete workflow
Save the following as .github/workflows/ios.yml in the repository. It runs when you push a tag starting with v, and you can also trigger it by hand on the Actions page.
1name: iOS build
2
3on:
4 push:
5 tags: ["v*"]
6 workflow_dispatch:
7
8jobs:
9 build-ios:
10 runs-on: [self-hosted, linux]
11 timeout-minutes: 60
12 env:
13 KXBUILD_NO_UPDATE_CHECK: "1"
14 steps:
15 - name: Checkout
16 uses: actions/checkout@v4
17
18 - name: Add KXBuild to PATH
19 run: |
20 echo "KXBUILD_HOME=$HOME/kxbuild" >> "$GITHUB_ENV"
21 echo "$HOME/kxbuild/bin" >> "$GITHUB_PATH"
22
23 - name: Check KXBuild
24 run: kxbuild version
25
26 - name: Prepare signing
27 env:
28 IOS_P12_BASE64: ${{ secrets.IOS_P12_BASE64 }}
29 IOS_P12_PASSWORD: ${{ secrets.IOS_P12_PASSWORD }}
30 IOS_PROFILE_BASE64: ${{ secrets.IOS_PROFILE_BASE64 }}
31 run: |
32 KC="$RUNNER_TEMP/ci-keychain"
33 mkdir -p "$KC"
34 printf '%s' "$IOS_P12_BASE64" | base64 -d > "$KC/distribution.p12"
35 printf '%s' "$IOS_PROFILE_BASE64" | base64 -d > "$KC/MyApp_AppStore.mobileprovision"
36 printf 'distribution.p12=%s\n' "$IOS_P12_PASSWORD" > "$KC/password.properties"
37 echo "KC=$KC" >> "$GITHUB_ENV"
38
39 - name: Build ipa
40 run: |
41 kxbuild build ./MyApp.xcworkspace -s MyApp -c Release --export app-store --clean \
42 --derivedDataDir ./out --skip-package-updates --ipa ./out/MyApp.ipa \
43 -D CODE_SIGN_KEYCHAIN="$KC" \
44 -D CURRENT_PROJECT_VERSION="$GITHUB_RUN_NUMBER"
45
46 - name: Upload ipa
47 uses: actions/upload-artifact@v4
48 with:
49 name: MyApp-${{ github.run_number }}
50 path: |
51 out/*.ipa
52 out/*.AppStoreInfo.plist
53 retention-days: 30
54 if-no-files-found: error
55
56 - name: Clean up signing files
57 if: always()
58 run: rm -rf "$RUNNER_TEMP/ci-keychain"
A few details.
runs-on: [self-hosted, linux]selects a self-hosted runner with both labels. If you have several runners and only want the one with KXBuild to take this job, give it a custom label such askxbuildwhen you register it and write[self-hosted, linux, kxbuild]here.GITHUB_RUN_NUMBERis the run number of this workflow in the repository. It goes up by one with each new run, which makes it a good build number.if-no-files-found: errorfails the step when no .ipa was produced, instead of quietly uploading an empty artifact.if: always()on the last step deletes the certificate files even when the build fails.- To take the version number (MARKETING_VERSION) from the tag, add
-D MARKETING_VERSION="${GITHUB_REF_NAME#v}"to the build command. The tagv1.2.0becomes1.2.0.
When the run finishes, open it on the Actions page of the repository and download MyApp-.zip from Artifacts at the bottom. It contains the .ipa and AppStoreInfo.plist. Upload the .ipa with Transporter or Appuploader, as described in Release and App Store submission. To upload automatically as part of the workflow, see Upload .ipa files with an API key.
What are the security risks of a self-hosted runner?
- Use self-hosted runners only with private repositories. GitHub’s secure use reference says self-hosted runners should almost never be used for public repositories, because anyone can open a pull request on a public repository and run code on your build machine. In a private repository, anyone who can fork it and open a pull request can also run code on the build machine, so grant repository access only to people you trust.
- Give the certificate only to the release workflow. Put the three secrets in an environment such as
release, addenvironment: releaseto the job, and restrict the environment to protected branches and tags. - Don’t print certificates in logs. Actions replaces secret values with
***, but the content of decoded files isn’t masked. Don’tcatfiles in the certificate folder in your scripts.
Self-hosted Linux runner or GitHub-hosted macOS runner
| Self-hosted Linux runner with KXBuild | GitHub-hosted macOS runner with Xcode | |
|---|---|---|
| Billing | GitHub doesn’t charge per minute. You pay for the machine and power | Private repositories are billed per minute, US$0.062 per minute for the standard macOS runner |
| Machine | Bring your own Linux machine | Provided by GitHub, a new machine every run |
| Toolchain | Install once, then build every time | Xcode preinstalled in the image |
| Producing the .ipa | One step with kxbuild build -c Release --export app-store |
Archive, then exportArchive |
| Metal shaders | Not supported. Projects with .metal files need a Windows build machine | Supported |
| Simulator and UI tests | Not supported | Supported |
| File name case | Case-sensitive | Case-insensitive by default |
As of October 2026, GitHub’s Actions billing documentation states that Actions usage on self-hosted runners is free. Hosted macOS runner rates are in the runner pricing reference, and GitHub’s current published rates apply.
For projects that only need an .ipa and don’t run simulator tests, a self-hosted Linux runner costs the least. If you need simulator tests, split them into two jobs, with tests on a hosted macOS runner and packaging on the self-hosted Linux runner.
What if the GitHub Actions iOS build fails?
| Symptom | Cause | Fix |
|---|---|---|
| The job stays queued, waiting for a runner | The runner is offline, or its labels don’t match runs-on |
Check that it shows Idle in Settings > Actions > Runners, and check the service with sudo ./svc.sh status |
kxbuild: command not found |
The runner service runs as a different user than the one that installed KXBuild, or the bin folder isn’t on PATH | Check the service user, write GITHUB_PATH as shown above, and use your actual path if you changed the install folder |
base64: invalid input |
The secret contains line breaks or BEGIN and END markers | Generate it again with -w0 or ToBase64String as shown above |
No signing certificate matching '…' found |
The certificate folder has no certificate of the type or team the project requires | Make sure the .p12 is Apple Distribution and belongs to the project’s DEVELOPMENT_TEAM |
| Wrong .p12 password | The file name in password.properties doesn’t match the .p12, or the password is wrong | Use distribution.p12 as the file name and set IOS_P12_PASSWORD again |
| Provisioning profile not found | The profile’s bundle ID doesn’t match the project, or the project names a different profile | Use an App Store profile. If the project sets PROVISIONING_PROFILE_SPECIFIER, it must match the profile name |
| Swift package resolution fails | Package.resolved isn’t committed, so --skip-package-updates has no versions to use |
Resolve locally and commit Package.resolved |
| Compiles locally, but CI can’t find a header | Linux file names are case-sensitive, so #import "myview.h" doesn’t find MyView.h |
Match the case of the file name exactly in the import |
| .metal files fail to compile | There’s no Metal compiler on Linux | Build on a Windows self-hosted runner. See Build and configuration |
| The build or doctor reports environment problems | The toolchain is incomplete, or doctor wasn’t run after an update | Run kxbuild doctor on the build machine as the runner user and follow its instructions |
When a GitHub Actions iOS build fails, read the full log of the Build ipa step first. The error details are in the regular output. See Troubleshooting for more.
FAQ
Why use a self-hosted runner to build iOS apps in GitHub Actions?
KXBuild needs you to sign in to your account once on the build machine, and a GitHub-hosted runner is a new machine on every run that keeps no state. A self-hosted runner stays signed in, doesn’t reinstall the toolchain on every run, and GitHub doesn’t charge per minute for it.
Can GitHub Actions build an iOS .ipa on a Linux runner?
Yes. Install KXBuild on your own Linux machine and register it as a self-hosted runner, decode the certificate from Secrets into a temporary folder, and run kxbuild build -c Release --export app-store to get a signed .ipa. It has the same layout as an Xcode export and can be uploaded to App Store Connect as is.
How do I put a p12 certificate in GitHub Secrets?
Convert the .p12 to a base64 string with no line breaks and store it as one secret, and store the password as a separate secret. In the workflow, decode it back to a file with base64 -d and write a filename=password line to password.properties in the same folder.
Can I use a self-hosted runner with a public repository?
It isn’t recommended. GitHub advises using self-hosted runners only with private repositories, because anyone can open a pull request on a public repository and run code on your build machine. For open source projects that need packages, do the packaging in a private repository or in a workflow that only maintainers can trigger.
Can a Linux runner build a project with Metal shaders?
No. There’s no Metal compiler on Linux. Build these projects on a Windows build machine with Metal Developer Tools for Windows installed, for example by registering a self-hosted runner on your own Windows machine.