DEV Community

Sudhir
Sudhir

Posted on

GitHub Actions to VPS: Zero-Trust with Tailscale

Deploying to private VPS servers from GitHub Actions typically requires exposing SSH ports or maintaining complex VPN configurations. Tailscale's zero-trust networking eliminates these security risks by creating ephemeral, encrypted connections between GitHub-hosted runners and your infrastructure.

This guide shows how to connect GitHub Actions to a private VPS using Tailscale's OAuth-based authentication, following the principle of least privilege.

Architecture Overview

When a GitHub Actions workflow runs, the Tailscale GitHub Action installs the Tailscale client on the GitHub runner, creating an ephemeral Tailscale node that joins your tailnet. This ephemeral node receives a tag-based identity (tag:app-ci) that identifies its role as a CI/CD runner. Access to resources is granted based on ACL rules matching this tag. After the workflow completes, the ephemeral node is automatically removed.

Secure connection flow between Github runner and VPS via tailnet

The ephemeral node is the Tailscale client running on the GitHub runner. It's tagged with tag:app-ci to identify its role, and ACL rules grant this tag access to your VPS (tag:app-server). All traffic is encrypted via WireGuard, and your VPS never needs public-facing ports.

Tailscale Ephemeral Nodes, WireGuard Encryption

Prerequisites

  • Tailscale account with Owner, Admin, or Network admin permissions
  • GitHub repository with admin access
  • VPS running Tailscale with appropriate tags
  • At least one configured tag in your tailnet

Setting up Tailscale on a Server, Using Tags

Step 1: Configure Tailscale OAuth Client

Create an OAuth client in the Tailscale admin console. This is preferred over auth keys because it provides automatic cleanup and better security isolation.

Tailscale OAuth Clients

Required OAuth Scopes (principle of least privilege):
Configured in Trust credentials settings in Tailscale admin console

devices:core
devices:core:read
devices:posture_attributes
devices:posture_attributes:read
devices:routes:read
device_invites:read
policy_file:read
dns:read
api_access_tokens:read
auth_keys
oauth_keys
users:read
services
Enter fullscreen mode Exit fullscreen mode

Store the OAuth Client ID and Secret securely—you'll add them to GitHub Secrets in the next step.

Step 2: Configure GitHub Secrets

Add the following secrets to your GitHub repository:

  • TS_OAUTH_CLIENT_ID: Your Tailscale OAuth Client ID
  • TS_OAUTH_SECRET: Your Tailscale OAuth Client Secret
  • VPS_HOST: Tailscale hostname or IP of your VPS (e.g., vps-name.tailnet-name.ts.net)
  • VPS_USER: SSH user for VPS access

Also, configure a repository variable:

  • TS_CI_RUNNER_TAG: The tag assigned to ephemeral nodes (e.g., tag:app-ci)

References: GitHub Encrypted Secrets, GitHub Variables

Step 3: GitHub Actions Workflow Configuration

Add the Tailscale GitHub Action to your workflow before any steps that need VPS access:

- name: Setup Tailscale
  uses: tailscale/github-action@v4
  with:
    oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
    oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
    tags: ${{ vars.TS_CI_RUNNER_TAG }}
Enter fullscreen mode Exit fullscreen mode

Complete Example (deployment workflow):

name: Deploy to VPS

on:
  workflow_dispatch:

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Tailscale
        uses: tailscale/github-action@v4
        with:
          oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
          oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
          tags: ${{ vars.TS_CI_RUNNER_TAG }}

      - name: Deploy via Ansible
        working-directory: infrastructure/ansible
        env:
          VPS_HOST: ${{ secrets.VPS_HOST }}
          VPS_USER: ${{ secrets.VPS_USER }}
        run: |
          ansible-playbook playbooks/deploy.yml
Enter fullscreen mode Exit fullscreen mode

After the Tailscale step, your workflow can access the VPS using its Tailscale hostname or IP. The ephemeral node is automatically logged out and removed when the workflow completes.

Step 4: Tailscale ACL Configuration

Configure your Tailscale ACL to grant the CI runner tag access to your VPS tag. Here's a minimal configuration:

{
  "tagOwners": {
    "tag:app-server": ["autogroup:admin"],
    "tag:app-ci": ["autogroup:admin"]
  },
  "grants": [
    {
      "src": ["tag:app-ci"],
      "dst": ["tag:app-server"],
      "ip": ["*"]
    }
  ],
  "ssh": [
    {
      "action": "accept",
      "src": ["tag:app-ci"],
      "dst": ["tag:app-server"],
      "users": ["autogroup:nonroot", "root"]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Key Points:

  • tagOwners defines who can assign tags (admins in this case)
  • grants allows tag:app-ci to reach tag:app-server on all ports
  • ssh rules explicitly permit SSH access from CI runners to servers

Ensure your VPS is tagged with tag:app-server in the Tailscale admin console.

How It Works

  1. Workflow starts: GitHub Action installs Tailscale client on the GitHub runner
  2. Ephemeral node created: The Tailscale client authenticates with OAuth and creates an ephemeral node with tag:app-ci that joins your tailnet
  3. Tag-based identity: The tag (tag:app-ci) identifies this node as a CI/CD runner
  4. ACL enforcement: ACL rules grant the tag:app-ci access to tag:app-server resources
  5. Connection established: The GitHub runner connects to the VPS via the Tailscale network using the VPS's Tailscale hostname or IP
  6. Automatic cleanup: When the workflow completes, the ephemeral node logs out and is automatically removed from your tailnet

The ephemeral node is the Tailscale client running on the GitHub runner. It's not a separate machine—it's the Tailscale connection that enables the runner to reach your private VPS. Ephemeral nodes are pre-approved on tailnets with device approval enabled, eliminating manual approval steps.

Security Benefits

  • No public ports: VPS remains completely private
  • Least privilege: OAuth scopes and ACL tags limit access to the minimum required actions
  • Ephemeral access: Nodes exist only during workflow execution
  • Encrypted traffic: All communication uses WireGuard encryption
  • Zero-trust: Every connection is authenticated and authorized

References: Zero Trust Networking

Troubleshooting

If connectivity issues occur, use the ping parameter to verify connectivity:

- name: Setup Tailscale
  uses: tailscale/github-action@v4
  with:
    oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
    oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
    tags: ${{ vars.TS_CI_RUNNER_TAG }}
    ping: vps-name.tailnet-name.ts.net
Enter fullscreen mode Exit fullscreen mode

Alternatively, test connectivity manually by adding the app-ci tag to your personal laptop and check if the tailscale ACL rules are working as expected

tailscale ping vps-name.tailnet-name.ts.net
Enter fullscreen mode Exit fullscreen mode

Conclusion

Tailscale's GitHub Action provides a secure, zero-trust way to connect CI/CD pipelines to private infrastructure. By using OAuth-based authentication and tag-based ACLs, you eliminate the need for public-facing ports while maintaining granular access control.

The ephemeral node pattern ensures that access is temporary and automatically cleaned up, reducing the attack surface compared to persistent VPN connections or exposed SSH ports.


Top comments (0)