skip to content
back to blog

Self-Hosted CI on Railway (and a MacBook Air)

blog

In a previous post, I described moving my personal projects into one repository with a shared CI process for automated checks and builds. Running all their check, test, and release jobs on GitHub-hosted runners used up my free Actions minutes well before the end of the month. iOS releases used the most. macOS runners count at a 10x multiplier1, so one 30-minute TestFlight build used the equivalent of five hours of Linux time.

I moved Linux jobs to a runner on Railway and iOS releases to the MacBook Air on my desk. I also moved the build caches, which were several gigabytes each, from GitHub to a Railway storage bucket after seeing the data-transfer bill. Changing runs-on was only the start of the migration.

Why Railway

One of my apps already had its backend on Railway, so I tried putting the runner there too. Railway's official guide and template2 run GitHub's runner agent in a container. The runner exits after each job and registers again for the next one. A separate service listens for workflow_job webhooks and adds runner copies when several jobs need to run at once. Starting fresh keeps state from leaking between jobs, but it also means caches must be restored each time.

Railway charges for the compute the runner uses, and an idle runner in this setup costs close to nothing. My personal projects have long idle periods followed by several pushes, so that fits better than paying by the CI minute.

Choosing runners with a repository variable

I wanted an easy way back to GitHub-hosted runners. Their images already have the build tools installed, and GitHub maintains the machines. The Linux workflows therefore read a repository variable:

jobs:
  test:
    runs-on: ${{ matrix.runner || vars.LINUX_RUNNER || 'ubuntu-latest' }}

When hosted minutes run low, gh variable set LINUX_RUNNER --body railway moves the Linux jobs to Railway. Deleting the variable moves them back. Projects that need GitHub's image can still set their own runner in the projects.json catalog from the previous post.

The iOS release job uses runs-on: [self-hosted, macOS, ARM64] with no hosted fallback. If the Mac is offline, the job waits until I open the lid. That is acceptable for my release schedule and avoids the macOS multiplier.

Filling in the missing tools

GitHub's ubuntu-latest image includes roughly 60GB of tools: Node, JDKs, Ruby, the Android SDK, OpenSSL headers, and a Docker daemon. A fresh Railway container has none of these. Many popular actions assume the hosted image, and I spent a week finding what my jobs had relied on.

Installing tools with mise

The setup-* actions failed first. ruby/setup-ruby provides prebuilt Rubies for GitHub-hosted images, with paths such as /Users/runner. Elsewhere, it failed with EACCES. I already used mise3 in the monorepo, so I used it for CI too and split the configuration into three files.

The committed root mise.toml lists tools shared by the projects: bun, rust, ktlint, and the pre-commit runner. The committed root mise.ci.toml adds the JDK, ruby, and node, which the runners lack. It loads only with MISE_ENV=ci, so local machines that already have those tools do not install them again. Railway's Railpack image builder, which reads mise.toml, also skips them. A gitignored mise.local.toml holds local conveniences such as the Railway CLI and cloudflared.

I learned why the separation matters by breaking builds. mise always loads *.local.toml, so committing that file would install my local tools in every CI run. Putting ruby in the server project's mise.toml made Railway compile it from source on every deploy. That failed because the Debian base image lacked libyaml. The server did not need ruby at all; the Actions runner did.

The root mise.lock and mise.ci.lock pin exact versions and download URLs for linux-x64 and macos-arm64. Jobs resolve the same tool versions on both platforms, and the caches use the lockfile hash. They no longer grow stale behind latest pins.

Build caches depend on their environment

Cargo's compiled output can contain absolute paths. Tauri's build script records the locations of generated permission files, but each Railway job starts in a random directory such as /_work/github-runner-<id>/.... Restoring a target/ cache from a different directory failed with failed to read plugin permissions: failed to read file <previous run's path>. On self-hosted runners, I now delete restored target directories before building. Only caches without those path dependencies, ~/.cargo/registry and ~/.cargo/git, carry over.

Compiled output also depends on system libraries. When Railway changed its runner image from Ubuntu focal to noble, a cargo cache built with OpenSSL 1.1 was restored onto OpenSSL 3. Linking failed because SSL_get_peer_certificate no longer existed. Cache keys now include the OS release, $ID$VERSION_ID from /etc/os-release, and runner.environment, so output is reused only in a matching runner environment.

Reducing build memory

