Deploy a self-hosted GitHub Actions runner on Railway by pasting one token. Built on the official actions/actions-runner image — Railrunner adds only the ~40 lines of registration glue.
GitHub-hosted runners are fine until you want more minutes, a fixed IP, persistent caches, or a bigger box. Railrunner runs a runner as a Railway service: deploy, set two env vars, done. Ephemeral by default — a clean runner per job.
Two ways — pick one.
A. From this repo (Railway builds it). New Project → Deploy from GitHub repo → thevedus/railrunner. Railway builds the Dockerfile and redeploys on every push.
B. From the prebuilt image. New Project → Deploy a Docker image → ghcr.io/thevedus/railrunner:latest.
Then add the env vars below. The service needs no port — it's a worker.
Set either ACCESS_TOKEN (recommended) or RUNNER_TOKEN, plus a target.
| Variable | Required | Default | What |
|---|---|---|---|
REPO_URL |
repo runners | — | https://github.com/owner/repo |
RUNNER_SCOPE |
org runners | repo |
set to org |
ORG_NAME |
org runners | — | org login (with RUNNER_SCOPE=org) |
ACCESS_TOKEN |
✅ recommended | — | a PAT; mints a fresh registration token each start, so the runner survives restarts |
RUNNER_TOKEN |
alt | — | a raw registration token; expires ~1h, quick tests only |
RUNNER_NAME |
railrunner-<host> |
display name in GitHub | |
RUNNER_LABELS |
railway,railrunner |
comma-separated; target with runs-on: |
|
RUNNER_GROUP |
— | runner group (org) | |
EPHEMERAL |
true |
one job, then re-register | |
DISABLE_AUTO_UPDATE |
true |
pin the runner version |
PAT (recommended). Create a fine-grained token → Repository access = your repo → Permissions → Administration: Read and write (repo runners), or organization Self-hosted runners: Read and write (org runners). Set it as ACCESS_TOKEN.
Raw token (quick test). Repo or org → Settings → Actions → Runners → New self-hosted runner → copy the value after --token. Set it as RUNNER_TOKEN and deploy within the hour.
Target the runner from a workflow:
jobs:
build:
runs-on: [self-hosted, railrunner]Run a second tiny service that listens for GitHub workflow_job webhooks and adjusts how many runner replicas Railway runs — up the instant jobs are queued, back down (even to zero) when they finish.
GitHub Actions autoscaler (autoscaler/) Railway
workflow_job ──webhook──▶ active jobs ──clamp(n, MIN, MAX)──scale──▶ runner replicas
It's event-driven (no API polling, so no rate limits), job-accurate (counts individual workflow_jobs, filtered by label), and reacts in seconds. One org-level webhook covers every repo in the org — that's how you autoscale across many repos. Scaling goes through the railway CLI, which applies the change gracefully: new replicas start on the existing image, and drained ones finish their current job first.
This is a second service. It does not run any jobs — it only changes the replica count of your runner service (the one from Deploy above). Deploy the runner first.
RUNNER_SERVICE_IDmust be the runner's ID, never this autoscaler's own — pointing it at itself means no real runners exist, and (onceDRY_RUN=false) it would scale the autoscaler to zero and kill it.
-
In the same Railway project as your runner, add another service from this repo and set its Root Directory to
autoscaler. -
Give it a public URL: Service → Settings → Networking → Generate Domain → enter port
8080(what the autoscaler listens on), then copy the URL. (The server binds$PORT, default8080; pin it by also setting aPORT=8080variable so the listening port and the domain port can't drift.) -
Add the env vars below (full list in
autoscaler/.env.example) — including aGITHUB_WEBHOOK_SECRETyou choose and aRAILWAY_API_TOKEN(Account or Workspace → Settings → Tokens — not a project token). -
Add the webhook in GitHub — repo or org → Settings → Webhooks → Add webhook:
- Payload URL: your Railway domain (e.g.
https://xxx.up.railway.app/) - Content type:
application/json - Secret: the same
GITHUB_WEBHOOK_SECRET - Events: "Let me select individual events" → Workflow jobs only
Railway's "Suggested Variables" can't auto-detect every variable — add
GITHUB_WEBHOOK_SECRETandRUNNER_SERVICE_IDyourself. Put an account/workspace token inRAILWAY_API_TOKEN(a project token inRAILWAY_TOKENreturns "Unauthorized" when scaling); set only one.RAILWAY_CLI_VERSIONis build-time only. - Payload URL: your Railway domain (e.g.
| Variable | Default | What |
|---|---|---|
GITHUB_WEBHOOK_SECRET |
— | shared secret; must match the webhook's Secret |
RAILWAY_API_TOKEN |
— | a Railway account or workspace token; a project token returns "Unauthorized" when scaling |
RUNNER_SERVICE_ID |
— | the runner service's ID (Service → Settings) |
RUNNER_REGION |
us-west |
region your runner runs in (us-west, us-east, eu-west, southeast-asia) |
RUNNER_LABELS |
railrunner |
only count jobs whose runs-on includes these labels |
MIN_RUNNERS |
1 |
floor — set 0 to scale to zero when idle |
MAX_RUNNERS |
5 |
ceiling (safety cap) |
JOB_TTL_SECONDS |
3600 |
drop a job we never saw finish (covers a missed event); raise above your longest job |
DRY_RUN |
false |
log decisions without scaling — flip on first to watch it think |
Tip: deploy with DRY_RUN=true, push a job, and watch the logs (queued job … -> active=1) before turning it off.
RUNNER_SERVICE_ID— open the runner service and copy the UUID from its URL:railway.com/project/…/service/<this-id>. Not the autoscaler's own ID — Railway already injects that asRAILWAY_SERVICE_ID, which is exactly why this one is namedRUNNER_SERVICE_ID.RUNNER_REGION— runner service → Settings → Regions.RAILWAY_API_TOKEN— an account token (Account → Settings → Tokens) or workspace token (Workspace → Settings → Tokens). A project token (RAILWAY_TOKEN) is deploy-scoped and returns "Unauthorized" when scaling. It's a secret — don't commit or share it.
- Job-accurate and label-filtered — each
workflow_jobis counted by itsid, and only if its labels include every entry inRUNNER_LABELS— so it ignores GitHub-hosted jobs and counts matrix jobs individually. - No polling, no GitHub token — GitHub pushes the events, so there's no API rate limit and one org webhook scales the whole org. The only credentials it needs are a Railway account/workspace token (to scale) and the webhook secret (to trust GitHub).
- In-memory state, self-healing — the active-job set lives in memory; a missed
completedevent is cleaned up byJOB_TTL_SECONDS, and a restart briefly drops toMIN_RUNNERSthen rebuilds as new events arrive (Railway's graceful drain means running jobs are never killed). - Single region — it scales
RUNNER_REGIONonly. - Bursts (a big matrix) are coalesced into one scale call; the whole server is ~120 lines in
autoscaler/autoscale.ts.
- No Docker-in-Docker on Railway. Railway containers aren't privileged and there's no host Docker socket, so jobs that run
docker buildor usecontainer:/ service containers won't work. Plain build / test / lint / deploy jobs are fine. - Never point a self-hosted runner at a public repo. Anyone who opens a PR can run arbitrary code on it and exfiltrate your
ACCESS_TOKEN. Use private repos, and require approval for outside-collaborator PRs (Settings → Actions → Fork pull request workflows). - Ephemeral runners (default) auto-deregister after each job; Railway's
ALWAYSrestart policy (set inrailway.json) brings up a fresh one for the next job.
Railway blocks privileged containers and the Docker daemon, so normal docker build / docker/setup-buildx-action jobs fail on these runners. The railrunner-builder image (Dockerfile.builder) adds Buildah, which builds images without a daemon — set up for the most restricted case (rootless, vfs storage, chroot isolation).
⚠️ Whether even this works depends on Railway allowing unprivileged user namespaces, which it may block. Confirm with the one-step probe below before converting real jobs.
Point your runner at the builder image (deploy ghcr.io/thevedus/railrunner-builder, or build Dockerfile.builder), then:
1. Probe that builds work at all:
probe:
runs-on: [self-hosted, railrunner]
steps:
- run: |
printf 'FROM alpine\nRUN echo ok > /ok\n' > Dockerfile.probe
buildah bud -f Dockerfile.probe -t probe:local .
echo "BUILDAH WORKS ON RAILWAY ✅"If it fails with a namespace / newuidmap / permission error, Railway's sandbox doesn't allow it — offload the build instead (remote BuildKit on a cheap VPS, Depot, or ubuntu-latest for the docker jobs).
2. If the probe passes, replace docker build with Buildah (a drop-in):
- name: Build & push
env:
IMAGE: ghcr.io/${{ github.repository_owner }}/your-image:${{ github.sha }}
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | buildah login -u "${{ github.actor }}" --password-stdin ghcr.io
buildah bud -t "$IMAGE" -f Dockerfile .
buildah push "$IMAGE"STORAGE_DRIVER=vfs and BUILDAH_ISOLATION=chroot are baked into the image, so plain buildah bud uses them.
docker run --rm \
-e REPO_URL=https://github.com/owner/repo \
-e ACCESS_TOKEN=github_pat_... \
ghcr.io/thevedus/railrunnerCI (.github/workflows/build.yml) pushes ghcr.io/thevedus/railrunner on every push to main. To let others deploy it:
- Make the GHCR package public: org → Packages →
railrunner→ Package settings → Change visibility → Public. - (Optional) Publish a Railway template from your dashboard (Account → Templates) pointing at this repo or image and exposing the env vars above, then swap the Deploy on Railway button URL for your template link.
MIT — see LICENSE.