I run my GitHub Actions jobs on my own laptop to save GitHub-hosted minutes. In the first two parts of this series I made that fast, and then made it stop resetting the laptop. This part is about the next step: the laptop is no longer enough on its own, so I added runners in Azure Container Apps (ACA) in my Azure subscription, the one with the monthly credit that comes with Visual Studio Professional. It works, but it is not free: that credit is shared with all my other services, so every ACA minute is a minute I cannot spend on them. Here is why I did it, how it works and which jobs run where. What broke on the way, and what I had to put in the image, is the next part.
Where I was
After part 2 the laptop still reset itself now and then. Less often than at the start, so my changes (CPU and memory limits per container, fewer jobs in parallel, no turbo boost) did help, but they did not cure it. The safest setting is one job at a time, so I went back to a single runner slot.
That is where it got slow. One of my projects starts a lot of workflows: on every push to a branch, and again after the pull request merges. With one runner they stand in a queue, one after the other. A change that starts three jobs at once takes three times as long as it needs to. And my GitHub-hosted minutes were almost gone anyway (about 30 left until the monthly reset), so going back to ubuntu-latest was not an option either.
There is a second reason, and it has nothing to do with speed. Sometimes I approve a pull request and then want to leave, which means closing the laptop. The runner goes with it. The workflows that should run after the merge then sit in the queue until I open the laptop again. One of them deploys my Cloudflare Workers, so the change stays off the site until the laptop is open again. With runners in ACA the workflows carry on without me: I can close the laptop, and later try the new UI on my phone.
The options
I wanted more capacity without relying on one laptop and without hosted minutes. A self-hosted runner does not use hosted minutes, wherever it runs.
| Option | Why not |
|---|---|
| Azure Functions as the runner | Execution time limits (10 minutes on the Consumption plan). A runner is a long-lived process. A function could only be the trigger for something else. |
| Azure Container Instances | It runs a container, but there is no GitHub-aware autoscaling. I would have to build the trigger myself. |
| A small VM | Simple, but always on, or I need start and stop automation. |
| AKS with Actions Runner Controller | Works, but a Kubernetes cluster for this is overkill, and too expensive for my budget: the nodes keep running (and costing) whether a job is queued or not. |
| Container Apps Job with the KEDA GitHub scaler | Event-driven, scales to zero, billed per second while a job runs. I already have a Container Apps environment. |
How it works
The laptop stays, as one persistent runner. In ACA there is a Container Apps Job with the KEDA github-runner scaler:
- The scaler checks the repository's queued workflow runs every 30 seconds.
- For every queued job it starts one container, with one runner inside it (at most 4 containers at the same time, each with its own 2 vCPU and 4 GiB).
- The container asks GitHub for a just-in-time runner configuration, registers as an ephemeral runner, runs that one job and exits.
- With an empty queue nothing runs and nothing is billed.
The price of that is the start: a job on ACA takes roughly 30 to 60 seconds longer to begin than on the laptop (the scaler's check, the container start, the registration).
Both kinds of runner have the same labels, self-hosted and Linux. So a workflow's runs-on is the same for both, and switching between them is a change of one repository variable. GitHub gives a queued job to any idle runner with the label. An idle laptop takes it within a second or two, before the scaler's next check, so the laptop mostly takes the first job and ACA runs the overflow, or everything when the laptop is off or restarting.
The runner is not tied to one repository. The image and the job definition take the owner, the repository and the labels as parameters, so every repository that wants ACA runners gets its own job from the same image.
Which jobs go where
More runners is not the goal by itself. I have three places to run a job, and each has a different price:
| Where | Parallel capacity | What it costs me |
|---|---|---|
| GitHub-hosted | Far more runners at the same time than I can ever provide | A monthly allowance of free minutes, which reset every month. Minutes I do not use are lost. |
| The laptop | One job at a time | Nothing extra, but slow, and it can still reset |
| ACA | Up to 4 at the same time | Comes out of my Azure budget, shared with my other services |
So the plan is to balance them, not to move everything. I decide per workflow, with three rules:
- It runs very frequently: always on the laptop or ACA. My clearest case is the CI workflow. It runs on the branch before the pull request and again on
mainafter the merge, takes around 5 minutes in total, and runs for every single change. Each run is not that long, but together they are what eats the hosted allowance fastest. - It runs long (more than 3 minutes): on the laptop or ACA. A long job uses a lot of minutes in one go, and on a single laptop runner everything queued behind it has to wait, which is where ACA helps.
- Everything else, short and rare, stays on GitHub-hosted runners. These jobs finish in a minute or two, start in parallel on as many runners as they need, and barely touch the allowance. Not using the free minutes I get every month would be a waste, and every short job I send to ACA instead takes budget from my other services.
What counts is the total minutes, so frequency and duration both decide: a short job that runs on every change belongs on my own runners, and so does a long one.
The switch is the RUNNER repository variable from part 1, and it can be per group of workflows instead of per repository: a separate variable for the ones that run on my own runners, an unset one (so ubuntu-latest) for the quick ones. Right now my hosted minutes are almost used up, so everything runs on my own runners until the monthly reset. After that I will start with this split and see where the allowance goes.
What I have not measured
The runner works. I do not have numbers yet for what the ACA runners cost per month, and I have not compared the total time of a batch with and without them. I expect the usage to stay small, because ACA only runs while a job runs and the quick jobs stay on GitHub, but that is an expectation, not a measurement. The cost matters because it comes out of one budget shared with my other services. When I have a few weeks of data I will write it down, together with how the split between hosted, laptop and ACA turned out.
Lessons learned
- Do not make your own computer the only runner. If it sleeps, restarts or leaves with you, the queue stops; a fallback in the cloud keeps it moving.
- Give the laptop and ACA the same labels, and one repository variable switches between them.
- Then decide per workflow: very frequent or longer than 3 minutes goes to your own runners, short and rare stays on GitHub, so you use both the free minutes and your own capacity.
- Look at the price of each place before you move things. For me the ACA part comes out of a budget I share with my other services.