Self-Hosted GitHub Runners: The Post-Install Checklist Nobody Tells You About
Installing a self-hosted GitHub runner is the easy part:
mkdir actions-runner && cd actions-runner
curl -o actions-runner-linux.tar.gz -L https://github.com/actions/runner/releases/latest/download/actions-runner-linux-x64-2.x.tar.gz
tar xzf actions-runner-linux.tar.gz
./config.sh --url https://github.com/YOUR_ORG --token YOUR_TOKEN
sudo ./svc.sh install
sudo ./svc.sh startThe runner is online. You push to the repo, the workflow triggers... and then the errors start. Not one, not two — a parade of environment problems that the GitHub-hosted runners never showed you, because their images are pre-configured for exactly this job.
This post is the post-install checklist that fixes the four errors you will actually hit. It assumes a Linux VM, a runner installed with svc.sh (systemd service), and a user called ubuntu — swap in your own user where relevant.
1. Docker: the socket permission error
Checking docker version
/usr/bin/docker version --format '{{.Server.APIVersion}}'
permission denied while trying to connect to the docker API at unix:///var/run/docker.sockDocker is installed. The daemon is running. But the runner's user can't talk to it — the socket is owned by root:docker and your runner user isn't in the docker group.
On the server:
# 1. Make sure the daemon is running
sudo systemctl enable --now docker
# 2. Add the runner user to the docker group
sudo usermod -aG docker ubuntu
# 3. This step is the one everyone forgets:
# group membership only applies at login, so restart the runner
sudo systemctl restart actions.runner.<owner>.<repo>.<name>.service
# 4. Verify as the runner user (not root!)
id # must show the docker group
docker version # must not say "permission denied"The restart matters. Adding the user to the group while the runner service keeps running changes nothing — the service's process still has its original group membership.
2. File descriptors: the nfiles limit
Your workflows will spin up services: containers (databases, Kafka brokers, Redis), and each container plus the runner itself opens file descriptors. The default nofile limit on a stock Linux box is typically 1024 soft / 4096 hard — enough for a laptop session, nowhere near enough for a runner running integration tests with a 3-node Kafka cluster.
Symptoms are hard to diagnose: too many open files deep inside test logs, Kafka brokers dying mid-run, flaky containers.
Check first:
sudo -u ubuntu bash -c 'ulimit -n'Raise it for the runner user:
# /etc/security/limits.conf
ubuntu soft nofile 64000
ubuntu hard nofile 64000And because the runner runs as a systemd service (which ignores limits.conf unless PAM is involved), set it in the service unit too:
sudo systemctl edit actions.runner.<owner>.<repo>.<name>.service[Service]
LimitNOFILE=64000sudo systemctl daemon-reload
sudo systemctl restart actions.runner.<owner>.<repo>.<name>.serviceFor Docker-based actions and service containers, also give the daemon sane defaults in /etc/docker/daemon.json — these apply to every container the runner starts:
{
"default-ulimits": {
"nofile": {
"Name": "nofile",
"Soft": 64000,
"Hard": 64000
}
}
}sudo systemctl restart dockerNow every container the runner spawns gets the same generous limits, and your Kafka cluster stops dying at 1024 open descriptors.
3. actions/setup-dotnet: the mystery mkdir error
This one looks like a filesystem permission bug on your VM, and it isn't:
/home/ubuntu/actions-runner/_work/_actions/actions/setup-dotnet/v4/externals/install-dotnet.sh --skip-non-versioned-files --runtime dotnet --channel LTS
mkdir: Permission deniedYou check ~/.dotnet, you check the runner's home, you check the tool cache — all owned by the right user. The mkdir fails anyway. Why?
Because actions/setup-dotnet@v4 does not install to $HOME/.dotnet. On Linux its default install root is hardcoded:
// actions/setup-dotnet, src/installer.ts
private static readonly default = {
linux: '/usr/share/dotnet', // <- this
...
};So every run executes mkdir -p /usr/share/dotnet — a root-owned system directory. On GitHub-hosted runners this never bites because their images pre-create /usr/share/dotnet owned by the runner user. Your fresh VM doesn't have that.
One-time fix on the server:
sudo mkdir -p /usr/share/dotnet
sudo chown -R ubuntu:ubuntu /usr/share/dotnetNo workflow changes, fixed forever. Bake it into your provisioning script.
The same class of error hits the dotnet global tools folder: dotnet tool install --global dotnet-ef installs to ~/.dotnet/tools, which is fine — but the next step can't find dotnet-ef, because of the PATH problem in section 4.
4. Injecting PATH: the BASH_ENV trick
Could not execute because the specified command or file was not found.
* You intended to run a global tool, but dotnet-ef does not exist.dotnet-ef exists. You verified: ls ~/.dotnet/tools/ shows it. The step that runs dotnet ef migrations apply can't find it because every GitHub Actions step runs in a fresh, non-interactive, non-login bash (bash -e {0}), and that shell reads exactly one file: the one pointed to by $BASH_ENV. Not .bashrc, not .bash_profile, not /etc/profile:
| File | Read by non-interactive bash? |
|---|---|
/etc/profile, /etc/profile.d/*.sh | ❌ (login shells only) |
~/.bash_profile, ~/.profile | ❌ (login only) |
~/.bashrc | ❌ (interactive only) |
/etc/environment | ❌ (PAM, login sessions only) |
$BASH_ENV script | ✅ the one exception |
And PATH itself is never rebuilt by bash — it's whatever the parent process passed down. For a systemd service runner, that chain is systemd → runsvc.sh → Runner.Listener → Runner.Worker → step shell, and systemd gives services a minimal default PATH. Nothing in that chain knows about ~/.dotnet/tools.
The clean fix: tell the runner about a script via its own .env file (the listener reads <runner-root>/.env on every start — it's in the runner source, and it survives runner upgrades because it's not part of the package), and append to PATH in that script using BASH_ENV:
# 1. A script that APPENDS — no full PATH anywhere
cat > /home/ubuntu/runner-bashenv.sh <<'EOF'
export PATH="$PATH:/home/ubuntu/.dotnet/tools"
EOF
# 2. Point the runner at it — one line in the runner's own .env
printf '%s\n' 'BASH_ENV=/home/ubuntu/runner-bashenv.sh' | sudo tee /home/ubuntu/actions-runner/.env > /dev/null
sudo chown ubuntu:ubuntu /home/ubuntu/actions-runner/.env
# 3. Restart
sudo systemctl restart actions.runner.<owner>.<repo>.<name>.serviceEvery step's bash now sources the script before running, so PATH is appended on each step. No full-path definitions, no systemd unit edits, and it covers any future global tool — dotnet-ef, dotnet-reportgenerator-globaltool, whatever you install next.
Why not the alternatives?
/etc/environmentlooks global but is PAM-only — systemd services never see it.environment.dis elegant (PATH=$PATH:/home/ubuntu/.dotnet/tools) but only feeds the systemd user manager — not system services like your runner.- The runner's
.pathfile works but replaces the entire PATH, so you have to maintain a full definition. - Editing the systemd unit's
Environment=works, but then you're maintaining PATH inside a generated file.
BASH_ENV + .env is the least-maintenance, append-only, upgrade-safe option.
The full checklist
# Docker socket
sudo usermod -aG docker ubuntu
# File descriptors
echo 'ubuntu soft nofile 16384' | sudo tee -a /etc/security/limits.conf
echo 'ubuntu hard nofile 16384' | sudo tee -a /etc/security/limits.conf
# setup-dotnet install root
sudo mkdir -p /usr/share/dotnet && sudo chown -R ubuntu:ubuntu /usr/share/dotnet
# PATH injection for global dotnet tools
cat > /home/ubuntu/runner-bashenv.sh <<'EOF'
export PATH="$PATH:/home/ubuntu/.dotnet/tools"
EOF
printf '%s\n' 'BASH_ENV=/home/ubuntu/runner-bashenv.sh' | sudo tee /home/ubuntu/actions-runner/.env > /dev/null
# Restart everything
sudo systemctl restart docker
sudo systemctl restart actions.runner.<owner>.<repo>.<name>.serviceVerify with a throwaway workflow:
name: runner-check
on: [workflow_dispatch]
jobs:
check:
runs-on: [self-hosted, Linux]
steps:
- run: docker version && id
- run: ulimit -n
- run: echo "$PATH"If you see the docker group in id, 16384 from ulimit -n, and ~/.dotnet/tools in $PATH — the runner is finally as well-configured as the hosted ones you were paying for.
Related: Build a GitHub Actions Dashboard on Any Screen · Claude Code Hooks — Push AI Agent Activity to a Physical Display · CLI docs · HTTP API reference