The Android release passed on hosted runners but ran out of memory on Railway. Our gradle.properties allowed a -Xmx4096M Gradle heap and a 3GB Kotlin daemon, sized for development machines. The container killed the daemon during mergeDexRelease, leaving the message "Gradle build daemon disappeared unexpectedly."

Gradle gives properties in GRADLE_USER_HOME priority over the project's settings. The workflow now uses those properties to cap heap sizes and worker counts on self-hosted runners. Local builds keep their existing settings.

Building iOS on the MacBook Air

Code signing was the biggest problem on the Mac. Xcode cloud signing with the login keychain failed with errSecInternalComponent. The runner starts through launchd without a graphical session, but the keychain's access rules expect a permission prompt.

fastlane's setup_ci creates a temporary keychain on hosted runners. It also makes that keychain the machine's default and never restores the old default, which disrupts a Mac I use daily. My release action creates a temporary keychain and makes it the only user keychain in the search list. Leaving the login keychain visible lets Xcode choose its existing development identity and hit the same permission problem. The action uses -allowProvisioningUpdates to create a fresh signing certificate in the temporary keychain. An always() cleanup step restores the login keychain and deletes the temporary one after success, failure, or cancellation.

I ran into two other problems:

  • xcodebuild -version | head -1 crashes xcodebuild with NSFileHandleOperationException. After one line, head closes the pipe, causing SIGPIPE. awk 'NR==1' prints the first line but continues reading the stream.
  • The Mac's ~/.konan, which holds Kotlin/Native tools and libraries, exceeded 10GB and the entire per-repository GitHub cache quota. A post-job step spent roughly three hours packing it into an archive, then stalled when I closed the lid. These files already survive between runs on the Mac. The action now disables cache save and restore in mise-action, setup-gradle, and the konan step when runner.environment == 'self-hosted'.

Moving caches to Railway buckets

The Linux runners start fresh after each job, so they need a remote cache. Using actions/cache sent their uploads to GitHub, which Railway billed as outgoing traffic. Cargo registries, Gradle directories, and mise tools added up to several gigabytes, uploaded again whenever a cache key changed.

Railway buckets4 use Tigris and support the S3 interface. Traffic between a Railway service and a bucket is free. runs-on/cache5 can replace actions/cache: it uses an S3 bucket when RUNS_ON_S3_BUCKET_CACHE is set and GitHub's cache otherwise. The workflow chooses with one expression:

- uses: runs-on/cache@v5
  env:
    RUNS_ON_S3_BUCKET_CACHE: ${{ runner.environment == 'self-hosted' && secrets.RAILWAY_CACHE_BUCKET || '' }}
  with:
    path: ~/.local/share/mise
    key: ${{ matrix.project }}-mise-${{ runner.os }}-...

Self-hosted jobs use the Railway bucket. GitHub-hosted jobs use GitHub's cache, as do pull requests from forks, which receive no secrets and therefore get an empty bucket name. Both use the same workflow file. mise-action can only use GitHub's cache internally, so I disabled that cache and added an explicit runs-on/cache step for the mise data directory.

The cost and the work

The move saved money. I no longer pay the macOS multiplier, Linux CI runs on compute I was already paying for, and cache traffic is free. The Actions free tier covers the few jobs that still use hosted runners.

It also uncovered about a dozen failures. Most came from assumptions about GitHub's images: installed tools, stable working directories, disposable keychains, and free traffic to the cache. The Actions protocol guarantees none of those. Each fix was small, but finding it cost a failed release build.

mise and its lockfiles gave the runners one committed tool configuration. runner.environment let me apply the environment-specific fixes while keeping the Linux workflows compatible with hosted runners. If Railway lets me down, I can switch those jobs back with one repository variable. The iOS release still depends on the Mac.

Footnotes

  1. GitHub counts macOS runner minutes at 10x the Linux rate against the included Actions quota. See GitHub Actions billing. ↩

  2. GitHub Actions Self Hosted Runners on Railway. The template starts fresh runners for each job and adjusts their number using workflow_job webhooks. ↩

  3. mise manages language runtimes and development tasks. Its lockfiles pin exact tool versions and download URLs for each platform. ↩

  4. Railway storage buckets are S3-compatible storage backed by Tigris. ↩

  5. runs-on/cache is a fork of actions/cache that uses S3-compatible storage through RUNS_ON_S3_BUCKET_CACHE and falls back to GitHub's cache when the variable is unset. ↩

comments