Tools
Tools: GitHub Actions to VPS: Zero-Trust with Tailscale
2026-01-23
0 views
admin
Architecture Overview ## Prerequisites ## Step 1: Configure Tailscale OAuth Client ## Step 2: Configure GitHub Secrets ## Step 3: GitHub Actions Workflow Configuration ## Step 4: Tailscale ACL Configuration ## How It Works ## Security Benefits ## Troubleshooting ## Conclusion 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. 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. 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 Setting up Tailscale on a Server, Using Tags 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 Store the OAuth Client ID and Secret securely—you'll add them to GitHub Secrets in the next step. Add the following secrets to your GitHub repository: Also, configure a repository variable: References: GitHub Encrypted Secrets, GitHub Variables Add the Tailscale GitHub Action to your workflow before any steps that need VPS access: Complete Example (deployment workflow): 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. Configure your Tailscale ACL to grant the CI runner tag access to your VPS tag. Here's a minimal configuration: Ensure your VPS is tagged with tag:app-server in the Tailscale admin console. 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. References: Zero Trust Networking If connectivity issues occur, use the ping parameter to verify connectivity: 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'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. Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse CODE_BLOCK:
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 CODE_BLOCK:
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 CODE_BLOCK:
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 CODE_BLOCK:
- 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 CODE_BLOCK:
- 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 }} CODE_BLOCK:
- 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 }} CODE_BLOCK:
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 CODE_BLOCK:
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 CODE_BLOCK:
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 CODE_BLOCK:
{ "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 CODE_BLOCK:
{ "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"] } ]
} CODE_BLOCK:
{ "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"] } ]
} CODE_BLOCK:
- 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 CODE_BLOCK:
- 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 CODE_BLOCK:
- 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 CODE_BLOCK:
tailscale ping vps-name.tailnet-name.ts.net Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
tailscale ping vps-name.tailnet-name.ts.net CODE_BLOCK:
tailscale ping vps-name.tailnet-name.ts.net - 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 - 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 - TS_CI_RUNNER_TAG: The tag assigned to ephemeral nodes (e.g., tag:app-ci) - 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 - Workflow starts: GitHub Action installs Tailscale client on the GitHub runner
- Ephemeral node created: The Tailscale client authenticates with OAuth and creates an ephemeral node with tag:app-ci that joins your tailnet
- Tag-based identity: The tag (tag:app-ci) identifies this node as a CI/CD runner
- ACL enforcement: ACL rules grant the tag:app-ci access to tag:app-server resources
- Connection established: The GitHub runner connects to the VPS via the Tailscale network using the VPS's Tailscale hostname or IP
- Automatic cleanup: When the workflow completes, the ephemeral node logs out and is automatically removed from your tailnet - 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
how-totutorialguidedev.toaimlubuntuservernetworknetworkingdnsvpnwireguardnodeansible