How to install GitHub action runner on EC2 Mac

Issue #1002

I recently set up self-hosted runners on EC2 Mac instances for iOS builds. Here are my notes on what actually works—plus all the gotchas I hit along the way.

For complete official instructions, see GitHub’s guide on adding self-hosted runners.

Downloading the Runner

Navigate to your organization settings: Settings → Actions → Runners → New runner

Select macOS and arm64 (crucial if you’re using Apple Silicon). GitHub gives you a download link and a registration token—grab it quick, it expires in 1 hour.

Extract it somewhere clean:

mkdir actions-runner && cd actions-runner
curl -o actions-runner-osx-arm64-2.331.0.tar.gz -L https://github.com/actions/runner/releases/download/v2.331.0/actions-runner-osx-arm64-2.331.0.tar.gz
tar xzf ./actions-runner-osx-arm64-2.331.0.tar.gz

What’s Inside

After extraction, you’ll see these files in your actions-runner folder:

_diag/
_work/
actions-runner-osx-arm64-2.331.0.tar.gz
bin/
config.sh
env.sh
externals/
run-helper.cmd.template
run-helper.sh
run-helper.sh.template
run.sh
runsvc.sh
safe_sleep.sh
svc.sh (appears after config.sh runs)

Config.sh Options

The config.sh command has several useful options. Here’s what you need to know:

Commands:

  • ./config.sh - Configure the runner
  • ./config.sh remove - Unconfigure the runner
  • ./run.sh - Run the runner interactively (great for testing)

Common Config Options:

  • --unattended - Skip interactive prompts, use defaults for missing options
  • --url <repo-url> - Your repository URL (required for unattended mode)
  • --token <token> - Registration token (required for unattended mode)
  • --name <name> - Name for the runner (defaults to hostname)
  • --labels <labels> - Custom labels like ios-build,m1,self-hosted
  • --runnergroup <group> - Add runner to a specific group
  • --no-default-labels - Skip default labels (self-hosted,OSX,Arm64)
  • --replace - Replace any existing runner with the same name
  • --ephemeral - Runner takes one job then exits
  • --disableupdate - Don’t auto-update to latest version

Configuring the Runner

Basic configuration:

./config.sh --url https://github.com/your-org --token YOUR_TOKEN

You’ll be prompted for:

  • Runner name: Something descriptive like mac-m1-runner-1
  • Runner group: Optional, useful for multiple runners
  • Labels: Important—use labels like ios-build, m1, self-hosted to target specific runners in your workflows

For automation, skip the prompts:

./config.sh --unattended --url https://github.com/your-org --token YOUR_TOKEN --name mac-runner-1 --labels ios-build

After config completes, your runner will appear on GitHub—but it’ll be offline. We’ll start it next.

Testing Before Running as Service

You can test the runner first by running it interactively. For testing, I use Session Manager to login to the EC2 Mac instance (you’ll be logged in as ssm-user). Then switch to ec2-user:

sudo su - ec2-user

Now start the runner:

cd ~/actions-runner
./run.sh

Once you confirm it works, you can set it up as a service.

cd ~/actions-runner
./svc.sh install ec2-user
./svc.sh start

It works because we have login session via Session Manager, but we will mostly like to have this script triggered when ec2 instances boots with user data.

Running as a Service with launchd Daemon

The best approach is to use a launchd daemon for reliable startup at boot. When you run user data scripts on AWS EC2 Mac instances, they execute as root, so we need to configure a daemon that runs as the ec2-user.

Create a plist file at /Library/LaunchDaemons/ec2-mac-runner.plist:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
  <dict>
    <key>Label</key>
    <string>ec2-mac-runner</string>
    <key>ProgramArguments</key>
    <array>
      <string>/Users/ec2-user/actions-runner/runsvc.sh</string>
    </array>
    <key>UserName</key>
    <string>ec2-user</string>
    <key>WorkingDirectory</key>
    <string>/Users/ec2-user/actions-runner</string>
    <key>RunAtLoad</key>
    <true/>
    <key>StandardOutPath</key>
    <string>/Users/ec2-user/Library/Logs/ec2-mac-runner/stdout.log</string>
    <key>StandardErrorPath</key>
    <string>/Users/ec2-user/Library/Logs/ec2-mac-runner/stderr.log</string>
    <key>EnvironmentVariables</key>
    <dict>
      <key>ACTIONS_RUNNER_SVC</key>
      <string>1</string>
    </dict>
    <key>ProcessType</key>
    <string>Interactive</string>
    <key>SessionCreate</key>
    <true/>
  </dict>
