HostiServer
2026-08-13 09:47
GitHub Actions Self-Hosted Runner: Installation and the First Pipeline on a VPS
📚 "CI/CD from scratch" series, part 3 of 6:
- What CI/CD is: from manual deploy to automated pipelines
- Self-hosted CI/CD: the concept, the architecture and runners
- GitHub Actions self-hosted runner: installation and the first pipeline on a VPS ← you are here
- GitLab CI self-hosted runner: installation and the first pipeline on a VPS
- Gitea Actions: the Git platform and the pipeline on one server
- GitHub Actions vs GitLab CI vs Gitea Actions in 2026: what to choose for self-hosted CI/CD
GitHub Actions self-hosted runner: installation and the first pipeline on a VPS
In the second part we broke down how a self-hosted runner is arranged: the agent picks up a job from the platform's queue, executes it on your server, and reports back the logs and the status. There we also looked at why it's worth moving this agent onto your own hardware and how to isolate code execution. Now that same architecture turns into concrete commands: we take a VPS, connect it to a repository on GitHub, and bring the chain all the way to a working deploy to production.
This part is practical from start to finish. First, a short overview of how GitHub Actions is put together, so the YAML that follows reads with understanding rather than getting copied blindly. Then agent installation, wrapping it as a systemd service, the first workflow, working with secrets, and a full pipeline that, after a push to main, builds the project and ships it to a server over SSH.
1.1 What we already have and what comes next
The minimum we're starting from:
- a repository on GitHub, private or internal to an organization;
- a VPS with Ubuntu 22.04 or 24.04, SSH access, a separate non-root user;
- repository admin rights: without them the runner-configuration page isn't reachable.
The agent doesn't need a public address or open inbound ports. It initiates the connection to GitHub itself over port 443 and holds it, waiting for a job. This is an important property: a runner can sit inside a closed network with no external access whatsoever, and it will still work just fine.
1.2 GitHub Actions: what the ecosystem is made of
GitHub Actions is GitHub's built-in automation system, launched in 2019. The configuration lives in the repository itself, in the .github/workflows/ directory: every YAML file there is a separate workflow that GitHub picks up automatically, with no registration needed in the UI.
The second part of the ecosystem is the Marketplace of ready-made actions. An action is a reusable step packaged as a separate repository: actions/checkout clones the code, actions/setup-node installs the required Node.js version, docker/build-push-action builds and publishes an image. Instead of a dozen lines of bash in the config, you get one uses line.
Convenience has a flip side. Every third-party action is someone else's code that executes in your environment and sees your secrets. On a self-hosted runner, the cost of a mistake is higher than on a one-time cloud machine: this server of yours keeps living afterward. The working rule is to pin actions to a full commit hash instead of a tag, at least for anything not owned by the actions and github organizations:
# instead of a moving tag
- uses: some-org/some-action@v3
# pinned to a specific commit
- uses: some-org/some-action@8f4b7e2c9a1d3f5b6c8e0a2d4f6b8c0e2a4d6f8b # v3.1.0
ℹ️ Terminology: GitHub Actions has no notion of stages as a separate entity, unlike GitLab CI. Execution order is set through dependencies via the needs key. If you're coming from GitLab, this is the main difference to get used to.
2. GitHub Actions architecture
Before installing the agent, it's worth breaking down the three levels the work is described in. Every YAML snippet in this article that follows rests on exactly these.
2.1 Workflow → Job → Step
Workflow is a single YAML file in .github/workflows/. It answers "when to run" and "what to do." You can have as many files as you like: separate checks for pull requests, a separate nightly dependency scan, a separate release-by-tag workflow.
Job is a task inside a workflow. Every job gets its own runner and its own clean environment. Jobs run in parallel by default, and order is set through needs. An important consequence: two jobs don't see each other's files — exchange happens through artifacts.
Step is a single step inside a job. Steps run sequentially within one environment, so files pass between them normally. A step comes in two flavors: run for shell commands and uses for a ready-made action.
workflow (file ci.yml)
├── job: build → runner #1, own environment
│ ├── step: checkout
│ ├── step: npm ci
│ └── step: npm run build
└── job: deploy → runner #2, clean environment
needs: build
├── step: download-artifact
└── step: ssh deploy
2.2 Triggers: when a workflow runs
Triggers are described in the on section. Four main ones:
- push to a given branch or by tag. The baseline trigger for CI and for releases.
- pull_request. Checks the merge result before it actually happens. This is the trigger you hang merge blocking on until checks pass.
- schedule. Runs on a cron schedule, in the UTC timezone. Good for nightly runs and daily dependency scans.
- workflow_dispatch. Triggered by a button in the UI or an API call, with optional inputs. This is the same manual step that separates Continuous Delivery from Continuous Deployment.
on:
push:
branches: [main]
paths-ignore:
- '**.md'
- 'docs/**'
pull_request:
branches: [main]
schedule:
- cron: '0 3 * * *' # daily at 03:00 UTC
workflow_dispatch:
inputs:
environment:
description: 'Where to deploy'
type: choice
options: [staging, production]
default: staging
There's a nuance about scheduling that saves hours of investigation: schedule only works for a workflow on the default branch, and the run can lag by a few minutes or shift under peak load. For precise timing you need an external scheduler that pokes workflow_dispatch through the API.
2.3 GitHub-hosted vs self-hosted runner: the difference in practice
A GitHub-hosted runner is a virtual machine GitHub spins up for every job and destroys afterward. A self-hosted runner is your own server with the agent installed, living permanently. The difference goes well beyond who pays for the hardware.
| Parameter | GitHub-hosted | Self-hosted |
|---|---|---|
| Environment lifespan | A clean VM per job | Permanent, state accumulates |
| Resources | Fixed by the plan | Yours — up to GPUs and hundreds of GB of RAM |
| Billing | Per build minute | Per server, minutes aren't counted |
| Pre-installed software | A large image with languages and utilities | Only what you installed |
| Access to a closed network | None | Yes, the runner sits inside |
| Queueing at peak hours | Possible | Your own queue, predictable timing |
| Administration | None | Updates, disk, isolation, monitoring |
The row about a permanent environment is both the main advantage and the main trap. The advantage is speed: package caches, Docker layers, and a cloned repository survive between builds, so a second build is often twice as fast as the first. The trap is that everything else survives too: globally installed packages, temporary files, leftover environment variables, a private key that a previous job wrote to the home directory and forgot to remove.
⚠️ Public repositories: GitHub explicitly recommends against connecting a self-hosted runner to a public repository. Anyone can open a pull request, and along with it propose changes to the workflow or to the code that workflow executes. On a one-time cloud machine, that ends together with the job. On your server, someone else's code gets access to everything that's on it and to the network it sits in. For open source, stick to GitHub-hosted runners.
3. Installing a self-hosted runner on a VPS
Here's the sequence after which the agent will show up in the runner list with the Idle status and survive a server reboot.
3.1 Registering with GitHub and getting a token
A runner connects at one of three levels:
- Repository. Settings → Actions → Runners → New self-hosted runner. The simplest option for your first agent.
- Organization. The same path in organization settings. One agent serves multiple repositories, access is scoped through runner groups.
- Enterprise. The tier for large installations, same logic.
The page immediately shows ready-made commands for the chosen OS and architecture, with the current agent version and a checksum. Copy them from there: the version updates regularly, and commands from someone else's article get stale fast.
⚠️ The registration token lives for about an hour. If more time passes between copying it and running config.sh, the configuration will fail with an authorization error. The fix is simple: refresh the page and grab a new token. This token is only needed to connect the agent — it isn't used during regular operation.
3.2 Downloading and starting the agent
All commands run as a regular user, not root. The agent will refuse to configure itself under root, and that's the correct behavior: jobs execute with the privileges of whichever user the agent runs as.
# a separate system user for the runner
sudo adduser --disabled-password --gecos "" runner
sudo usermod -aG docker runner # only if builds use Docker
sudo su - runner
mkdir -p ~/actions-runner && cd ~/actions-runner
# grab the version and link from the New self-hosted runner page
RUNNER_VERSION=2.3xx.x
curl -o actions-runner.tar.gz -L \
https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz
# verify the checksum against the one GitHub showed
echo "<hash from the page> actions-runner.tar.gz" | shasum -a 256 -c
tar xzf ./actions-runner.tar.gz
The unpacked directory contains config.sh for connecting, run.sh for running, svc.sh for wrapping it as a service, and the bin/installdependencies.sh script, which pulls in the system libraries the agent's .NET runtime needs. The last one runs once and requires sudo.
sudo ./bin/installdependencies.sh
./config.sh \
--url https://github.com/OWNER/REPO \
--token <token from the page> \
--name vps-build-01 \
--labels self-hosted,linux,x64,build \
--work _work \
--unattended \
--replace
What the flags mean:
--nameis the name in the runner list. Make it meaningful: once you have five agents,vps-build-01reads much better thanubuntu-server.--labelsare the tags a workflow uses to pick an agent. On top of the three standard ones (self-hosted, OS, architecture), add your own:build,deploy,gpu.--unattendedremoves interactive prompts, needed for automation through Ansible or cloud-init.--replaceoverwrites an agent with the same name if one's already registered.
You can verify the connection right away, in the foreground:
./run.sh
# √ Connected to GitHub
# Listening for Jobs
At this point the agent shows up in the list with the Idle status. Stop it with Ctrl+C and move on to the service.
3.3 The runner as a systemd service
Running via run.sh only lasts until the session closes. For permanent operation, the agent gets wrapped as a service, and the script for that is already sitting in the directory:
sudo ./svc.sh install runner
sudo ./svc.sh start
sudo ./svc.sh status
The script creates a unit named something like actions.runner.OWNER-REPO.vps-build-01.service and enables autostart. From there you work with it through the usual systemd tools:
systemctl status 'actions.runner.*'
journalctl -u 'actions.runner.*' -f
sudo ./svc.sh stop
sudo ./svc.sh uninstall
Detailed job-execution logs live separately, in the _diag directory inside the agent's folder. It's worth checking there when a job fails without a clear message in the GitHub interface.
Two settings that are worth doing right away:
- Working-directory rotation. The
_workdirectory grows without bound: every repository, every artifact, every cache. Clean it on a weekly schedule, or watch free disk space, because running out of disk looks like random build failures. - Auto-updates. The agent updates itself when GitHub ships a new version. If updates need to happen only within your own maintenance window, add
--disableupdateduring configuration and update manually. Keep in mind that a stale agent eventually stops accepting jobs.
4. The first workflow
The agent is connected — now let's give it something to do.
4.1 The structure of the YAML file
The file goes into .github/workflows/ci.yml and has three required parts: name for display in the UI, on for triggers, jobs for the actual work.
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: [self-hosted, linux, x64]
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test
GitHub picks up the file automatically after a push. There's nowhere to register it, and syntax errors show up on the Actions tab right after the commit.
4.2 runs-on: how a job finds your agent
runs-on is a filter by labels. A job goes to an agent that has all the listed labels:
# any of your agents
runs-on: self-hosted
# only linux x64 among yours
runs-on: [self-hosted, linux, x64]
# only an agent labeled gpu
runs-on: [self-hosted, linux, gpu]
# GitHub's cloud agent
runs-on: ubuntu-latest
If there's no agent with the required label set, or it's offline, the job doesn't fail — it queues and waits. By default it will sit there for a few hours, then get canceled by timeout. This is the typical cause of "the pipeline started and nothing's happening": check the label match and the agent status, not the logs.
Labels combine nicely with runner groups at the organization level: the group determines which repositories are even allowed to send jobs to this agent at all, and labels then split the jobs within what's allowed.
4.3 A working example: checkout, dependencies, tests
A full config with comments on every block:
name: CI
on:
push:
branches: [main]
pull_request:
# new runs for the same branch cancel the previous ones
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: [self-hosted, linux, x64]
timeout-minutes: 20
steps:
- name: Check out the code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Test
run: npm test -- --ci
- name: Save the report
if: always()
uses: actions/upload-artifact@v4
with:
name: test-report
path: reports/
retention-days: 7
Three details that matter more on a self-hosted runner than on a cloud one:
timeout-minutes. Without it, a hung job occupies the agent for six hours, and every subsequent build queues up behind it. On your own agent this blocks the whole team, since there's no spare machine.concurrency. Cancels previous runs for the same branch. With a single agent, this clears out pointless runs for commits already superseded by newer ones.if: always(). The step runs even after previous ones fail. For saving reports this is mandatory, since the report from a failed run is the interesting one.
ℹ️ About setup-node and similar actions: on a self-hosted runner they cache installed versions in the agent's directory, so the first run is longer and subsequent ones are nearly instant. The alternative is pre-installing the needed versions on the system, but then bumping a version stops being a one-line config change and turns back into a manual server operation.
5. Secrets and variables
A pipeline almost always needs something you can't put in the repository: SSH keys, image-registry tokens, database passwords. GitHub has a dedicated store for this.
5.1 Where secrets are stored
The path in the UI: Settings → Secrets and variables → Actions. There are two tabs there. Secrets holds values that are masked in logs and can't be read back after they're saved. Variables holds ordinary settings without masking: a branch name, a staging server address, an image version.
Secrets exist at three levels:
| Level | Who sees it | When to choose it |
|---|---|---|
| Repository | Every workflow in this repository | A value only this project needs |
| Organization | Selected repositories in the organization | A shared registry token, a monitoring key |
| Environment | Only jobs with the matching environment |
Production access that needs to be separated from the rest |
Masking works at the text level: if a secret's value ends up in the output, GitHub replaces it with asterisks. The protection isn't absolute. A secret split into parts, base64-encoded, or printed character by character shows up in full in the logs. So on top of masking, the usual rule of minimal privilege applies: a key that can only drop files into a directory and restart one service won't be a disaster even after a leak.
5.2 Environment secrets for staging and production
Environments (Settings → Environments) are the most useful mechanism in this section. They do two things at once: hold their own set of secrets and impose rules on the jobs that reference them.
- Required reviewers. The job pauses and waits for approval from a specific person. This is the manual Continuous Delivery button, but with the approver's name in the logs.
- Deployment branches. Restricts which branches can deploy. Set
mainfor production, and an accidental deploy from an experimental branch becomes impossible. - Wait timer. A pause before execution, during which the deploy can be canceled.
jobs:
deploy-staging:
runs-on: [self-hosted, linux, deploy]
environment: staging
steps:
- run: ./scripts/deploy.sh
env:
HOST: ${{ secrets.DEPLOY_HOST }} # value from the staging environment
deploy-production:
needs: deploy-staging
runs-on: [self-hosted, linux, deploy]
environment: production # waits for reviewer approval
steps:
- run: ./scripts/deploy.sh
env:
HOST: ${{ secrets.DEPLOY_HOST }} # same name, different value
The same secret name in two environments isn't a coincidence — it's convenience: the workflow stays a single one, and GitHub handles the difference between environments.
5.3 Using secrets in a workflow
Access happens through the secrets context:
steps:
- name: Correct: via env
env:
API_TOKEN: ${{ secrets.API_TOKEN }}
run: ./scripts/publish.sh
- name: Unsafe: the value lands in the command line
run: ./scripts/publish.sh --token ${{ secrets.API_TOKEN }}
The difference between the two steps isn't cosmetic. In the second case, the value gets substituted into the command string before the shell even runs, so it's visible in ps on the server and lands in shell history. Through env, it's passed as a process environment variable, and that's a noticeably narrower surface.
⚠️ Pull requests from forks: secrets are unavailable to such runs by default, and that's protection against an obvious attack. The pull_request_target trigger removes that protection, since it runs the workflow in the context of the base branch, secrets included. Use it only with base-branch code, and never check out fork code inside it.
6. A practical example: deploying to a server over SSH
Let's put it all together. Goal: after a push to main, the project builds on the runner, the artifact ships to the production server, and the service restarts.
6.1 The deploy key
Generate a dedicated key, just for this task, that never overlaps with an engineer's personal key:
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ./gh_deploy -N ""
On the production server, create a non-root user and add the public part with restrictions:
# on the production server
sudo adduser --disabled-password --gecos "" deploy
sudo install -o deploy -g deploy -m 700 -d /home/deploy/.ssh
# /home/deploy/.ssh/authorized_keys
from="203.0.113.10",no-agent-forwarding,no-port-forwarding,no-X11-forwarding,no-pty ssh-ed25519 AAAA... github-actions-deploy
The from parameter ties the key to your runner's address: even with a stolen private key, logging in from a different machine won't work. Rights to restart the service are granted narrowly, through sudoers, rather than blanket sudo access:
# /etc/sudoers.d/deploy
deploy ALL=(root) NOPASSWD: /bin/systemctl restart app.service
6.2 Secrets in the repository
Add three values to the production environment:
SSH_PRIVATE_KEYis the full content of thegh_deployfile, including theBEGINandENDlines and the trailing newline.SSH_KNOWN_HOSTSis the output ofssh-keyscan -H your-server.example.com.DEPLOY_HOSTis the server's address.
The second item often gets skipped and replaced with StrictHostKeyChecking=no. Don't do that: this option disables the check that protects against server spoofing, and its presence in the config means the deploy will happily go wherever answers over SSH.
6.3 The full workflow
name: Build and deploy
on:
push:
branches: [main]
concurrency:
group: deploy-production
cancel-in-progress: false
jobs:
build:
runs-on: [self-hosted, linux, x64, build]
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run lint
- run: npm test -- --ci
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
retention-days: 7
deploy:
needs: build
runs-on: [self-hosted, linux, deploy]
environment: production
timeout-minutes: 10
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Prepare SSH
run: |
install -m 700 -d ~/.ssh
install -m 600 /dev/null ~/.ssh/deploy_key
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
echo "${{ secrets.SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
- name: Ship the files
run: |
rsync -az --delete \
-e "ssh -i ~/.ssh/deploy_key" \
dist/ deploy@${{ secrets.DEPLOY_HOST }}:/var/www/app/
- name: Restart the service
run: |
ssh -i ~/.ssh/deploy_key deploy@${{ secrets.DEPLOY_HOST }} \
"sudo systemctl restart app.service"
- name: Verify it came back up
run: |
for i in $(seq 1 10); do
code=$(curl -s -o /dev/null -w "%{http_code}" https://app.example.com/health || true)
[ "$code" = "200" ] && exit 0
sleep 3
done
echo "Service isn't responding after the deploy"
exit 1
- name: Clean up the key
if: always()
run: rm -f ~/.ssh/deploy_key
The last step with if: always() isn't a formality — it's a direct consequence of the permanent environment. On a cloud runner, the machine disappears together with the key. Your agent keeps living, and a private key left in the home directory stays accessible to any subsequent job, including one that came from a different branch.
The health-check step turns the deploy into an action with a verified outcome. Without it, the workflow goes green right after the service restart, even if the app immediately fell into a crash loop, and you'd find out about the outage from your customers.
ℹ️ When SSH isn't needed at all: if the runner sits on the same server as the app, the deploy boils down to copying files locally. The temptation is understandable, but a build is a load on CPU, disk, and memory, and it will compete with production at the worst possible moment. The working compromise is a separate machine for the runner and a private network between it and production: the build stays isolated, and deploy traffic never touches the public internet.
7. Conclusion
A self-hosted runner for GitHub Actions installs in a handful of commands, and the real work starts after that. What's worth taking from this part:
- The agent needs no open ports. It reaches out to GitHub itself over port 443, so it can live comfortably inside a closed network right next to production.
- Labels are the routing mechanism.
runs-onlooks for an agent with all the listed labels, and a job stuck in the queue almost always means a mismatch right here. - A permanent environment speeds things up and accumulates things. A cache surviving between builds is a win; leftover files and keys are a risk. Cleaning up after yourself becomes part of the workflow.
- Environments matter more than individual secrets. An Environment with reviewers and branch restrictions gives you both access separation and a manual gate before production.
- A deploy ends with a check. A green pipeline without a health check only means the commands ran — not that the app is actually working.
The minimal working setup looks like this: a dedicated user for the agent, a systemd service, labels per job type, timeout-minutes on every job, secrets in the production environment, and key cleanup in a step with if: always().
What's next in the series
In the fourth part we'll do the same thing for GitLab CI: install GitLab Runner on a VPS, walk through the executors (shell, docker, docker+machine), and go from the first .gitlab-ci.yml all the way to a production deploy. The comparison with GitHub Actions will run throughout, since the architectural decisions differ there more than the syntax does.
📚 Series navigation:
You're reading part 3 of 6, "GitHub Actions self-hosted runner: installation and the first pipeline on a VPS."
Previous: ← Part 2. Self-hosted CI/CD: the concept, the architecture and runners
Next: Part 4. GitLab CI self-hosted runner: installation and the first pipeline on a VPS →
🚀 VPS and dedicated servers for your GitHub Actions runners
A runner runs into the CPU during a build, into disk during dependency installation, and into the network during a deploy. Hostiserver gives you predictable resources and a network you can safely reach production from.
🖥️ Dedicated Servers
- From $90/mo, full control over the hardware for heavy builds and parallel runners
- NVMe storage: a fast cache for npm, Maven, and Docker layers between runs
- No build-minute limits: you pay for the server, not for pipeline time
- Private network between the runner and production servers
- 24/7 support: engineers help with agent setup and deploys
💻 Cloud (VPS) Hosting
- From $19.95/mo, KVM isolation, dedicated vCPU and RAM
- Perfect for your first runner: spin it up, connect it to the repo, try it on a real project
- Easy to scale: a separate VPS for build and a separate one for deploy, with different labels
💬 Not sure which option you need?
💬 Reach out and we'll help you figure it out!
Frequently Asked Questions
- Can the runner be put on the same server where production lives?
Technically yes, and for a small project it works. The problem is resource contention: the build eats CPU and memory exactly when a release is shipping, i.e. exactly when the app is under the heaviest load. The second problem is permissions: an agent sitting next to production usually gets access to the app's files and to restarting services, and along with it, so does any code that runs in the pipeline. The compromise is a separate cheap VPS for the runner and a private network to production.
- How much VPS resource does one runner need?
The agent itself consumes around 150-250 MB of RAM when idle; the rest depends on the builds. For a typical Node.js or Python web project, 2 vCPU and 4 GB of RAM is enough; for Docker builds, 4 vCPU and 8 GB is better. Disk matters more than it seems: 40 GB is the minimum, because
_work, package caches, and Docker layers grow fast. One agent runs one job at a time, so for parallel builds you either add more agents or get a beefier server.
- The runner shows Offline even though the service is running. What should I check?
Start with the logs:
journalctl -u 'actions.runner.*' -n 100and the_diagdirectory inside the agent's folder. The most common causes are outbound connections to port 443 on github.com being blocked by a firewall or corporate proxy, an outdated agent version GitHub no longer accepts, and disk space running out. A separate case is a runner removed on GitHub's side: the agent keeps running, but its token is no longer valid, and the only fix is reconfiguring with a new token.
- Is it safe to enable a self-hosted runner on a public repository?
No, and GitHub warns about it directly. Anyone can open a pull request, and their code will execute on your server. The environment is permanent, so the consequences don't vanish once the job finishes: a leftover process, a modified system package, files read from the working directory. For public projects, use GitHub-hosted runners. If you genuinely need your own agent there, the remaining option is ephemeral agents in one-time containers, with mandatory run approval for external contributors.
- How do I run several runners on one server?
Each agent lives in its own directory with its own configuration. Create
~/actions-runner-1,~/actions-runner-2, runconfig.shin each with a unique--name, thensvc.sh install. You end up with two independent systemd services and two parallel jobs. A sensible limit is the core count divided by two: agents compete for CPU and disk, and four simultaneous builds on a two-core VPS will be slower than two run one after another.
- Do I still need actions/cache if the environment is already permanent?
Mostly no. On a permanent agent,
node_modules, the~/.m2directory, or~/.cache/pipstay put between runs, soactions/cachejust adds an extra upload/download cycle over GitHub's network. The exception is multiple agents: a shared cache then evens out build time regardless of which agent picked up the job. For ephemeral agents, caching is mandatory, since the environment is fresh every time.
- What do I do when the runner doesn't have the language version or tool I need?
Two paths. First: actions like
setup-node,setup-python,setup-java— they download the needed version themselves and cache it on the agent, and the version stays documented in the repository. Second: run the steps in a container via thecontainerkey at the job level, and then the environment is fully described by the image. Manually installing packages on the server works too, but it brings back the exact problem we started the series with: the environment is only known to whoever set it up.