Manage Linux Machines with Github Actions and Tailscale
How to manage Linux machines using GitHub Actions and Tailscale with secure SSH access and automation.
Managing Homelab Linux Servers with GitHub Actions and Tailscale
Maintaining a homelab with multiple Linux servers often leads to repetitive tasks like updates, container cleanup, and application upgrades. With GitHub Actions and Tailscale SSH, we can automate these tasks across any private network — without opening a single port or exposing services.
In this post, I’ll show you how I use GitHub Actions + Tailscale to manage my Pi-hole boxes, Docker hosts, and Ubuntu servers — safely and automatically.
✅ Overview
We’ll cover:
- Creating a dedicated
github-runneruser - Restricting sudo access using
visudo - Setting up Tailscale:
- Enabling SSH
- Applying tags and ACLs
- Creating an OAuth client for GitHub Actions
- Automating:
- OS updates
- Pi-hole upgrades
- Docker cleanup
- Using GitHub Actions + JSON-based machine tagging
👤 Step 1: Create the github-runner user
On each machine:
1
sudo adduser --disabled-password --gecos "" github-runner
🔒 Step 2: Limit sudo access with visudo
We only want github-runner to run specific commands with sudo.
Run:
1
sudo visudo -f /etc/sudoers.d/github-runner
Then paste:
Defaults:github-runner env_keep += "DEBIAN_FRONTEND"
github-runner ALL=(ALL) NOPASSWD: /usr/bin/apt, /usr/bin/apt-get, /usr/bin/sh, /usr/bin/pihole, /usr/bin/docker
This grants:
- Passwordless sudo only for update commands, Pi-hole, and Docker
DEBIAN_FRONTENDto suppress interactive prompts
🛜 Step 3: Set up Tailscale
Enable Tailscale SSH
On each machine:
1
sudo tailscale up --ssh --authkey tskey-...
Or, use ephemeral keys via GitHub Actions.
Configure ACLs in the Tailscale admin:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"tagOwners": {
"tag:ghup": ["autogroup:admin"],
"tag:ghci": ["autogroup:admin"]
},
"ssh": [
{
"action": "accept",
"src": ["tag:ghci"],
"dst": ["tag:ghup"],
"users": ["github-runner"]
}
]
}
Tag each machine in the Tailscale admin
Example:
usvr.tail9990.ts.net:tag:ghup- GitHub runner (when connected):
tag:ghci
Create a Tailscale OAuth client
Visit https://login.tailscale.com/admin/oauth
- Grant these scopes:
device:createdevice:readdevice:delete
- Save the Client ID and Client Secret in GitHub repo secrets:
TS_OAUTH_CLIENT_IDTS_OAUTH_SECRET
⚙️ Step 4: Define Machines in JSON
Create machines.json in the root of your repo:
1
2
3
4
5
6
{
"machines": [
{ "name": "dsvr.tail0990.ts.net", "tags": ["update-os", "containers"] },
{ "name": "pi-dns-01.tail0990.ts.net", "tags": ["pihole"] }
]
}
🚀 Step 5: GitHub Workflows
🔧 1. OS Updates: .github/workflows/update-os.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
name: Update OS
on: [workflow_dispatch]
jobs:
generate-matrix:
...
run: |
MACHINE_LIST=$(jq '[.machines[] | select(.tags | index("update-os")) | .name]' machines.json)
echo "matrix={\"machine\": $MACHINE_LIST}" >> "$GITHUB_OUTPUT"
update:
...
run: |
ssh -o StrictHostKeyChecking=no github-runner@$ << 'EOF'
echo "Running on: $(hostname)"
echo "User: $(whoami)"
sudo DEBIAN_FRONTEND=noninteractive apt update -y
sudo DEBIAN_FRONTEND=noninteractive apt upgrade -y
sudo DEBIAN_FRONTEND=noninteractive apt autoremove -y
EOF
🧼 2. Clean Docker Containers: .github/workflows/clean-containers.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
name: Clean Docker Containers
on: [workflow_dispatch]
jobs:
generate-matrix:
...
run: |
MACHINE_LIST=$(jq '[.machines[] | select(.tags | index("containers")) | .name]' machines.json)
echo "matrix={\"machine\": $MACHINE_LIST}" >> "$GITHUB_OUTPUT"
clean-containers:
...
run: |
ssh -o StrictHostKeyChecking=no github-runner@$ << 'EOF'
echo "Cleaning on: $(hostname)"
sudo docker system prune -f
EOF
🛠 3. Pi-hole Update: .github/workflows/update-pihole.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
name: Update Pi-hole
on: [workflow_dispatch]
jobs:
generate-matrix:
...
run: |
MACHINE_LIST=$(jq '[.machines[] | select(.tags | index("pihole")) | .name]' machines.json)
echo "matrix={\"machine\": $MACHINE_LIST}" >> "$GITHUB_OUTPUT"
update:
...
run: |
ssh -o StrictHostKeyChecking=no github-runner@$ << 'EOF'
echo "Running on: $(hostname)"
sudo pihole -up
EOF
📌 Recap
- ✅ Tailscale gives secure, zero-config SSH access across any network
- ✅ GitHub Actions orchestrates updates on a schedule or on-demand
- ✅ Tags and JSON-based config make targeting servers flexible
- ✅ Principle of least privilege enforced with
visudo
📎 Bonus Ideas
- Add Discord/Slack notifications
- Include logging to a GitHub artifact or centralized log store
- Add reboot scheduling for updates that require it
- Use
tailscale logoutto explicitly clean up ephemeral runners
💬 Final Thoughts
This setup has been rock solid in my homelab. I don’t log into boxes for routine updates anymore — GitHub Actions does it safely, and Tailscale ensures it’s secure and private.
Let me know if you’d like to see a template repo with this structure — or a GUI front-end to trigger workflows per machine group.