</plist>

The key settings here:

  • UserName: Runs as ec2-user instead of root
  • RunAtLoad: Starts automatically when the system boots
  • ProcessType: Set to Interactive for proper GUI session handling
  • SessionCreate: Creates a login session so keychain and other services are accessible

Load it with:

sudo launchctl load /Library/LaunchDaemons/ec2-mac-runner.plist

EC2 User Data Setup

For AWS EC2 Mac instances, use user data to configure everything on first boot. Here’s a complete example:

#!/bin/bash
# EC2 Mac Runner Setup

# Ensure the runner logs directory exists
mkdir -p /Users/ec2-user/Library/Logs/ec2-mac-runner

# Copy the plist file from above to /Library/LaunchDaemons/ec2-mac-runner.plist
# (Use the plist configuration shown in the previous section)

# Load the daemon
sudo launchctl load /Library/LaunchDaemons/ec2-mac-runner.plist

To verify the setup worked, use Session Manager to connect to your instance and check:

sudo launchctl list | grep ec2-mac-runner

And check the logs:

cat /Users/ec2-user/Library/Logs/ec2-mac-runner/stdout.log

Keeping Your Instance Awake

By default, macOS instances will sleep or shut down to conserve resources. This breaks your runner. Disable sleep and ensure the instance stays alive:

systemsetup -setallowpowerbuttontosleepcomputer off > /dev/null 2>&1
pmset sleep 0
pmset disksleep 0
pmset displaysleep 0

Auto-restart if there’s a power failure:

pmset autorestart 1

Disable power nap:

pmset -c powernap 0

You can also use caffeinate to keep the system awake long-term:

caffeinate -i -m -u &

Troubleshooting

Here are some issues I ran into along the way:

“Http response code: UnprocessableEntity (422)” or 404 errors

Your token is probably expired (they only last 1 hour) or your GitHub URL is wrong. Double-check both and generate a fresh token if needed.

“no such file or directory: ./config.sh”

You’re not in the actions-runner directory. This happens a lot with sudo -u -i ec2-user because -i changes to the user’s home directory (/Users/ec2-user), not where your runner is. Use sudo su - ec2-user instead, then navigate to your runner folder.

“Runner.Listener: cannot execute binary file”

You downloaded the wrong architecture (x64 instead of arm64) or wrong OS. Go back to GitHub and explicitly select macOS and arm64 when generating the runner.

“Must not run with sudo” error during config

Don’t use sudo for config.sh. If you need to configure as a specific user (like ec2-user), do:

sudo su - ec2-user bash -c "cd ~/actions-runner && ./config.sh --unattended --url ... --token ..."

“Load failed: 5: Input/output error”

The launchd daemon needs proper session setup to access system resources. When you load and bootstrap services, the user needs to be in a GUI session to configure them properly.

We could use remote desktop to our ec2 instance and ./svc.sh start and it should work. For terminal setup, I used to use this, runsvc.sh start the agent but in the foreground but adding the & moves the it to the background

sudo su -- ec2-user ./runsvc.sh start &

While this works and the runner is registered on GitHub, I get some keychain reading issues with this approach.

For automated setup via user data, make sure your plist file has these settings:

  • ProcessType set to Interactive
  • SessionCreate set to true
  • Use launchctl load (not launchctl bootstrap system) to load the plist

Verify the plist syntax is correct:

plutil -lint /Library/LaunchDaemons/ec2-mac-runner.plist

“./bin/actions.runner.service.temp: Permission denied”

This happens if you pre-baked the runner in an AMI but then login as ssm-user (AWS Session Manager). Either ensure ssm-user has access: sudo chown -R ssm-user:ssm-user /Users/ec2-user/actions-runner, or use ec2-user consistently throughout your setup.

“There are no local code signing identities found”

Fastlane shows this when it can’t find code signing certificates:

Fetching profiles...
> Verifying certificates
> There are no local code signing identities found

Background services have different keychain access. When I checked user data logs, it couldn’t find any certificates:

security find-identity -p codesigning -v
     0 valid identities found

But when I connected via Session Manager and logged in as ec2-user, it worked:

security find-identity -p codesigning -v
5D74AB69F1E0E5E8340CB2A9663F8FDCB36319B0 "Apple Distribution: My Company (3ACDEFG12)"
     1 valid identities found

