GitHub Actions Self Hosted Runners

screenshot of a deployment of a self hosted GitHub Actions runner on Railway

Deploying GitHub Actions Self Hosted Runners on Railway is an excellent way to run your own CI infrastructure because you only pay for what you use. With self-hosted runners, you also unlock the ability to cache expensive and time-consuming dependencies (node_modules, cargo, etc.) or large git repositories. Best of all, Railway's built-in replicas means you can scale your runners horizontally, or even distribute them to different regions with just a click and redeploy. You'll save build times and costs over using standard runners, AND you'll unlock more sophistocated workflows to streamline building your app.

In this guide you'll learn:

  1. The basics to deploy a GitHub Actions Self Hosted Runner on Railway.
  2. How to authenticate self-hosted runners on Railway with your GitHub Organization or Enterprise.
  3. How to scale up replicas to serve bigger Actions workloads.
  4. Best Practices for configuring your self-hosted runners on Railway.

Quickstart: Deploy your self-hosted Runners with our Railway template.

Deploy a GitHub self-hosted runner on Railway

  1. Navigate to the GitHub Actions self-hosted Runner Template. You'll notice the template requires an ACCESS_TOKEN. This token, along with our RUNNER_SCOPE will determine where our self-hosted runners get registered on GitHub. Thankfully, this template supports self registration of your runners -- which means you can dynamically scale up or down the number of runners you have just by adjusting your replicas!

  2. Set your RUNNER_SCOPE to org. We want to set up our self-hosted runners to register with a GitHub Organization, so any repositories within our organization can use the same pool of runners. This is super useful because you don't have to set up permissions for every single repository!

If you have a GitHub Enterprise, you can similarly set up your runners using an ACCESS_TOKEN, you just need to set your RUNNER_SCOPE as ent instead.

If you need additional configuration, then you can simply add a variable to your Service.

Setup a GitHub ACCESS_TOKEN

For this guide, we will create a new GitHub Fine-Grained Personal Access Token. These are modern personal access tokens that obey the principle of least priviledge, making them easy to secure, revoke, and audit!

Note: You need to have Admin access to the organization for which you are making the ACCESS_TOKEN.

screenshot of a GitHub Fine Grained Access Token
  1. Create a new fine-grained personal access token. Navigate to your Settings -> Developer Settings -> Personal Access Token -> Fine-grained tokens -> Generate New Token
  2. Set the Resource owner as your Organization. Alternatively, if you are using a ent RUNNER_SCOPE, select your Enterprise.
  3. Set Expiration
  4. Under Permissions, Select Organization Permissions -> Self Hosted Runners -> Read and Write (If Enterprise, select Enterprise instead).
  5. Click Generate. Save your ACCESS_TOKEN in a safe place! You won't see it again. (Save it in a Password Vault as an API Key!)
  6. DONE. You don't need any other permissions!

Scaling up your Railway Self Hosted Runners

screenshot of scaling up your Railway self hosted Runners with Railway Replicas
  1. Navigate to the Settings tab of your Service to the Region area.
  2. Change the number next to your region from 1 to your desired number of replicas.
  3. Click Deploy.
  4. Done! Your new replicas will automatically spin up and register themselves with GitHub.

View Your Registered Self Hosted Runners

screenshot of viewing your Registered self hosted runners

You can view all your runners by navigating to your organization's Actions -> Runners page at https://github.com/organizations/(your-organization-name)/settings/actions/runners?page=1

Routing Actions Jobs

You can route jobs by simply changing the LABELS variable. By default, we include the railway label on runners you make through the Template. LABELS is a comma (no spaces) delimited list of all the labels you want to appear on that runner. This enables you to route jobs with the specificity that your workflows need, while still allowing you to make runners available for your entire Organization.

Setting up GitHub Actions workflows for Pull Requests

GitHub Actions uses workflow files located in .github/workflows/<workflow>.yml. You can easily incorporate pre-built steps to get up-and-running quickly.

  • When you want to run a workflow every time a pull request is opened, set the on key to pull_request in your .github/workflows/<workflow>.yml.

  • Set the runs-on key when you want to route your workflow job to a particular runner. Use a comma delimited list for greater specificity. For example, a [self-hosted, linux, x86, railway] workflow needs to match all labels to an appropriate runner in order to route the job correctly.

