Autoscaling, ephemeral self-hosted GitHub Actions runners on Railway: one short-lived Railway service per job. A small controller watches GitHub for queued jobs with your label, registers a one-job (JIT) runner for each, starts it as its own Railway service, and deletes the service when the job is over. Nothing is shared between two jobs.
GitHub App webhook (workflow_job) --> controller --> Railway API
| ^ |
poll queued jobs/runners | | v
v | runner service (one per job)
GitHub run.sh --jitconfig -> one job -> exit
It is meant for private repositories of one owner or team. Read Limits and SECURITY.md before you point it at anything else.
- One service per job. A fresh container per job, so nothing a job leaves behind (processes, files, caches) reaches the next one.
- Safe scale-down. The controller deletes exactly the service it picked, and only after GitHub agreed the runner is idle (GitHub refuses to delete a busy runner). Services never restart.
- Org and personal repositories.
org:<name>scopes get org runners in a dedicated runner group;repo:<owner>/<name>scopes get repository-scoped runners, which is what personal accounts need. - Webhook plus poll. The webhook only wakes the controller; every
POLL_SECONDSit reads GitHub and Railway afresh and reconciles: create a runner for every queued job no runner is coming for, remove finished, idle-surplus or overdue services.
| Path | What |
|---|---|
cmd/controller |
the controller binary (HTTP: /webhook, /healthz) |
internal/reconcile |
the pure planner: what to create and what to remove |
internal/ghapp |
GitHub App auth, queued jobs, runners, JIT configs (standard library only) |
internal/railway |
create, list and delete runner services |
internal/webhook |
HMAC-verified workflow_job receiver |
image/ |
the runner image (base) and its Postgres + Playwright variant (pg) |
You need a GitHub organization or account you administer, a Railway account (a plan with enough CPU and memory for your jobs; usage is billed), and the repositories you want to serve.
- Controller project: one service, the controller. It holds the GitHub App key and the Railway token.
- Runner project: nothing but the runner services the controller creates and deletes.
They are separate because services in one Railway environment share a private network: a job must not be able to reach the controller. In the runner project create a project token (Project Settings, Tokens) and note the project id and environment id.
Create it by hand with exactly the settings below, or from the manifest in
docs/github-app-manifest.json with GitHub's
app manifest flow:
- Edit the manifest: a unique
name, your controller's webhook URL inhook_attributes.url, and aredirect_urlyou control. If you serve onlyrepo:scopes, deleteorganization_self_hosted_runners. - Submit it as the
manifestfield of an HTML form POST tohttps://github.com/settings/apps/new(personal account) orhttps://github.com/organizations/<org>/settings/apps/new, and confirm on GitHub. - GitHub redirects to
redirect_url?code=<code>. Within an hour, exchange the code:curl -X POST https://api.github.com/app-manifests/<code>/conversions. The answer holds the Appid, the private key (pem) and thewebhook_secret: put them straight into the controller's variables and do not keep them anywhere else.
The settings:
- Repository permissions: Administration: Read and write (to create and delete repository runners), Actions: Read, Metadata: Read.
- Organization permissions: Self-hosted runners: Read and write (only for
org:scopes; leave it out if you serve onlyrepo:scopes). - Subscribe to the Workflow job event. Webhook URL:
https://<controller domain>/webhook, with a webhook secret you generate.
Generate a private key, then install the App on the repositories (or organization) you serve. Administration write is broad: install it on the repositories you serve when you can.
For an org: scope, create a dedicated runner group (Settings, Actions, Runner groups) limited to
the repositories you serve, with "Allow public repositories" off, and note its id.
In the controller project, add a service from the image
ghcr.io/farzai/github-runner-railway-controller:<version> (see Versions), give it a
public domain, port 8080, healthcheck path /healthz, and the variables below. Put the domain into
the App's webhook URL.
GITHUB_APP_ID=123456
GITHUB_APP_PRIVATE_KEY=-----BEGIN RSA PRIVATE KEY-----\n...
GITHUB_WEBHOOK_SECRET=<the App's webhook secret>
RUNNER_RAILWAY_TOKEN=<runner project token>
RUNNER_RAILWAY_PROJECT_ID=<runner project id>
RUNNER_RAILWAY_ENVIRONMENT_ID=<runner environment id>
RUNNER_IMAGE=ghcr.io/farzai/github-runner-railway:<version>
RUNNER_SCOPES=repo:acme/app,repo:acme/api
RUNNER_LABEL=railway
MAX_RUNNERS=2
/healthz answers 200 once a cycle has completed, and 503 when the last cycle is older than three polls or ALERT_FAILED_CYCLES cycles in a row have failed. Its JSON: last_cycle, last_success, consecutive_failures, unreadable_scopes, services, queued, error. github_rate_limits shows the requests GitHub last reported left per installation account (a warning is logged once per window below a tenth). The log line controller up shows the scopes,
label, prefix, image and limits it runs with.
jobs:
test:
runs-on: [self-hosted, railway]Always name your label: the runners also carry self-hosted, linux and x64, and a job that asks
for a label the runners do not have is not served. If you lint workflows with actionlint, declare the
label in .github/actionlint.yaml:
self-hosted-runner:
labels:
- railwayStart with one repository and MAX_RUNNERS=1, watch a job go through, then add scopes and raise the
limits. docs/deploy.md has the full guide, rollout and rollback.
| Variable | Meaning | Default |
|---|---|---|
GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, GITHUB_WEBHOOK_SECRET |
the App (the PEM may keep \n escaped) |
required |
RUNNER_RAILWAY_TOKEN, RUNNER_RAILWAY_PROJECT_ID, RUNNER_RAILWAY_ENVIRONMENT_ID |
a project token, project and environment of the runner project (not RAILWAY_*: Railway injects those for the controller's own project, and the controller refuses to manage the project it runs in) |
required |
RUNNER_IMAGE |
the runner image, pinned to a version or digest | required |
RUNNER_SCOPES |
comma list of org:<name> and repo:<owner>/<name> that may get runners |
required |
RUNNER_LABEL |
the runs-on label of this controller's runners |
railway |
RUNNER_SERVICE_PREFIX |
Railway service-name prefix of this controller's runners; a second controller in the same runner project needs its own (a letter, a letter or digit, a dash, e.g. rp-) |
rr- |
RUNNER_REGION |
Railway region id for this controller's runners, e.g. us-east4-eqdc4a; the API's regions query lists them. Unset leaves placement to Railway (the workspace's preferred region) |
unset |
RUNNER_GROUP_ID |
the dedicated runner group of org runners; required for an org: scope, and the Default group (1) is refused |
none |
MAX_RUNNERS / MAX_CREATE_PER_CYCLE |
services in all / created in one cycle | 8 / 4 |
POLL_SECONDS |
polling period (a queued-job webhook wakes a cycle sooner: after a 3 s debounce, and at least 20 s after the previous cycle started) | 120 |
STARTUP_GRACE_SECONDS |
how long a registered runner may stay offline before its service is removed | 300 |
IDLE_TTL_SECONDS |
how long a surplus idle runner is kept | 120 |
MAX_JOB_MINUTES |
job timeout; a service older than this plus 10 minutes is removed | 360 |
ALERT_QUEUED_MINUTES / ALERT_WEBHOOK_URL |
log an alert (and POST {"text": ...} to the URL) when a job waits this long with no runner coming |
5 / none |
ALERT_FAILED_CYCLES |
after this many failed cycles in a row: one alert (naming scopes unreadable that long), /healthz 503, and one "recovered" alert when a cycle succeeds again |
3 |
PORT |
HTTP port | 8080 |
| Image | Contents |
|---|---|
ghcr.io/farzai/github-runner-railway:<version> |
the official actions-runner (Ubuntu 24.04) plus git, curl, jq, python3, build-essential, shellcheck, gh; no sudo, no Docker |
ghcr.io/farzai/github-runner-railway:pg-<version> |
the above plus PostgreSQL 18, Playwright's Chromium/WebKit system libraries and browsers (see image/Dockerfile for the pinned versions), and ci-postgres |
ghcr.io/farzai/github-runner-railway:sudo-<version> |
the base image plus passwordless sudo for the runner user (opt-in, see below) |
ghcr.io/farzai/github-runner-railway:php-<version> |
the base image plus PHP 8.3, 8.4 and 8.5 (ondrej/php PPA) and Composer, and use-php <version> to pick one; for PHP projects, since setup-php cannot run without sudo |
ghcr.io/farzai/github-runner-railway:redis-<version> |
the base image plus a pinned redis-server and ci-redis |
To serve both, run a second controller with RUNNER_LABEL=railway-pg, RUNNER_SERVICE_PREFIX=rp-
and the pg- image, in the same runner project. Each controller lists and removes only its own
services and only takes jobs that ask for its label. A GitHub App has one webhook URL, so the second
controller works on polling alone (set POLL_SECONDS=60).
The published pg- image has PostgreSQL 18 and no extensions. Build your own with another major and
PGDG extensions (all pinned, from the signed PGDG repository) and push it to your registry:
docker build --platform linux/amd64 --target pg \
--build-arg PG_MAJOR=16 --build-arg PG_VERSION=16.15-1.pgdg24.04+2 \
--build-arg "PG_EXTENSIONS=postgresql-16-partman=5.5.0-1.pgdg24.04+1" \
-t ghcr.io/<you>/runner-pg16:1 image/
PG_MAJOR (16, 17 or 18), PG_VERSION (that major's exact PGDG package version), and
PG_EXTENSIONS (space-separated postgresql-<major>-<name>=<version>; anything else fails the build).
Find versions with apt-cache madison against PGDG, or in its package index. image/smoke-pg.sh <major> [extension ...] checks a built image (CI runs it for 18 and for 16 with pg_partman).
In a job on the pg image, start a throwaway database inside the job's own container:
runs-on: [self-hosted, railway-pg]
steps:
- run: ci-postgres start app secret app_test # user, password, database
- run: npm test # CI_POSTGRES_URL is set from here onci-postgres runs initdb as the runner user, listens on 127.0.0.1 only on a free port with fsync
off, masks the password, and writes CI_POSTGRES_URL, CI_POSTGRES_PORT and CI_POSTGRES_DATA to
$GITHUB_ENV. The server ends with the container.
In a job on the redis- image (or a pg image built with --build-arg REDIS_VERSION=<version>, for
both), start a throwaway Redis the same way as Postgres:
steps:
- run: ci-redis start # optional port; CI_REDIS_URL is set from the next step on
- run: npm testci-redis runs redis-server as the runner user on 127.0.0.1 only (a free port unless one is given),
without persistence, waits until it answers PING, and writes CI_REDIS_URL, CI_REDIS_PORT and
CI_REDIS_DIR to $GITHUB_ENV; ci-redis stop shuts it down. Redis comes from packages.redis.io,
with the signing key pinned by sha256 and the version pinned in image/Dockerfile.
Build a derived image FROM ghcr.io/farzai/github-runner-railway:<version> and add what your jobs
need at build time, so they need no sudo. examples/derived/Dockerfile
pins its base by digest and adds a Postgres client of a chosen major, libcurl headers, a prebuilt shared library registered with
ldconfig, a CLI pinned by checksum, Node 22 with pnpm and bun, and Playwright browsers with their
system libraries; CI builds it on every change. Switch to root for the installs and end with
USER runner and WORKDIR /home/runner; keep the base's entrypoint (it reads
ACTIONS_RUNNER_INPUT_JITCONFIG, runs one job and exits). Push it to your registry and set
RUNNER_IMAGE to it.
The default images have no sudo, and an action that shells out to sudo does not fail: it waits.
| Action | Default image | Why |
|---|---|---|
actions/setup-node |
works (verified on the pool) | downloads Node into the runner's tool cache, no root |
oven-sh/setup-bun |
works (verified on the pool) | downloads one binary into the home directory |
shivammathur/setup-php |
hangs (reproduced, see below) | runs sudo for apt, ln and tee on a self-hosted Linux runner |
other setup-* actions |
not verified here | test on the pool with a step timeout-minutes first; any that call sudo will hang the same way |
setup-php prints sudo: a password is required, then never returns; the job runs until its
timeout, which is how two PHP repositories each left four jobs running for 40 minutes. Put
timeout-minutes on the step and the job so a stuck action costs minutes, not an hour. For PHP
use the php-<version> image (target php in image/Dockerfile): PHP 8.3, 8.4 and 8.5
and Composer baked in, and use-php <version> to pick one with no root and no setup-php step (plain php is 8.5, the newest installed). The opt-in sudo
variant below is the other route (setup-php can then run its apt steps); it is untested with
setup-php here and gives the job root in its container.
For workflows you cannot change yet (sudo apt-get install, playwright install --with-deps,
ldconfig in a step), the sudo-<version> image gives the runner user passwordless sudo. Each job
is its own container, so root stays inside that job's container and is gone when it ends, but the
job can now change anything in it, and the container still shares the runner project's private
network with concurrent jobs. Use it for private repositories only, in its own pool (a controller
with its own label and RUNNER_SERVICE_PREFIX), and prefer a derived image where you can. The
default images stay sudo-free.
- Not for public repositories. Runner services share a Railway private network, so a job from a public repository (anyone can open a pull request) could reach a concurrent private job. Serve private repositories only, and require approval for fork pull request workflows (SECURITY.md).
- No Docker (a Railway container has no daemon): no
docker build,docker://actions, jobcontainer:orservices:. Use thepgimage or a hosted runner for those. - No sudo in the default images, so
sudo apt-get installfails: usesetup-*actions, a derived image, or the opt-in sudo variant. - CPU and processes: each container is capped by your Railway plan (for example 24 vCPU, 24 GB and
about 1,000 processes and threads).
nprocshows the host's cores, not the quota; Node'sos.availableParallelism()reads the quota. Pass explicit-j/worker counts to be safe. - Railway API: 1,000 requests an hour per token. A job costs about four calls and a cycle one, so roughly 230 jobs an hour at the default poll; two controllers sharing one token share that budget.
- GitHub API: 5,000 requests an hour per installation, shared by every scope of that installation
(one access token per installation). Run and job lists are read with
If-None-Match, and an unchanged list (304) does not count against the limit. Preferrepo:scopes for large organizations. - Cold start is the Railway deploy plus the image pull: about 20 s when the host has the image,
longer on a host's first pull of a large image (see
docs/spike.md).
Railway bills the CPU, memory and network the runner services actually use, plus a little for the
controller, which idles most of the time. A job costs roughly its duration times the CPU and memory it
uses; check your plan's prices. Keep MAX_RUNNERS and MAX_JOB_MINUTES as the real cap on spend.
Railway's usage limits are set per workspace, not per project, and reaching the hard limit takes
every workload in the workspace offline (Railway docs, usage limits, read 2026-10-17). Set a soft
limit (an email alert) for the runner fleet, and a hard limit only if the workspace holds nothing
else you need running.
Releases are tagged vX.Y.Z (see CHANGELOG.md) and publish:
ghcr.io/farzai/github-runner-railway:vX.Y.Z,:pg-vX.Y.Z,:php-vX.Y.Z,:redis-vX.Y.Zand:sudo-vX.Y.Zghcr.io/farzai/github-runner-railway-controller:vX.Y.Z
Commits on main also publish images under the commit sha, but only for the images they change (docs/deploy.md says which sha is current). There is no latest:
pin a version (or a digest, image@sha256:...) so a runner never changes under you.
| Symptom | Look at |
|---|---|
| The controller exits at start | its log names the missing or malformed variable (never its value) |
/healthz 503 |
no cycle has completed yet, the last one is older than three polls, or cycles keep failing (consecutive_failures, unreadable_scopes); see the cycle failed log lines |
/healthz "error": true |
the last cycle failed; the cycle failed log line says why (often a GitHub or Railway API error, which the next cycle retries) |
| A job stays queued | the job's labels must all be among self-hosted, linux, x64 and your label; its repository must be in RUNNER_SCOPES and the App installed on it; MAX_RUNNERS may be reached |
| How often cycles run, and what they cost | every cycle logs one cycle line: duration, github_calls, railway_calls, created, reaped, failed |
| Runner services pile up | each should go about a minute after its job ends ("runner gone" in the log); check the Railway token can delete services |
docker: command not found |
expected: see Limits |
See SECURITY.md.
go vet ./... && go test -race ./...
docker build --platform linux/amd64 -t runner image/
docker build --platform linux/amd64 --target pg -t runner-pg image/
Contributions: CONTRIBUTING.md. License: LICENSE.