Run most of your builds locally
Self-hosted GitHub Actions runners for your Mac
GitHub charges $0.062/minute for their cheapest macos runners. A 20-minute build costs $1.24. Push twice a day and you're spending over $50/month on CI. Run the same jobs on your MacBook and they finish in less than half the time for $0.
Here's what some open source projects would save:
| Project | Builds/mo | Runners | p90 | Cost/mo |
|---|---|---|---|---|
| Alamofire | ~9 | macos-15 | 8m | $14 |
| mattermost-mobile | ~225 | macos-15-large | 25m | $321 |
| SwiftFormat | ~72 | macos-15 | 4m | $22 |
Pricing as of January 2026. Costs calculated from jobs via generate-benchmarks.sh.
Local builds are also faster. Based on XcodeBenchmark:
| Runner | Time |
|---|---|
| GitHub macos-latest M1 x3 ($0.06/m) | 838s |
| GitHub macos-15-large Intel x12 ($0.08/m) | 955s |
| GitHub macos-15-xlarge M2 Pro x5 ($0.10/m) | 339s |
| MacBook Air M2 x8 (2022) | 202s |
| MacBook Pro M4 Max x16 (2024) | 77s |
Features:
- Automatic fallback — workflows detect when your Mac is available; fall back to hosted runners when it's not
- One-click setup — no terminal commands, no manually generating registration tokens
- Lid-close protection — close your laptop without killing in-progress jobs
- Multi-runner parallelism — run 1-8 concurrent jobs
- Network isolation — runner traffic is proxied through an allowlist (GitHub, npm, PyPI, etc.)
- Filesystem sandboxing — runner processes can only write to their working directory
- Resource-aware scheduling — automatically pause runners when on battery or during video calls
localmost is a macOS app that manages GitHub's official actions-runner binary. It handles authentication, registration, runner process lifecycle, and automatic fallback — the tedious parts of self-hosted runners.
Security note: Running CI jobs on your local machine has inherent risks—especially for public repos that accept external contributions. localmost sandboxes runner processes and restricts network access, but these are not VM-level isolation. See SECURITY.md for details on the threat model and recommendations.
- Runner proxy — maintains long-poll sessions with GitHub's broker to receive job assignments
- Runner pool — 1-8 worker instances that execute jobs in sandboxed environments
- HTTP proxy — allowlist-based network isolation for runner traffic (GitHub, npm, PyPI, etc.)
- Build cache — persistent tool cache shared across job runs (Node.js, Python, etc.)
Add to your GitHub Actions workflow to automatically use localmost when available:
permissions:
actions: read
contents: read
jobs:
check:
uses: bfulton/localmost/.github/workflows/check.yaml@main
build:
needs: check
runs-on: ${{ needs.check.outputs.runner }}
steps:
- uses: actions/checkout@v4
# ... your stepsPrefer not to reference an external workflow? Copy the check inline:
jobs:
check:
runs-on: ubuntu-latest
outputs:
runner: ${{ steps.check.outputs.runner }}
steps:
- id: check
run: |
HEARTBEAT="${{ vars.LOCALMOST_HEARTBEAT }}"
if [ -n "$HEARTBEAT" ]; then
HEARTBEAT_TIME=$(date -d "$HEARTBEAT" +%s 2>/dev/null || echo "0")
AGE=$(($(date +%s) - HEARTBEAT_TIME))
if [ "$AGE" -lt 90 ]; then
echo "runner=self-hosted" >> $GITHUB_OUTPUT
exit 0
fi
fi
echo "runner=macos-latest" >> $GITHUB_OUTPUTThe check workflow uses a simple heartbeat mechanism:
- localmost automatically updates a
LOCALMOST_HEARTBEATvariable in your repo/org every 60 seconds - The workflow reads this variable and checks the timestamp
- If the timestamp is less than 90 seconds old → use
self-hosted - Otherwise → fall back to
macos-latest(or your configured fallback) - On clean exit, localmost immediately marks the heartbeat stale so workflows fall back without waiting
This fallback-to-cloud design is intentional: if your Mac is asleep, offline, or the heartbeat is stale for any reason, workflows continue running on GitHub-hosted runners rather than waiting or failing.
localmost uses a GitHub App for authentication. During installation, you'll be asked to grant the following permissions:
| Permission | Level | Purpose |
|---|---|---|
| Administration | Read & Write | Register and remove self-hosted runners on repositories |
| Actions | Read & Write | Check workflow status and cancel running jobs |
| Variables | Read & Write | Write the LOCALMOST_HEARTBEAT variable that workflows check |
| Contents | Read | Fetch the repository's .localmostrc sandbox policy |
| Metadata | Read | Access basic repository information (required by GitHub for all apps) |
| Self-hosted runners (org) | Read & Write | Register and remove self-hosted runners at the organization level |
| Variables (org) | Read & Write | Write the LOCALMOST_HEARTBEAT variable at the organization level |
GitHub's permission model requires Administration: Read & Write for managing self-hosted runners at the repository level. This is the same permission scope needed by the official actions/runner registration process.
While this permission could theoretically allow other administrative actions, localmost only uses it for:
- Generating runner registration tokens (
POST /repos/{owner}/{repo}/actions/runners/registration-token) - Removing runners when you stop them (
DELETE /repos/{owner}/{repo}/actions/runners/{runner_id})
localmost is open source — you can verify this by searching for actions/runners in the codebase.
For organization-level runners, the narrower Self-hosted runners: Read & Write permission is used instead of Administration.
Sandbox policies live in the repository, so localmost reads .localmostrc at the
commit a job is running, before deciding what that job is allowed to reach. GitHub
has no narrower scope for reading a single file, so this grants read access to
repository contents — but localmost requests it read-only and calls the contents
API from exactly one place, for exactly that file
(getFileContent).
Without it, a private repository's policy cannot be read at all: the API answers
Resource not accessible by integration, and localmost refuses the job and cancels
the run rather than falling back to a weaker sandbox.
During GitHub App installation, you choose which repositories to grant access to:
- All repositories - localmost can register runners for any repo in your account/org
- Only select repositories - limit access to specific repos you want to run locally
You can change this at any time in your GitHub settings under Applications > Installed GitHub Apps > localmost > Configure.
localmost uses OAuth device flow authentication.
- Your access token is never written to disk. It lives in memory, and a fresh one is obtained on each start
- Only the refresh token is stored, encrypted with macOS Keychain
- Access is scoped to the repositories you explicitly grant
- Revocable at any time from your GitHub settings
localmost includes a command-line interface for controlling the app from your terminal:
# Start/stop the app
localmost start
localmost stop
# Check runner status
localmost status
# Pause the runner (stops accepting new jobs)
localmost pause
# Resume the runner
localmost resume
# View recent job history
localmost jobs
# Manage the repos/orgs this machine runs jobs for
localmost targets # List targets
localmost targets add bfulton/voight-kampff # Register runners for a repo
localmost targets add my-org --org # Register runners for an org
localmost targets disable bfulton/supdb # Stop accepting its jobs
localmost targets remove bfulton/supdb # Unregister its runners
localmost targets list --json # Machine-readable outputAdding a target registers one runner per concurrent job slot with GitHub, and the
running app picks up the new target without a restart. Removing one unregisters
those runners; remove asks for confirmation unless you pass --yes, and refuses
to run unconfirmed outside a terminal. Every subcommand accepts --json.
From the app menu: localmost → Install Command Line Tool...
This creates a symlink in /usr/local/bin so you can use localmost from any terminal. You'll be prompted for your administrator password.
For development builds, use npm link instead.
The CLI communicates with the running app via a Unix socket. Most commands require the app to be running - use localmost start to launch it first.
Built with Electron + React/TypeScript. Requires Node.js 18+.
# Clone and install dependencies
git clone https://github.com/bfulton/localmost.git
cd localmost
npm install
# Start the app in development mode
npm start
# Run tests
npm test
# Build for macOS (creates .dmg)
npm run makeCurrent release: 0.3.0 — Test Locally, Secure by Default
- Run workflows locally before pushing with
localmost test - Declarative sandbox policies with
.localmostrc - Sandbox policy levels (strict / moderate / permissive) declared per repository and enforced by the local proxy
- Contributor-based job filtering for public repos
- Repository policies require approval before the runner applies them
- Opt-in container work through a filtering Docker socket declared per repo as
pull,runandbuildactions; anything unlisted is denied, and registry credentials never enter the sandbox - Environment comparison with GitHub runners
Future feature ideas:
- Fail a blocked job visibly - a job refused by the filter is cancelled through the GitHub API before any worker starts, so it appears as cancelled rather than failing with a message explaining why.
- Roll discovery output up further -
--updatercnow drops paths already covered by a listed ancestor, which removes the bulk of the redundancy. It still records content-addressed cache paths (npm's_cacache/content-v2/sha512/...) verbatim, which differ per machine and per dependency change; those want rolling up to their cache directory. - Approve policies in the app - approval is CLI-only today (
localmost policy diff,localmost policy approve). The app refuses the job and logs the diff, but there is no UI to review and accept it, and no audit log of approvals. - Show a diff when
--updatercrewrites a policy - it writes directly, with no diff and no confirmation, so a discovery run can widen a checked-in policy without the change being obvious. - Homebrew formula -
npx localmostworks;brew install localmostdoes not exist. - Quick actions - Re-run failed job, cancel all jobs.
- Spotlight integration - Check status or pause builds from Spotlight.
- Artifact inspector - Browse uploaded artifacts without leaving the app.
- Disk space monitoring - Warn or pause when disk is low, auto-clean old work dirs.
- Linux and Windows host support - Run self-hosted runners on non-Mac machines for projects that need them.
- Higher parallelism cap - Parallelize proxy registration to support 16+ concurrent runners (currently capped at 8 due to serial registration time).
- Managed Docker VM - Run the daemon behind the filtering socket in a VM whose only mount is the workspace, so a filter defect is contained, container egress is policed, and
privilegedbecomes grantable. - Ephemeral VM isolation - Run each job in a fresh lightweight VM for stronger isolation between jobs.
Bugs and quick improvements:
- Fix "build on unknown" race where jobs don't get links
