A GitHub Action that creates a secure, temporary bridge to your private network via Tailscale to deploy or update stacks on a Portainer instance. No public ports, no VPN juggling — just secure CI/CD.
Features
- Zero-Config Tunneling — Automatically joins your Tailnet using ephemeral nodes
- Stack Lifecycle Management — Create, update, or delete Portainer stacks via the API
- Endpoint Auto-Detection — Automatically finds your Portainer environment (single-endpoint setups need no config)
- Private Registry Auth — Configures GHCR, Docker Hub, or any private registry credentials in Portainer
- Intelligent Connectivity Wait — Retry logic with exponential backoff waits for route availability
- Auto-Cleanup — Post-step ensures the ephemeral node is always logged out, even on failures
- MagicDNS Ready — Supports both Tailscale IPs and MagicDNS hostnames
Prerequisites
1. Tailscale Setup
- Go to Tailscale Admin Console → Settings → OAuth Clients
- Click "Generate OAuth Client"
- Select scopes:
devicesandauth_keys(read + write) - Copy the Client ID and Secret → store as GitHub Secrets:
TS_OAUTH_CLIENT_IDTS_OAUTH_SECRET
2. Tailscale ACL Policy
Add tag:ci to your ACL policy (required for OAuth):
{
"tagOwners": {
"tag:ci": ["autogroup:admin"]
}
}Optionally restrict the CI node's access:
{
"acls": [
{
"action": "accept",
"src": ["tag:ci"],
"dst": ["tag:server:9443"]
}
]
}3. Portainer Setup
- In Portainer, go to My Account → Access Tokens → generate a new API key
- Store it as GitHub Secret:
PORTAINER_API_KEY
4. Private Registry (optional)
If your compose file references private images (e.g. from GHCR):
- Create a GitHub PAT (classic) with
read:packagesscope - Store it as GitHub Secret:
GHCR_TOKEN
Usage
Basic
steps: - uses: actions/checkout@v4 - name: Install Tailscale run: curl -fsSL https://tailscale.com/install.sh | sh - name: Deploy to Portainer uses: hackstrix/portainer-tailscale-deployment-action@v1 with: ts_oauth_client_id: ${{ secrets.TS_OAUTH_CLIENT_ID }} ts_oauth_secret: ${{ secrets.TS_OAUTH_SECRET }} portainer_url: 'https://my-server.tailnet.ts.net:9443' portainer_api_key: ${{ secrets.PORTAINER_API_KEY }} stack_name: 'my-app' compose_file: './docker-compose.yml'
With Private Registry (GHCR)
- name: Deploy to Portainer uses: hackstrix/portainer-tailscale-deployment-action@v1 with: ts_oauth_client_id: ${{ secrets.TS_OAUTH_CLIENT_ID }} ts_oauth_secret: ${{ secrets.TS_OAUTH_SECRET }} portainer_url: 'https://my-server.tailnet.ts.net:9443' portainer_api_key: ${{ secrets.PORTAINER_API_KEY }} stack_name: 'my-app' compose_file: './docker-compose.yml' registry_url: 'ghcr.io' registry_username: 'your-username' registry_token: ${{ secrets.GHCR_TOKEN }}
With Environment Variables
- name: Deploy to Portainer uses: hackstrix/portainer-tailscale-deployment-action@v1 with: ts_oauth_client_id: ${{ secrets.TS_OAUTH_CLIENT_ID }} ts_oauth_secret: ${{ secrets.TS_OAUTH_SECRET }} portainer_url: 'https://my-server.tailnet.ts.net:9443' portainer_api_key: ${{ secrets.PORTAINER_API_KEY }} stack_name: 'my-app' compose_file: './docker-compose.yml' env_vars: | NODE_ENV=production DB_PASSWORD=${{ secrets.DB_PASS }}
With Config Files
Upload config files alongside your compose file (applied on stack creation):
- name: Deploy to Portainer uses: hackstrix/portainer-tailscale-deployment-action@v1 with: ts_oauth_client_id: ${{ secrets.TS_OAUTH_CLIENT_ID }} ts_oauth_secret: ${{ secrets.TS_OAUTH_SECRET }} portainer_url: 'https://my-server.tailnet.ts.net:9443' portainer_api_key: ${{ secrets.PORTAINER_API_KEY }} stack_name: 'my-app' compose_file: './docker-compose.yml' config_files: | ./configs/traefik.yml:traefik.yml ./configs/prometheus.yml:monitoring/prometheus.yml
Reference these files with relative volume mounts in your compose file:
services: traefik: volumes: - ./traefik.yml:/etc/traefik/traefik.yml
Note: Config files are uploaded on stack creation only. If you update a stack that already exists, config files are not re-uploaded. To update config files, delete the stack first and redeploy.
Using a Pre-generated Auth Key
If you prefer not to set up OAuth:
- name: Deploy to Portainer uses: hackstrix/portainer-tailscale-deployment-action@v1 with: ts_authkey: ${{ secrets.TS_AUTHKEY }} portainer_url: 'https://my-server:9443' portainer_api_key: ${{ secrets.PORTAINER_API_KEY }} stack_name: 'my-app'
Note: Auth keys expire after 90 days max. OAuth clients don't expire.
Inputs
| Input | Required | Default | Description |
|---|---|---|---|
ts_oauth_client_id |
No* | — | Tailscale OAuth Client ID |
ts_oauth_secret |
No* | — | Tailscale OAuth Client Secret |
ts_authkey |
No* | — | Pre-generated auth key (fallback) |
ts_tags |
No | tag:ci |
ACL tags for the ephemeral node |
ts_hostname |
No | auto-generated | Tailscale hostname |
ts_connect_timeout |
No | 60 |
Seconds to wait for route |
portainer_url |
Yes | — | Portainer URL (e.g. https://host:9443) |
portainer_api_key |
Yes | — | Portainer API key |
stack_name |
Yes | — | Stack name to deploy |
compose_file |
No | ./docker-compose.yml |
Path to compose file |
endpoint_id |
No | 0 (auto-detect) |
Portainer environment ID |
env_vars |
No | — | Multiline KEY=VALUE env vars |
config_files |
No | — | Multiline local_path:remote_path config files (creation only) |
tls_skip_verify |
No | false |
Skip TLS verification |
registry_url |
No | — | Registry URL (e.g. ghcr.io) |
registry_username |
No | — | Registry username |
registry_token |
No | — | Registry password/PAT |
action |
No | deploy |
deploy or delete |
*Either (ts_oauth_client_id + ts_oauth_secret) OR ts_authkey must be provided.
Outputs
| Output | Description |
|---|---|
stack_id |
Portainer stack ID after deployment |
stack_status |
Result: created, updated, or deleted |
How It Works
- Authenticate — Gets an ephemeral auth key via Tailscale OAuth (or uses a provided key)
- Connect — Runs
tailscale upto join the tailnet as an ephemeral node - Wait — Retries until Portainer is reachable over the Tailscale route
- Configure Registry — If credentials provided, creates/updates registry in Portainer
- Auto-Detect Endpoint — If
endpoint_idis0, fetches and uses the available endpoint - Upload Config Files — If
config_filesprovided and stack is new, uploads via multipart form-data - Deploy — Creates a new stack or updates the existing one via Portainer API
- Cleanup — Post-step always runs
tailscale logoutto remove the ephemeral node
Development
# Install dependencies npm install # Run tests npm test # Build (compile + bundle with ncc) npm run build # The dist/ directory must be committed
License
MIT