Skip to content

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:

"tagOwners": {
    "tag:ci":     ["your-user@github"],
    "tag:server": ["your-user@github"]
}

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

  1. Go to Tailscale admin → Trust credentials
  2. Click CredentialOpenID Connect
  3. Select GitHub as the issuer
  4. Set the Subject to scope access (e.g. repo:<owner>/<repo>:ref:refs/heads/main to restrict to a specific branch)
  5. Under scopes, enable auth_keys: write
  6. Add tag:ci as the tag
  7. Click Generate credential
  8. 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 3
  • TS_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: write is required for OIDC federation
  • tailscale/github-action@v4 joins the Tailnet as an ephemeral tag:ci node
  • The node is automatically removed after the job completes
  • No SSH keys are involved — Tailscale SSH handles authentication

How it works

  1. GitHub Actions runner starts and requests an OIDC token from GitHub
  2. The Tailscale action exchanges that token for an ephemeral auth key via the federated identity credential
  3. The runner joins your Tailnet as a tag:ci node
  4. ACL rules grant it SSH access to the server as the deploy user
  5. The workflow can now SSH to the server (rsync, scp, ssh commands, etc.)
  6. 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