GitHub Actions Deploy via Tailscale SSH¶
Deploy from GitHub Actions to a server that only accepts SSH over Tailscale — no public SSH port, no SSH keys to manage.
Prerequisites¶
- A server on your Tailnet
- A deploy user on the server with write access to the deploy target directory
- Admin access to the Tailscale admin console
- Tailscale SSH enabled on the server (
tailscale set --ssh). If you're connected over SSH when running this, add--accept-risk=lose-ssh— your session will drop but you can reconnect once ACLs are in place.
Step 1: Tag the server and create a CI tag¶
Tag the server in the Tailscale admin machines page (e.g. tag:server). Then add both tags to your ACL in the ACL editor:
Tagging changes device ownership
Tagging a device transfers its ownership from your user to the tag. Tagged devices are not reachable by autogroup:member or the * wildcard in grants. You must add explicit grant rules from your devices to the server tag (see step 2).
Step 2: Lock down CI access¶
Add grants and SSH rules. Use tags for dst in SSH rules — raw IPs will error.
"grants": [
{
"src": ["autogroup:member"],
"dst": ["*"],
"ip": ["*"]
},
{
"src": ["<your-device-tag>"],
"dst": ["tag:server"],
"ip": ["*"]
},
{
"src": ["tag:ci"],
"dst": ["tag:server"],
"ip": ["22"]
}
],
"ssh": [
{
"src": ["tag:ci"],
"dst": ["tag:server"],
"users": ["<deploy-user>"],
"action": "accept"
},
{
"src": ["<your-device-tag>"],
"dst": ["tag:server"],
"users": ["root"],
"action": "accept"
}
]
The second grant and SSH rule ensure you can still reach the server from your own devices after tagging it.
Merge these into your existing ACL — don't replace other rules.
Step 3: Create a federated identity credential¶
- Go to Tailscale admin → Trust credentials
- Click Credential → OpenID Connect
- Select GitHub as the issuer
- Set the Subject to scope access (e.g.
repo:<owner>/<repo>:ref:refs/heads/mainto restrict to a specific branch) - Under scopes, enable auth_keys: write
- Add
tag:cias the tag - Click Generate credential
- Copy the Client ID and Audience
Step 4: Add GitHub secrets¶
In your repo → Settings → Secrets and variables → Actions, create:
TS_OAUTH_CLIENT_ID— the Client ID from step 3TS_AUDIENCE— the Audience from step 3
Step 5: Create the workflow¶
name: Deploy
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Tailscale
uses: tailscale/github-action@v4
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
audience: ${{ secrets.TS_AUDIENCE }}
tags: tag:ci
- name: Deploy
run: rsync --archive --delete -e "ssh -o StrictHostKeyChecking=accept-new" <build-output>/ <deploy-user>@<server-tailscale-ip>:<deploy-path>/
Key points:
permissions: id-token: writeis required for OIDC federationtailscale/github-action@v4joins the Tailnet as an ephemeraltag:cinode- The node is automatically removed after the job completes
- No SSH keys are involved — Tailscale SSH handles authentication
How it works¶
- GitHub Actions runner starts and requests an OIDC token from GitHub
- The Tailscale action exchanges that token for an ephemeral auth key via the federated identity credential
- The runner joins your Tailnet as a
tag:cinode - ACL rules grant it SSH access to the server as the deploy user
- The workflow can now SSH to the server (rsync, scp, ssh commands, etc.)
- The ephemeral node is removed when the job finishes
Security model¶
- No secrets to rotate — OIDC tokens are short-lived and generated per workflow run
- No SSH keys — Tailscale SSH handles auth based on ACL identity
- No public ports — the server's SSH is only reachable via Tailscale
- Scoped access — the CI runner can only reach one server, one port, one user
- Ephemeral — the runner's Tailnet presence is automatically cleaned up