Background scripts running as daemons can’t access the login keychain like a regular login session can. Since the runner service runs like a daemon without a user session, it can’t access the login keychain—it only uses the system keychain. Make sure your signing certificates are in the system keychain.

If running from a background daemon (like launchd), set SessionCreate to true in your plist. This creates a login session with proper keychain access.

“Error parsing provisioning profile”

If you get this error:

Downloading provisioning profile...
Successfully downloaded provisioning profile...

Error parsing provisioning profile at path '/Users/ec2-user/actions-runner/_work/my-app/com.myapp.mobileprovision'

Background services (launchd daemons) run without a user login session by default. Even though they run as a specific user, they can’t access session-specific resources like the login keychain. I tried importing certs into the system keychain at /Library/Keychains/System.keychain, but the background script had problems interacting with its certificates.

Make sure your launchd plist includes SessionCreate set to true. This creates an interactive session for the daemon, allowing it to:

  • Access the login keychain
  • Interact with Xcode and code signing infrastructure
  • Handle interactive tasks that require GUI session context

This is related to the SessionCreate key. GitHub’s runner template already includes it at actions.runner.plist.template, so if you’re using the official template, you should be good.

/var/root/.zprofile: No such file or directory

If you see this when loading your zsh profile:

source ~/.zprofile
eval "$(rbenv init -)"

The launch daemon has loaded in the wrong context. Double-check that you’re using launchctl load when loading your plist:

sudo launchctl load /Library/LaunchDaemons/ec2-mac-runner.plist

Don’t use:

sudo launchctl bootstrap system /Library/LaunchDaemons/ec2-mac-runner.plist

Reference: What svc.sh Actually Generates

If you want to see what GitHub’s official svc.sh script generates, you can login to Session Manager, run

./svc.sh install`

and it will create a launch agent plist file at /Users/ec2-user/Library/LaunchAgents/ec2-mac-runner.plist.

Here’s what that file looks like:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
  <dict>
    <key>Label</key>
    <string>ec2-mac-runner</string>
    <key>ProgramArguments</key>
    <array>
      <string>/Users/ec2-user/actions-runner/runsvc.sh</string>
    </array>
    <key>UserName</key>
    <string>ec2-user</string>
    <key>WorkingDirectory</key>
    <string>/Users/ec2-user/actions-runner</string>
    <key>RunAtLoad</key>
    <true/>
    <key>StandardOutPath</key>
    <string>/Users/ec2-user/Library/Logs/ec2-mac-runner/stdout.log</string>
    <key>StandardErrorPath</key>
    <string>/Users/ec2-user/Library/Logs/ec2-mac-runner/stderr.log</string>
    <key>EnvironmentVariables</key>
    <dict>
      <key>ACTIONS_RUNNER_SVC</key>
      <string>1</string>
    </dict>
    <key>ProcessType</key>
    <string>Interactive</string>
    <key>SessionCreate</key>
    <true/>
  </dict>
</plist>

This is the official configuration from GitHub’s runner, and you can use it as a reference for your own setup.

Launch Agents vs Launch Daemons

Notice that svc.sh creates a file in /Users/ec2-user/Library/LaunchAgents/. This is a Launch Agent. Launch Agents are only invoked when the user logs into a graphical session. They run in the context of that user’s session and have access to the login keychain and other user-specific resources.

Launch Daemons, on the other hand, are stored in /Library/LaunchDaemons/ and are launched when the system boots, running outside of any specific user session. They start automatically at boot time, even before anyone logs in.

For our EC2 user data setup, we use a Launch Daemon (in /Library/LaunchDaemons/) because:

  • We need the runner to start automatically at boot time
  • User data scripts run as root during boot, before any user login
  • We don’t want to depend on a user logging in for the runner to start

The configuration is nearly identical—the main difference is where the plist file lives and when it gets triggered.

Using Organization-Level Runners

Set up runners at the organization level instead of per-repository. This way, all your iOS repos can use the same runners without duplicating setup. Just register at the org level, and it’s available everywhere.

Final Thoughts

Setting up self-hosted Mac runners requires careful attention to daemon configuration and keychain/session setup, but once it’s working, you’ll have a reliable build environment. The key is ensuring your launchd daemon has the right session context and permissions.

Written by

I’m open source contributor, writer, speaker and product maker.

Start the conversation