Extends the action to be a clean deploy leaf called by the gitea-deploy-orchestrator. Backward-compatible with the existing direct-call pattern. New inputs: - env_file .env file (merged with env_vars, env_vars wins) - image pin to specific image:tag (rewrites compose) - image_digest pin to specific sha256:... ref - rollback re-deploys the previous image digest from container labels - previous_image_count how many previous images to track - force_pull always pullImage:true (default true) - prune prune stopped containers on update (default true) - output_file write JSON result the orchestrator can parse - fail_on_healthcheck false = warn instead of fail Bug fixes from the original: - stack_name is now REQUIRED. The old default (derive from repo name) was broken on Gitea because path-style names (owner/repo) produce invalid Portainer stack names. Forces explicit config. - Image/image_digest/rollback now have mutual exclusion checks that fail fast. - env vars are now merged from env_file AND env_vars (env_vars wins), not replaced. New infrastructure: - Structured JSON output (status, action, stack_id, image, duration, error) lets the orchestrator parse deploy outcomes without scraping logs. - Container-label-based rollback: orchestrator writes gitea-deploy-orchestrator.image-digest on every deploy, rollback reads it. Pitfall fixes: - Documented jq requirement (default ubuntu-latest has it, alpine does not). - Documented stack_name as required (was: silently buggy). - Documented rollback label dependency. Tested with ./test.sh: 8/8 cases pass with a mocked Portainer. See gitea-deploy-orchestrator for the consumer.
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, image pinning, and structured JSON output for orchestrators.
This action handles deployment only. Your workflow must build and push images in a preceding step. See the full example below.
This action is the deploy leaf in the gitea-deploy-system. It can be called directly (as below) or by the orchestrator with a rendered compose + env file.
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://portainer:9000'
portainer_token: ${{ secrets.PORTAINER_API_TOKEN }}
stack_name: 'iron-requiem' # REQUIRED — set explicitly
compose_file: 'docker-compose.yml'
registry_url: 'git.homelab.local:8443'
registry_user: ${{ github.actor }}
registry_pass: ${{ secrets.GITEA_TOKEN }}
healthcheck_url: 'https://iron-requiem.damascusfront.net/'
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
portainer_url |
No | http://portainer:9000 |
Full URL to your Portainer instance |
portainer_token |
Yes | — | Portainer API access token. Long-lived OR short-lived (Hermes-issued). |
endpoint_id |
No | 1 |
Portainer environment/endpoint ID |
stack_name |
Yes | — | Name of the stack in Portainer. No default — must be set explicitly to avoid the bug where Gitea path-style repo names (e.g. owner/repo) produce invalid stack names. |
compose_file |
No | docker-compose.yml |
Path to compose file in your repo |
env_file |
No | — | Path to a .env file. Lines become stack env vars. |
env_vars |
No | — | Comma-separated KEY=VALUE pairs (override env_file on conflict). |
image |
No | — | Pin stack to this image:tag (e.g. git.homelab.local:8443/owner/repo:abc123d). Overrides image: line in compose_file. |
image_digest |
No | — | Pin to a specific sha256:... digest. For rollbacks. |
rollback |
No | false |
If true, re-deploys the previous image. Reads from container labels. |
previous_image_count |
No | 2 |
How many previous images to keep in stack history. |
force_pull |
No | true |
Always pullImage: true. Set false if you trust local cache. |
prune |
No | true |
Prune stopped containers on update. |
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 |
fail_on_healthcheck |
No | true |
If false, healthcheck failure is reported as a warning, not a fatal error. |
output_file |
No | — | Path to write a JSON result file. Schema below. |
Output (when output_file is set)
{
"status": "success",
"action": "update",
"stack_id": 7,
"stack_name": "iron-requiem",
"image": "git.homelab.local:8443/kaykayyali/iron-requiem:abc123d",
"image_digest": "",
"duration_seconds": 42,
"healthcheck_status": "passed",
"error": ""
}
The orchestrator parses this to decide what to tell Hermes. If the action fails, status is
failure and error contains the diagnostic.
How it works
- Validate — check required inputs, mutual exclusions (
imagevsimage_digestvsrollback). - Env — merge
env_file(if set) withenv_vars(if set),env_varswins on conflict. - Compose — if
imageorimage_digestis set, rewrite the firstimage:line in the compose. - Check — fetch existing stacks from Portainer, look for a match by name.
- Rollback (if
rollback=true) — read container labels to find the previous image digest. - Create or Update — POST or PUT to Portainer with
pullImageand (for update)pruneflags. - Verify — if
healthcheck_urlis set, poll it. Report success/failure/skip. - Report — write JSON to
output_fileif set. Always log a one-line summary.
Pitfalls (read before filing issues)
stack_nameis required. The old default (derive from repo name) was broken on Gitea because path-style names likekaykayyali/iron-requiemproduce invalid Portainer stack names.imagevsimage_digestare mutually exclusive. The action refuses to run if both are set.- Rollback relies on container labels. The
gitea-deploy-orchestratorwrites agitea-deploy-orchestrator.image-digestlabel on every deploy. If you call this action directly (not via the orchestrator) and want rollback to work, you must set the label yourself or the previous image lookup will fail. - Env file format.
KEY=VALUEper line,#for comments, blank lines ignored. Values are passed verbatim to Portainer (no shell expansion). - The
jqrequirement. This action needsjqin the runner image. The defaultruns-on: ubuntu-latesthas it. The defaultcontainer: alpinedoes not — addapk add jqfirst.
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.
Secrets
Add these in your Gitea repo: Settings → Secrets → Add secret
| Secret | Purpose |
|---|---|
PORTAINER_API_TOKEN |
Portainer access token with permission to manage stacks. In the orchestrator flow, this is replaced by a short-lived token issued by Hermes at deploy time — see gitea-deploy-orchestrator. |
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
If fail_on_healthcheck: false, a healthcheck failure is reported in the JSON output but does not
fail the action. Use this for "fire and observe" deploys where you want to see what happens.
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.
Why is stack_name required?
The old default derived it from github.event.repository.name, which on Gitea includes the owner:
kaykayyali/iron-requiem. Portainer rejected that. The new version makes you set it explicitly
so the failure is at config time, not at deploy time.
How do I roll back a bad deploy?
Set rollback: true and the action re-deploys the previous image digest it found in container
labels. The orchestrator exposes this as a webhook (/webhooks/rollback).
See also
- gitea-deploy-orchestrator — the GitOps control plane that calls this action
- gitea-runner-bootstrap — installs the runner that executes the workflow
- ARCHITECTURE.md — the full system design