Skip to content

Installation

Check what the job needs from the host. Ports above all: if your job serves anything — a dev environment, a test database behind a router, a preview server — whatever it binds has to be free here. A runner that cannot bind its port fails inside the job, minutes in, not at deploy time.

Terminal window
ss -ltnp

Give it room. Size the disk for your job, not for the runner: a browser-test job that pulls an application image, a database, a cache and a browser needs tens of gigabytes before it needs anything else. And cores matter if your test suite runs workers — fewer cores than the suite expects makes the job slower than the hosted runner you are replacing, which defeats the purpose.

Do not use a host that carries anything precious. Not production, not another project’s data. See Security.

The path matters more than it looks — see the identical-paths rule.

Terminal window
sudo mkdir -p /srv/e2e-ci/_work
sudo chown 1000:1000 /srv/e2e-ci/_work

1000:1000 is the runner user inside the container.

In the repository: Settings → Actions → Runners → New self-hosted runner. Copy the token.

It is valid for one hour and single-use. It registers the runner, which then holds its own credentials — so the token being short-lived costs you nothing after the first deploy.

In Portainer: Stacks → Add stack → Repository.

fieldvalue
Repository URLhttps://github.com/NoiXdev/portainer-github-runner
Compose pathdocker-compose.yml

Under Environment variables, switch to Advanced mode and paste the whole of stack.env.example from the repository, then fill the five required values. Configuration explains each one and what happens if you get it wrong.

Deploy.

The repository’s Settings → Actions → Runners should show your RUNNER_NAME as Idle, carrying your label.

If the deploy failed with a registration error, the token expired. Fetch a fresh one and set the variable again — nothing else is lost.

jobs:
e2e:
runs-on: [self-hosted, linux, x64, your-label]

Add this to the job itself, before and after. It is the step most workflows never needed, because a hosted runner is discarded after every job:

- name: Tear down anything a previous run left behind
run: docker compose -f your-stack.yml down -v --remove-orphans || true
# … the job …
- name: Tear down
if: always()
run: docker compose -f your-stack.yml down -v --remove-orphans || true

Both halves, and in that order. if: always() covers a failed job but not a cancelled one, so the pre-flight is what actually guarantees a clean start. How it works explains why this matters more than disk space.