Example GitHub Actions Workflow

If you've never made a workflow before, here is a basic out-of-the-box example of a NuxtJS project using Bun to execute an eslint check.

name: eslint check

on:
  pull_request:
    branches:
      - main

permissions:
  contents: read

jobs:
  build:
    name: Check
    runs-on: [self-hosted, railway]

    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          token: ${{ secrets.GITHUB_TOKEN }}

      - name: Install bun
        uses: oven-sh/setup-bun@v2

      - name: Set up Node
        uses: actions/setup-node@v4
        with:
          node-version: 22

      - name: Cache Files
        uses: actions/cache@v4
        with:
          path: |
            ~/.bun/install/cache
            path: ${{ github.workspace }}/**/node_modules
            path: ${{ github.workspace }}/**/.nuxt
          key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock', 'nuxt.config.ts', 'app.config.ts', 'app.vue') }}

      - name: Install packages
        run: bun install --prefer-offline

      - name: Lint
        run: bun run lint

Best Practices

  1. Only use private repositories and disable forks: Make sure when using self-hosted runners, that you only attach them to private repositories. A known attack vector is for a malicious actor to fork a public repository and then exfiltrate your private keys from your self-hosted runners by executing workflows on them. Disabling forks can also mitigate this attack, and it's a good idea in general for locking down security on your repositories!

  2. Seal your ACCESS_KEY: While all variables are encrypted on Railway, you can prevent prying eyes (including your future self) from ever viewing your API Key. Navigate to the Variables tab and next to the ACCESS_KEY variable click the three-dots-menu ... -> Seal. Make sure your ACCESS_KEY is stored in a secure Password Vault before doing this!

  3. Security Harden your self-hosted Runners: Security Hardening will make your runners robust and prevent any concerns about your build infrastructure. GitHub's detailed guide can help you secure secrets, authentication, auditing, and managing your runners. Similarly dduzgun-security has an excellent guide to securing your runners that's worth your time.

Known Limitations

  • Because Railway containers are non-priveleged, GitHub Workflows that build-and-then-mount containers on the same host (i.e. Docker-in-Docker) will fail.

  • Using the Serverless Setting on this Service is not recommended and will result in idle runners disconnecting from GitHub and needing to reauthenticate. GitHub Runners have a 50 second HTTP longpoll which keeps them alive. While the runners in this template can automatically reauth with an ACCESS_TOKEN it will result in unnecessary offline / abandoned runners. If you want your runners to deauthenticate and spin down, consider using ephemeral runners instead.

Troubleshooting self-hosted runner communication

A self-hosted runner connects to GitHub to receive job assignments and to download new versions of the runner application. The self-hosted runner uses an HTTPS long poll that opens a connection to GitHub for 50 seconds, and if no response is received, it then times out and creates a new long poll. The application must be running on the machine to accept and run GitHub Actions jobs.

GitHub's documentation details all of the different endpoints your self-hosted runner needs to communicate with. If you are operating in a GitHub Allow List environment you must add your self-hosted runners IP Address to this allow list for communication to work.

If you are using a proxy server, refer to GitHub's documentation on configuring your self-hosted runner. You can simply add the required environment variables by adding them to the Variables tab of your Service.

Cost Comparison

On Railway you only pay for what you use, so you'll find your GitHub workflows are significantly cheaper. For this guide we tested over ~2,300 1 minute builds on Railway self-hosted runners and our usage costs were $1.80 compared to GitHub's Estimated Hosted Runner cost of $18.40 for the same workload. Even better? We had 10x Railway replicas with 32 vCPU and 32GB RAM for this test, meaning that our actions workflows would never slow down.

On other platforms you pay for the maximum available vCPUs and Memory. On Railway, you're only paying for usage, or in the below screenshot, the filled in purple area. This enables your workloads to still burst up to the maximum available resources you have configured, with no tradeoffs on cost.

screenshot of Railway's Observability dashboard demonstrating burstable usage of Memory

Edit this file on GitHub