Using Tailscale in GitHub Actions for Secure Deployment

Public SSH endpoints, long-lived deploy keys, and open firewall rules are still common in CI pipelines. Tailscale gives GitHub Actions a private, encrypted path into your infrastructure so deployments can happen without exposing servers to the public internet.

Why use Tailscale for deployment?

A typical GitHub Actions deployment pipeline needs to reach something sensitive: a VM, a Docker host, a Kubernetes control plane, a staging database, or an internal admin endpoint. The usual shortcut is to expose SSH or an API port to the internet and then restrict access with IP rules, keys, or both. That works, but it increases attack surface and makes secret management harder than it needs to be.

Tailscale changes that model. Instead of making your infrastructure reachable from everywhere, you join your servers and your GitHub Actions job to the same private tailnet. The runner gets secure network access only for the duration of the workflow, and your target machines can stay off the public internet.

Traditional CI deployment Public IPs, open inbound ports, firewall exceptions, long-lived credentials, and more operational risk.
Tailscale-based deployment Private addressing, authenticated device identity, scoped access policies, and less exposure by default.
The biggest security improvement is often simple: your deployment target no longer needs to accept inbound traffic from the public internet at all.

How it works inside GitHub Actions

At runtime, your workflow starts on a GitHub-hosted runner. The Tailscale GitHub Action authenticates that runner into your tailnet using credentials you store as GitHub secrets. Once connected, the runner can reach private machines by their Tailscale name or tailnet IP, just like any other trusted device on your network.

From there, deployment is normal shell automation. You can use ssh, rsync, scp, internal HTTP endpoints, or private service discovery. The difference is that your network path is private and identity-aware rather than publicly routable.

Basic setup

Join your deployment target to Tailscale

Install Tailscale on the server or VM that receives deployments. Confirm it can be reached from other devices in your tailnet by hostname or Tailscale IP.

Create auth for CI

Use a Tailscale OAuth client or another supported auth method designed for automation. Store those credentials in GitHub repository or environment secrets.

Apply access controls

Restrict the CI identity so it can only reach the exact hosts and ports needed for deployment. The runner should not get broad access to your whole tailnet.

Run deploy commands over the tailnet

Once the action connects, your pipeline can execute deployment commands against internal infrastructure without opening those services to the public internet.

Example workflow

The following workflow checks out your repository, joins the runner to Tailscale, verifies connectivity, and deploys over SSH to a private server.

name: Deploy app

on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Connect runner to Tailscale
        uses: tailscale/github-action@v3
        with:
          oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
          oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
          tags: tag:ci

      - name: Check connectivity
        run: |
          ping -c 2 my-app-server || true
          ssh -o StrictHostKeyChecking=no deploy@my-app-server 'hostname'

      - name: Upload release bundle
        run: |
          tar -czf release.tar.gz .
          scp -o StrictHostKeyChecking=no release.tar.gz deploy@my-app-server:/tmp/release.tar.gz

      - name: Run deployment script
        run: |
          ssh -o StrictHostKeyChecking=no deploy@my-app-server '
            set -e
            mkdir -p /srv/app
            tar -xzf /tmp/release.tar.gz -C /srv/app
            cd /srv/app
            ./deploy.sh
          '

In a production setup, you would usually replace StrictHostKeyChecking=no with pinned host key verification, and you would make the remote user as limited as possible.

Security best practices

1. Give CI its own identity

Treat GitHub Actions as a machine actor, not as a human administrator. Tag it separately, write specific access rules for it, and avoid reusing credentials meant for developer laptops.

2. Limit what the runner can reach

Your workflow probably does not need access to every internal service. Allow only the deploy target, and only on the ports the job truly needs, such as SSH or a private HTTPS endpoint.

3. Prefer ephemeral, short-lived trust

GitHub-hosted runners are naturally short-lived, which is a good fit for deployment. Use that property to your advantage: connect for the job, deploy, and let access disappear when the run ends.

4. Separate environments

Staging and production should not share the same blast radius. Use different tags, different target machines, and ideally different secrets or approval gates for each environment.

5. Keep the remote side locked down

Tailscale protects the transport path, but your destination server still matters. Use a low-privilege deploy user, minimal sudo rules, and a clear deployment script instead of ad hoc shell commands.

Good deployment use cases

  • Deploying to a private VPS without exposing SSH publicly
  • Sending builds to a self-hosted Docker or Podman host
  • Triggering internal deployment hooks behind a private reverse proxy
  • Running database migrations against a private network endpoint
  • Accessing a private Kubernetes API from CI without opening it to the internet

Common pitfalls

Overly broad access rules If your CI tag can talk to everything, you lose much of the security benefit.
Weak remote permissions A private network path is not a replacement for least privilege on the destination host.
Skipping host verification It is easy to disable SSH checks in examples. In real deployments, pin host keys.
Mixing staging and production Shared secrets and shared targets make mistakes easier and rollback harder.

Tailscale vs traditional VPN-style deployment access

Traditional VPNs can also give CI access to internal systems, but they often assume a network perimeter model: once connected, you are broadly inside. Tailscale is better aligned with modern, identity-aware access. Instead of only asking whether a runner is on the network, you can ask which actor it is, which tag it has, and what exactly it is allowed to touch.

That makes Tailscale especially appealing for GitHub Actions, where ephemeral jobs need narrow, auditable access for a short period of time.