GitHub Actions has become a very useful part of our teaching workflow at the Department of Electronics, Telecommunications and Informatics (DETI) of the University of Aveiro. Students can automatically build software, run tests, create Docker images, perform static analysis, and experiment with CI/CD using the same tools they are likely to encounter professionally.
There is, however, a practical problem when GitHub Actions is used across many courses and student projects: compute minutes add up quickly.
For private repositories, GitHub accounts receive a monthly allowance of GitHub-hosted runner minutes depending on their plan, and usage above that allowance may be charged. By contrast, execution on self-hosted runners does not consume GitHub-hosted runner minutes; we provide and maintain the computing infrastructure ourselves.
For a university department that already operates servers for education and research, that makes self-hosted runners particularly attractive.
At DETI, the result is a small pool of Linux runners registered at organization level and made available to selected student repositories.
The basic architecture
The architecture is deliberately simple:
GitHub
|
detiuaveiro organization
|
GitHub Actions
|
+-------+--------+
| Runner Group |
| DETI runners |
+-------+--------+
|
+-----------+------------+
| | |
deti-srv3-1 deti-srv3-2 ... deti-srv3-4
| | |
+-----------+------------+
|
Docker host
Instead of every student workflow being executed on infrastructure provided by GitHub, GitHub schedules selected jobs on machines operated by DETI.
Organization-level runners are convenient in this scenario because a single pool can serve multiple repositories.
Why organization runners?
It would be possible to register one runner individually in every student repository, but this becomes difficult to manage when there are tens or hundreds of repositories.
Instead, the runners are registered with the GitHub organization.
GitHub runner groups can then control which repositories are allowed to use the infrastructure. This is useful for teaching because we can, for example, create a runner group for repositories associated with a particular course rather than exposing departmental infrastructure to every repository in the organization.
Running the GitHub runner inside Docker
Rather than installing the GitHub runner directly on the host, we use the excellent myoung34/docker-github-actions-runner project.
It provides a containerized GitHub Actions runner and takes care of registration, authentication, and runner configuration.
The project supports organization-level runners and authentication using a GitHub App instead of storing a personal access token (PAT).
Authentication with a GitHub App
A PAT typically belongs to an individual account. A GitHub App instead represents the infrastructure itself and can be installed directly in the organization.
Creating a GitHub App belonging to the organization and generating a private key for it is straightforward.
For registration of organization self-hosted runners, the important GitHub App organization permission is:
Self-hosted runners: Read and write
The credentials needed by the container are:
APP_ID=1234567
APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----"
These go into a .env file next to the Compose configuration.
Do not forget to protect it:
chmod 600 .env
The private key should never be committed to Git.
Four persistent runners
One issue appears when running several replicas of the same Docker Compose service.
Docker can easily do:
docker compose up --scale worker=4
but the containers receive dynamically generated identities. That is fine for disposable runners, but it does not work particularly well when we want to reuse runner registrations.
GitHub identifies each self-hosted runner individually, while the myoung34 image can persist its registration through:
CONFIGURED_ACTIONS_RUNNER_FILES_DIR: /runner/data
We therefore give every runner a permanent identity:
deti-srv3-1
deti-srv3-2
deti-srv3-3
deti-srv3-4
Each runner also receives its own persistent configuration directory and working directory.
Our Compose configuration therefore looks like this:
x-runner-common: &runner-common
image: ghcr.io/myoung34/docker-github-actions-runner:latest
restart: unless-stopped
environment: &runner-env
RUNNER_SCOPE: org
ORG_NAME: detiuaveiro
APP_ID: ${APP_ID}
APP_PRIVATE_KEY: ${APP_PRIVATE_KEY}
LABELS: linux,x64,deti
CONFIGURED_ACTIONS_RUNNER_FILES_DIR: /runner/data
DISABLE_AUTOMATIC_DEREGISTRATION: "true"
RUN_AS_ROOT: "false"
UNSET_CONFIG_VARS: "true"
DISABLE_AUTO_UPDATE: "true"
security_opt:
- label:disable
services:
worker-1:
<<: *runner-common
environment:
<<: *runner-env
RUNNER_NAME: deti-srv3-1
RUNNER_WORKDIR: /tmp/runner/work
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /runner/data/deti-srv3-1:/runner/data
- /tmp/runner/deti-srv3-1:/tmp/runner/work
worker-2:
<<: *runner-common
environment:
<<: *runner-env
RUNNER_NAME: deti-srv3-2
RUNNER_WORKDIR: /tmp/runner/work
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /runner/data/deti-srv3-2:/runner/data
- /tmp/runner/deti-srv3-2:/tmp/runner/work
worker-3:
<<: *runner-common
environment:
<<: *runner-env
RUNNER_NAME: deti-srv3-3
RUNNER_WORKDIR: /tmp/runner/work
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /runner/data/deti-srv3-3:/runner/data
- /tmp/runner/deti-srv3-3:/tmp/runner/work
worker-4:
<<: *runner-common
environment:
<<: *runner-env
RUNNER_NAME: deti-srv3-4
RUNNER_WORKDIR: /tmp/runner/work
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /runner/data/deti-srv3-4:/runner/data
- /tmp/runner/deti-srv3-4:/tmp/runner/work
This looks slightly more verbose than scale: 4, but it has an important operational advantage: runner identity is deterministic.
Using the DETI runner from a student project
The runners have the custom label:
deti
A student workflow can therefore contain:
name: Build and Test
on:
push:
pull_request:
jobs:
test:
runs-on:
- self-hosted
- linux
- x64
- deti
steps:
- uses: actions/checkout@v4
- name: Run tests
run: |
make test
Instead of being placed in GitHub’s hosted runner queue, this job is sent to one of our four DETI runners.
The student experience remains exactly the same: commits trigger GitHub Actions and results appear normally in the GitHub interface.
The infrastructure executing them is simply ours.
Controlling concurrency
Four runners also impose a natural concurrency limit.
Rather than allowing a class of students to generate an uncontrolled amount of cloud compute, the physical infrastructure itself establishes the available capacity.
Students may have to wait briefly during peak periods, but there is no surprise compute bill at the end of the month.
Security boundaries matter
Self-hosted runners execute workflow code on infrastructure that we operate, so the security model deserves explicit attention.
Runner groups are useful not only for organization but also for access control. They let us decide which repositories can use a particular runner pool and avoid exposing departmental infrastructure more broadly than necessary.
The runners should also be treated as machines that execute potentially untrusted commands. In practice, that means keeping secrets to a minimum, restricting repository access to the runner group, limiting what the runner host can reach on the network, and avoiding sensitive material on the host itself.
Mounting /var/run/docker.sock is operationally useful for courses that build containers, but it is also a powerful capability. It should be used with the same care as any other privileged access to a shared build host.
For teaching infrastructure, this is a deliberate trade-off: students get realistic CI/CD workflows, while the department keeps the runner pool small, isolated, and scoped to the intended repositories.
What this does, and does not, save
Moving workloads to self-hosted runners eliminates consumption of GitHub-hosted compute minutes for those jobs.
It does not mean that everything associated with GitHub Actions is necessarily unlimited or free.
Artifacts, caches, and other GitHub services may have their own quotas and billing rules.
And, of course, self-hosting is not actually zero-cost: DETI provides the servers, electricity, storage, network connectivity, and system administration.
The important difference is that these are resources we already operate and whose capacity we can directly control.
Why this makes sense in a university
There is also a pedagogical benefit beyond reducing GitHub Actions costs.
Students still learn industry-standard tooling:
Git
|
GitHub
|
GitHub Actions
|
CI/CD pipeline
|
Docker
|
build / test / deploy
But they also gain some awareness that the apparent abstraction of “the cloud” ultimately runs on real infrastructure.
The arrangement combines three useful properties:
Scalability. A small central runner pool can support many repositories and several courses.
Cost control. Student jobs no longer consume the organization’s GitHub-hosted runner minute allowance.
Realism. Students use the same GitHub Actions workflows they would use with commercial cloud runners.
For subjects involving software engineering, cloud computing, networks, IoT, and DevOps, that makes self-hosted GitHub Actions runners a particularly useful piece of teaching infrastructure.
At DETI, four modest Docker-based runners are enough to turn existing university compute capacity into a shared CI/CD service for students, while keeping GitHub Actions as the familiar interface that orchestrates everything.
