- Use jq for JSON payload construction (no more string interpolation) - Add env_vars input — parsed into proper Portainer env array - Add registry_url/user/pass inputs for private registry auth - Fix pullImage: true on create path (was only on update) - Add healthcheck polling loop with configurable retries/delay - Fix stack_name default — resolve in script, not action default expression - Rewrite README with full build+push+deploy example
Portainer Stack Deploy Action
A reusable Gitea composite action that creates or updates a Docker Compose stack via the Portainer API. Idempotent — creates the stack if it doesn't exist, updates it (with fresh image pulls) if it does. Includes built-in healthcheck verification.
This action handles deployment only. Your workflow must build and push images in a preceding step. See the full example below.
Quick Start
# .gitea/workflows/deploy.yml
name: Build & Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build and push image
run: |
docker build -t git.homelab.local:8443/${GITHUB_REPOSITORY}:latest .
echo "${{ secrets.GITEA_TOKEN }}" | docker login git.homelab.local:8443 -u ${{ github.actor }} --password-stdin
docker push git.homelab.local:8443/${GITHUB_REPOSITORY}:latest
- name: Deploy to Portainer
uses: kaykayyali/portainer-deploy-action@main
with:
portainer_url: 'http://192.168.1.200:9000'
portainer_token: ${{ secrets.PORTAINER_API_TOKEN }}
registry_url: 'git.homelab.local:8443'
registry_user: ${{ github.actor }}
registry_pass: ${{ secrets.GITEA_TOKEN }}
env_vars: 'NODE_ENV=production,PUBLIC_API_URL=https://api.example.com'
healthcheck_url: 'https://app.damascusfront.net/health'
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
portainer_url |
No | http://portainer:9000 |
Full URL to your Portainer instance |
portainer_token |
Yes | — | Portainer API access token |
endpoint_id |
No | 1 |
Portainer environment/endpoint ID |
stack_name |
No | repo name | Name of the stack in Portainer. Falls back to github.event.repository.name if empty. |
compose_file |
No | docker-compose.yml |
Path to compose file in your repo |
env_vars |
No | — | Comma-separated KEY=VALUE pairs passed as stack environment variables |
registry_url |
No | — | Private registry URL if Portainer needs auth to pull images |
registry_user |
No | — | Registry username (pair with registry_url) |
registry_pass |
No | — | Registry password or token (pair with registry_url) |
healthcheck_url |
No | — | URL to poll after deploy. HTTP 2xx = success. Empty = skip. |
healthcheck_retries |
No | 12 |
Max retries for healthcheck polling |
healthcheck_delay |
No | 10 |
Seconds between healthcheck attempts |
How It Works
- Check — fetches existing stacks from Portainer, looks for a match by name
- Create or Update — if the stack exists, PUTs an update with
pullImage: trueandprune: true. If not, POSTs a new stack with optional registry credentials - Verify — if
healthcheck_urlis set, polls the endpoint up tohealthcheck_retriestimes withhealthcheck_delayseconds between attempts. Fails the workflow if the healthcheck never passes
Both create and update paths pass pullImage: true, so :latest tags always pull fresh images.
Registry Auth
If your compose file references images in a private registry (e.g., Gitea's container registry), Portainer needs credentials to pull them. Set all three:
registry_url: 'git.homelab.local:8443'
registry_user: ${{ github.actor }}
registry_pass: ${{ secrets.GITEA_TOKEN }}
These become a registries array in the Portainer API payload, so the target Docker engine can authenticate.
Secrets
Add these in your Gitea repo: Settings → Secrets → Add secret
| Secret | Purpose |
|---|---|
PORTAINER_API_TOKEN |
Portainer access token with permission to manage stacks |
GITEA_TOKEN |
Gitea token with write:package scope (for pushing images + registry auth) |
Healthcheck Verification
Without a healthcheck, the action can only confirm that Portainer accepted the compose file — not that containers actually started correctly. Set healthcheck_url to a known-good endpoint:
- For web apps:
https://app.example.com/healthor just the root/ - For APIs: a
/healthzor/readyendpoint that returns 2xx - For services without HTTP: leave empty and rely on Portainer logs directly
FAQ
Why doesn't this action build Docker images?
Separation of concerns. Build and push are two lines in your workflow — you control caching, platforms, tags, and registries. This action focuses on the one thing Portainer does uniquely: managing compose stacks.
Can I use this with images from Docker Hub?
Yes — just omit registry_url/registry_user/registry_pass. Portainer pulls from Docker Hub by default.
What if my stack needs secrets, not just env vars?
Use Portainer's built-in secret management in the UI for sensitive values. The env_vars input is for non-sensitive configuration (NODE_ENV, PUBLIC_API_URL, etc.). Never pass tokens or passwords through env_vars.