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.
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
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.
Use a Tailscale OAuth client or another supported auth method designed for automation. Store those credentials in GitHub repository or environment secrets.
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.
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
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.