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.
- 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
- 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
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"]
}
]
}- In Portainer, go to My Account → Access Tokens → generate a new API key
- Store it as GitHub Secret:
PORTAINER_API_KEY
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
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' - 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 }} - 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 }}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.ymlReference these files with relative volume mounts in your compose file:
services:
traefik:
volumes:
- ./traefik.yml:/etc/traefik/traefik.ymlNote: 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.
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.
| 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.
| Output | Description |
|---|---|
stack_id |
Portainer stack ID after deployment |
stack_status |
Result: created, updated, or deleted |
- 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
# Install dependencies
npm install
# Run tests
npm test
# Build (compile + bundle with ncc)
npm run build
# The dist/ directory must be committedMIT