Development
Mac mini M1 GitHub Actions Self-Hosted Runner: Complete Flutter CI/CD Setup Guide (2026)
Set up a macOS ARM64 GitHub Actions runner on a Mac mini M1 for Flutter: launchd, Xcode, validation workflows, troubleshooting, security, and costs.
Why use a Mac mini for Flutter CI/CD?
A Mac mini M1 can turn a spare Apple Silicon machine into a repeatable place to validate a Flutter application and compile its iOS target. The useful distinction is ownership: you choose when to update Xcode, which Flutter SDK the project uses, and which jobs may touch the machine. That control is valuable when a native plugin or an older release branch depends on a particular toolchain.
This guide builds a small, trusted CI runner for a private repository. The end result is a manually triggered job that checks formatting, analyzes Dart, runs Flutter tests, and compiles an unsigned iOS application. It does not create a distribution certificate, upload an IPA, or publish an App Store release. Those are separate delivery stages with different permissions and acceptance checks.
All paths, machine names, capacity suggestions, and workflow choices below are example configurations. They are not claims about AiLane production infrastructure or measured build performance. Start with one runner and one job, then measure your own queue time, build duration, memory pressure, and maintenance effort before expanding.
Requirements and a sensible starting configuration
Use a dedicated macOS account for the runner, with a home directory that contains no personal documents or unrelated credentials. An M1 is an ARM64 machine; select native ARM64 downloads wherever the tool supports them. An example starting point is 16 GB of memory, enough free SSD space for Xcode and several build workspaces, wired networking, and a documented recovery procedure. These are planning choices, not minimum requirements or benchmark recommendations.
- Choose a macOS release supported by both the runner and the Xcode version your application requires. The runner operating-system minimum alone does not establish iOS build compatibility.
- Have repository-owner or appropriate organization administrator access to register the runner. Check organization restrictions before buying or configuring hardware.
- Allow the runner to reach GitHub and your package registries over outbound HTTPS. Runner registration does not require opening a public inbound port.
- Plan physical access or an approved remote-management path for restarts, login sessions, updates, and FileVault recovery. A machine waiting at a disk-unlock screen cannot pick up jobs.
Check Apple’s Xcode and macOS compatibility table
Check GitHub’s supported runner platforms and network requirements
GitHub-hosted versus self-hosted runners
GitHub-hosted runners remove much of the machine-management work. For an occasional iOS build, that simplicity can be worth more than retaining local caches. A self-hosted Mac gives you control over installed tools and local storage, but every successful job leaves the machine in a state that the next job may inherit. Treat that persistence as both an operational responsibility and a security boundary.
Compare the complete workflow rather than CPU names. A warm dependency cache may help repeated builds, while a large checkout, a slow network, or a serialized test suite may dominate elsewhere. A single machine also has a finite queue: two releases arriving together cannot both assume exclusive access to its keychain, simulator, ports, or working files.
For a first installation, keep Linux and Apple jobs separate. GitHub Actions Docker container actions and service containers require a Linux runner. Installing Docker Desktop on the Mac does not make a job with a Linux container action supported on macOS. Route those jobs to a Linux runner and keep native Apple builds on the Mac.
Create a repository runner in GitHub
Open your private repository, choose Settings, then Actions, then Runners, and select New self-hosted runner. Choose macOS and ARM64. GitHub generates download, verification, extraction, and registration commands for that selection. Use the commands shown for your repository at setup time; do not copy a historical runner archive URL or a registration token from a tutorial.
Keep the registration page private. Its token is time limited and belongs to this setup operation. If registration expires, generate fresh instructions instead of storing the token in a repository, shell script, screenshot, or issue. Use a clear runner name such as mac-mini-m1-ci and keep the default platform labels. A custom label can describe a dedicated capability, but a label is not an authorization control.
Download and register the macOS ARM64 runner
Sign in as the account that will own the runner. Choose a permanent folder outside Desktop, Documents, and Downloads so that ordinary workspace access does not depend on protected-folder prompts. In this example the runner lives at ~/actions-runner. Run GitHub’s generated download and integrity-check commands inside that folder, extract the archive, and execute the generated ./config.sh command.
mkdir -p "$HOME/actions-runner"
cd "$HOME/actions-runner"
# Run the current download, verification, extraction and config commands
# supplied by your GitHub repository’s runner setup page.
# After registration completes:
./run.shWait until the foreground process is listening for work and GitHub shows the runner online. Confirm its labels include self-hosted, macOS, and ARM64. This first foreground connection separates registration and network problems from later service problems. Stop it with Control-C before starting the service; do not leave two listeners competing for the same runner installation.
Do not reuse a registered runner directory by copying it to another host. Give each runner its own registration and working folder. If a download or checksum fails, stop and investigate the original source and network path; removing the integrity check is not a recovery strategy.
Run the runner as a launchd service
From the registered runner directory, run the official service helper as the runner account. The macOS helper must not be run with sudo. It creates a LaunchAgent for that user; Linux service examples using sudo are not interchangeable with these commands.
cd "$HOME/actions-runner"
./svc.sh install
./svc.sh start
./svc.sh statusA LaunchAgent belongs to a user session. Do not equate a plist on disk with a running listener, or assume a machine reboot means the appropriate user session has returned. Check the owning account, its launchd session, and GitHub’s online status after a reboot or logout. For workflows involving Simulator or other GUI services, verify the required graphical session explicitly. An SSH login alone is not proof that the GUI environment is ready.
The helper reports the installed service path and records that plist path in .service. Read its Label field for a focused diagnostic without guessing an unrelated service name. Run this inspection as the same signed-in user. If the GUI domain does not exist or the label is absent, investigate the session and service installation before changing the plist.
cd "$HOME/actions-runner"
service_plist="$(cat .service)"
service_label="$(/usr/libexec/PlistBuddy -c 'Print :Label' "$service_plist")"
launchctl print "gui/$(id -u)/$service_label"
# Planned maintenance, after active work has finished:
./svc.sh stop
# Start it again when the machine is ready:
./svc.sh startKeep a written restart test: machine reachable, runner user session ready, service active, runner online, then one real CI job completed. Do not enable unattended login or weaken disk encryption merely to make this test pass. Choose a recovery policy that fits the security requirements of the machine.
Install Flutter, Xcode, and native dependencies
Install Xcode from Apple and select the version your project supports. Complete the first-run setup and review the license interactively as the machine owner. Use the full Xcode developer directory, not just the standalone Command Line Tools directory. These setup operations may require an administrator and should happen during machine provisioning, not in every CI job.
sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer
sudo xcodebuild -runFirstLaunch
sudo xcodebuild -license
xcodebuild -version
xcode-select -p
xcodebuild -downloadPlatform iOSInstall the Apple Silicon Flutter SDK using Flutter’s official instructions. The example workflow expects an approved SDK at ~/development/flutter. Record its version in your repository’s development documentation and update it deliberately. Do not put flutter upgrade inside a validation job: doing so changes the toolchain while the job is supposed to be checking a specific revision.
export PATH="$HOME/development/flutter/bin:/opt/homebrew/bin:$PATH"
command -v flutter
flutter --version
dart --version
flutter doctor -v
flutter precache --iosIf your project or plugins use CocoaPods, install it following the official CocoaPods guide. Prefer a managed Ruby environment over changing Apple’s system Ruby. Keep any Gemfile, Gemfile.lock, and Podfile.lock consistent with your project. A project using Bundler must expose its pinned bundle to the Flutter build; a project using Swift Package Manager should follow that project’s integration rather than adding a Podfile solely because a tutorial mentions CocoaPods.
ruby --version
command -v pod
pod --versionInspect flutter doctor output by target. An Android-toolchain warning does not establish an iOS failure, but a missing Xcode component does. A service does not necessarily load your interactive shell configuration; verify tool paths in a real job. Put required PATH entries in the workflow or a maintained provisioning configuration. Grant only the specific macOS permission needed by the process that actually performs the work, rather than broadly enabling Full Disk Access.
A complete Flutter validation workflow
Save this example as .github/workflows/flutter-mac.yml in a private repository whose default branch is main and whose Flutter project is at the repository root. It deliberately runs only by manual dispatch from main. Review and protect that branch, restrict who can change workflows, and dispatch only code you trust. Adapt the branch and working directory explicitly if your repository differs.
name: Flutter Mac validation
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: flutter-mac-validation
cancel-in-progress: false
jobs:
validate:
if: github.ref == 'refs/heads/main'
runs-on: [self-hosted, macOS, ARM64]
timeout-minutes: 45
defaults:
run:
shell: bash
steps:
- name: Check out trusted source
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Configure installed toolchain
run: |
echo "$HOME/development/flutter/bin" >> "$GITHUB_PATH"
echo "/opt/homebrew/bin" >> "$GITHUB_PATH"
- name: Record toolchain
run: |
uname -m
xcodebuild -version
flutter --version
flutter doctor -v
- name: Resolve dependencies
run: flutter pub get
- name: Check formatting
run: dart format --output=none --set-exit-if-changed .
- name: Analyze
run: flutter analyze
- name: Test
run: flutter test
- name: Compile unsigned iOS application
run: flutter build ios --release --no-codesignThe example pins checkout to the full commit SHA of its v6.0.2 release. Verify the linked release and its runner-version requirement before adopting it, and review future updates to the pin. An immutable reference prevents a moving tag from silently changing the action; it does not replace reviewing what the action does or keeping the runner compatible.
The three labels are an intersection: a runner must match all of them. The concurrency group serializes this workflow in this repository; it is not a host-wide lock across other repositories or runner registrations. Start with one registration on the Mac. If you later share the host, introduce a common resource-lock policy before parallelizing jobs that use simulators, keychains, or fixed ports.
The last command compiles without signing. It proves neither that a device can install the app nor that App Store Connect will accept it. Production signing needs a matching bundle identifier, developer team, entitlements, certificate and private key, provisioning profile or approved automatic-signing setup, and secure keychain access. Distribution also needs an archive/export configuration, upload authorization, Apple processing, and separate release approval.
Run and verify the first job
Commit the workflow through your normal review process onto the default branch. Open Actions, select Flutter Mac validation, choose Run workflow, and select main. Watch whether the job is queued, assigned, running, or finished. A green runner status only proves a listener is available; the job result is the evidence that the toolchain can execute your project.
- Check that the job ran on the intended Mac and reported arm64, the expected Xcode, and the approved Flutter version.
- Read each step result. A skipped build, cancelled job, or job that never acquired a runner is not a successful iOS compilation.
- Retain the commit SHA, run URL, toolchain output, and build result. Do not save tokens, signing material, or private dependency credentials in the receipt.
- Repeat after a planned service restart. Verify that the same trusted revision still works without an interactive shell supplying hidden environment settings.
If the sample fails on formatting or analysis, fix the reported project issue through normal review. Do not remove those checks to make the installation look complete. If compilation fails because your application requires flavors or additional build arguments, document those requirements and adapt the final command without introducing release credentials into basic validation.
Updates and monitoring
The runner application normally updates itself. That does not update macOS, Xcode, Flutter, Ruby, CocoaPods, or your application dependencies. Maintain those as separate inventories. Leave runner automatic updates enabled for the simple setup, and consult GitHub’s update policy before choosing to manage runner updates manually.
Schedule toolchain maintenance when the machine is idle. Record the old and new tool versions, update one component at a time where practical, run the known validation workflow, and keep a recovery path. An Xcode update can change SDK availability or require first-run preparation even when the runner itself stayed online.
Monitor queue age, service availability, free disk space, and actual workflow failures. Runner logs live under the runner directory’s _diag folder; inspect the relevant Runner and Worker log for the time of the failure. Redact before sharing. Define a retention policy for workspaces, caches, and logs, but do not delete a shared cache or build directory while another job is using it.
Common errors and how to narrow them down
- Job stays queued: compare every runs-on label with the runner’s labels, check repository or runner-group access, and confirm the runner is online and idle. A busy runner or a concurrency queue needs a different fix from a missing listener.
- Foreground works, service fails: check the owning macOS account and session, service status, filesystem permissions, PATH, and the service log. Test the exact service environment before reinstalling tools.
- flutter or pod is not found: inspect command -v and PATH inside the job. Installing a tool for your personal account does not install it for the dedicated runner account.
- Xcode reports an invalid developer directory or missing SDK: inspect xcode-select -p and xcodebuild -version, then complete the approved Xcode setup and platform installation.
- A native dependency reports an incompatible architecture: check whether the dependency supports Apple Silicon and whether an Intel Ruby or binary entered the toolchain. Do not apply Rosetta globally without understanding which dependency needs it.
- Code signing requires interaction: distinguish a locked keychain, a missing certificate/private key, an unmatched profile, and missing entitlements. A certificate file alone is not a usable signing identity.
- The runner disappears after reboot: check disk unlock, the runner account’s session, and the installed LaunchAgent. A successful install command from yesterday is not current process evidence.
- A build hangs or exhausts disk: inspect the active process and disk usage, impose a job timeout, and investigate workspace growth. Avoid terminating unrelated jobs or deleting a whole home directory.
Change one cause at a time and rerun the same trusted revision. Keep the original error and the successful verification together. This makes a future failure diagnosable and prevents a temporary workaround from becoming an undocumented machine dependency.
Protect a persistent build machine
Use a private repository with trusted contributors for this basic setup. Untrusted pull requests can execute repository scripts, dependency installation hooks, tests, and build steps on the machine. A persistent runner may retain malware or modified tools after the job ends. Withholding repository secrets is useful, but it does not isolate the filesystem, local network, or credentials already present on the host.
Do not add a pull_request trigger for public forks to this workflow, and never combine pull_request_target with checkout and execution of untrusted pull-request code on the Mac. Manual dispatch is not a security sandbox either: reviewers must trust the workflow and the entire checked-out revision, including dependencies and scripts.
- Keep the runner account unprivileged for daily work and remove unrelated credentials and network access.
- Use read-only job permissions by default, disable persisted checkout credentials when unnecessary, and pin reviewed third-party actions.
- Put signing and release work behind separate protected environments and review. Prefer short-lived credentials where the service supports them.
- Use isolated, disposable execution environments for untrusted builds. Cleaning the workspace after a job cannot prove that a compromised persistent host is clean.
Real costs and trade-offs
A spare Mac is not free infrastructure. Budget hardware depreciation or replacement, electricity, broadband, storage wear, backup or recovery capacity, and the time spent maintaining tools. The economic question is whether the control and reuse are worth those costs for your workload. Check current GitHub pricing and plan terms when comparing hosted capacity; this guide makes no fixed monthly-cost or savings promise.
For an example calculation, measure average power at the wall over a representative week. Convert watts to kilowatts, multiply by hours of operation, and then by your local energy tariff. Add a monthly hardware allowance and the cost of engineering time. Compare that total with the hosted jobs you would actually run, including queue delays and failures, rather than comparing purchase price with one advertised minute rate.
Include downtime in the decision. One Mac is a single point of failure. A failed SSD, unavailable network, or required operating-system restart can block every Apple release. Keep a documented alternative such as a second prepared machine or a hosted workflow, and test that alternative before a release deadline.
Build trust before adding release automation
The useful milestone is a reproducible validation job from a known commit, using recorded tool versions, after a service restart. Once that baseline is reliable, add project-specific integration tests, simulator coverage, artifact retention, and a separately protected signing pipeline. Each new stage should establish its own result rather than inheriting confidence from an earlier green check.
Keep the runner’s responsibilities small: execute trusted code, report useful failures, and remain recoverable. A well-documented Mac mini can be a practical Flutter CI machine, provided its owner accepts the maintenance and security work that comes with it.