Installation
Before you start
Section titled “Before you start”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.
ss -ltnpGive 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.
1. Create the work directory
Section titled “1. Create the work directory”The path matters more than it looks — see the identical-paths rule.
sudo mkdir -p /srv/e2e-ci/_worksudo chown 1000:1000 /srv/e2e-ci/_work1000:1000 is the runner user inside the container.
2. Get a registration token
Section titled “2. Get a registration token”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.
3. Deploy the stack
Section titled “3. Deploy the stack”In Portainer: Stacks → Add stack → Repository.
| field | value |
|---|---|
| Repository URL | https://github.com/NoiXdev/portainer-github-runner |
| Compose path | docker-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.
4. Check it registered
Section titled “4. Check it registered”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.
5. Point the job at it, last
Section titled “5. Point the job at it, last”jobs: e2e: runs-on: [self-hosted, linux, x64, your-label]6. Tear down what your job leaves behind
Section titled “6. Tear down what your job leaves behind”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 || trueBoth 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.