-
Notifications
You must be signed in to change notification settings - Fork 0
CI CD
podman-api exposes everything a CI pipeline needs to deploy preview, staging, and production instances from a Forgejo (or any other) pipeline.
PUT /hosts/{host}/instances/{template}/{slug} is the one endpoint you need. It:
- Pulls the container image (unless
?skip_pull=true) - Renders the template with the parameters you supply
- Creates or replaces the pod in-place (idempotent)
- Waits for container healthchecks to pass (up to the daemon's verify timeout)
- If the template declares
ingress:and you passdomains, updates the Caddy reverse-proxy on the host automatically — one Caddyfile per host, regenerated atomically, zero-downtime reload
The response is an Observed object with ready: true/false, container states,
and any readiness warnings.
CI pipelines need a key with the minimum scope to deploy:
podman-api hash-token "$(openssl rand -base64 32)"
# prints: $argon2id$v=19$m=65536,t=3,p=4$...Add it to /etc/podman-api/keys.yaml:
keys:
# ... existing keys ...
- id: ci-forgejo
secret_hash: '$argon2id$v=19$m=65536,t=3,p=4$<output from above>'
scopes: [hosts:read, instances:write]
description: "Forgejo CI runner"Then reload the daemon without restarting (keys reload on SIGHUP):
kill -HUP $(systemctl show -p MainPID --value podman-api)Store the plaintext token as a Forgejo repository secret (PODMAN_API_TOKEN).
The hash in keys.yaml is useless to an attacker — only the plaintext matters.
Scope note:
instances:writeallows create, replace, start, stop, upgrade, and delete. It does not grant access to the template catalog, secrets store, or migrate/evacuate jobs. If the pipeline also needs to manage templates, addtemplates:write.
Set these repository variables/secrets:
| Name | Value |
|---|---|
PODMAN_API_URL |
https://api.example.com |
PODMAN_API_TOKEN |
plaintext token from above |
DEPLOY_HOST |
target host id (matches a hosts/<id>.yaml filename) — must be the host whose public IP the domain resolves to |
BASE_DOMAIN |
e.g. preview.example.com
|
DEPLOY_HOSTmust match the DNS target. If the domain resolves toengine-1's IP, setDEPLOY_HOST=engine-1. Deploying to the wrong host results in a running pod that is unreachable at the domain.
The "domains" field in a deploy request activates podman-api's built-in
ingress controller, which spins up its own Caddy pod (podman-api-ingress-caddy)
on the target host. Do not use "domains" if the host already has a Caddy
(or other reverse proxy) managing port 80/443 — the two will conflict and
the deploy will return a 500.
If you are routing through an existing reverse proxy (e.g. a manually managed
caddy-edge pod), omit "domains" from the deploy body entirely. Instead:
-
Your template must expose a
portparameter bound tohostIP: 127.0.0.1so the pod publishes a unique host port:ports: - containerPort: 80 hostPort: {{.port}} hostIP: 127.0.0.1
-
Pass that port in the deploy parameters:
{ "parameters": { "slug": "dev", "image": "...", "port": 31029 } } -
Add the upstream to your Caddyfile and reload:
dev.myapp.example.com { reverse_proxy 127.0.0.1:31029 }
Pick a port that is unique across all instances on the host. Ports in the
31000–31999 range are conventionally used for podman-api managed apps.
on: [pull_request]
jobs:
preview:
runs-on: ubuntu-latest
steps:
- name: Deploy preview
run: |
curl -fsS -X PUT \
"$PODMAN_API_URL/hosts/$DEPLOY_HOST/instances/web/${{ gitea.event.pull_request.number }}" \
-H "Authorization: Bearer $PODMAN_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"parameters": {"image": "registry.example.com/myapp:${{ gitea.sha }}"}
}'on:
push:
branches: [main]
jobs:
deploy-prod:
runs-on: ubuntu-latest
steps:
- name: Deploy production
run: |
curl -fsS -X PUT \
"$PODMAN_API_URL/hosts/$DEPLOY_HOST/instances/web/prod" \
-H "Authorization: Bearer $PODMAN_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"parameters": {"image": "registry.example.com/myapp:${{ gitea.sha }}"}
}'on:
pull_request:
types: [closed]
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Delete preview instance
run: |
curl -fsS -X DELETE \
"$PODMAN_API_URL/hosts/$DEPLOY_HOST/instances/web/${{ gitea.event.pull_request.number }}" \
-H "Authorization: Bearer $PODMAN_API_TOKEN"When using an existing reverse proxy, remember to remove the upstream route from its config when tearing down an instance — podman-api only manages the pod lifecycle, not your proxy.
A successful deploy returns HTTP 200 with an Observed object:
{
"template": "web",
"slug": "42",
"ready": true,
"pod": { "status": "Running", ... },
"containers": [{ "name": "web-42-app", "health": "healthy", ... }],
"warnings": []
}ready: false with entries in warnings means the pod started but healthchecks
did not pass within the verify window — the app may still be initialising. Treat
it as a soft warning; poll GET .../instances/web/42 to watch it come up, or
check warnings[0] for the timeout message.
A non-2xx response body always contains {"code": "...", "message": "..."}.
curl -fsS turns any HTTP error into a non-zero exit code, which fails the CI
step automatically.
Templates define what gets deployed. They are stored in the daemon's SQLite catalog
and referenced by id in the PUT /hosts/{host}/instances/{template}/{slug} path.
Add a file under templates/ in the repository. The file must start with a
# template-meta: comment block followed by a Kubernetes Pod YAML body:
# template-meta:
# id: my-app
# display:
# name: My App
# description: Runs my-app and exposes port 8080.
# category: Apps
# parameters:
# - name: slug
# type: string
# required: true
# - name: image
# type: string
# required: true
# label: Image
# description: Container image (e.g. registry.example.com/my-app:latest)
# ingress:
# container: app
# port: 8080
# networks: # optional: shared podman networks, independent of ingress
# - my-shared-net
---
apiVersion: v1
kind: Pod
metadata:
name: my-app-{{.slug}}
labels:
podman-api/template: my-app
podman-api/slug: {{.slug}}
spec:
containers:
- name: app
image: {{.image}}
ports:
- containerPort: 8080Templates in templates/ are seeded into an empty catalog on first boot.
If the catalog already has templates, the seed step is skipped — use Option B
or the API to add new templates to a running daemon.
| Type | Notes |
|---|---|
string |
Default when type is omitted |
int |
Rendered as a bare integer |
bool |
Rendered as true or false
|
select |
Must also set options: [...]
|
Add default: to make a parameter optional. Add secret: true to hide the
value from logs and the admin UI.
Register a template against a running daemon with POST /templates. The key
difference from the YAML file format is that body (the Pod YAML) and the
metadata fields are sent as separate JSON keys rather than combined in one file.
curl -fsS -X POST "$PODMAN_API_URL/templates" \
-H "Authorization: Bearer $PODMAN_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"id": "my-app",
"display": {
"name": "My App",
"description": "Runs my-app and exposes port 8080.",
"category": "Apps"
},
"parameters": [
{"name": "slug", "type": "string", "required": true},
{"name": "image", "type": "string", "required": true, "label": "Image"}
],
"ingress": {"container": "app", "port": 8080},
"body": "apiVersion: v1\nkind: Pod\nmetadata:\n name: my-app-{{.slug}}\n labels:\n podman-api/template: my-app\n podman-api/slug: {{.slug}}\nspec:\n containers:\n - name: app\n image: {{.image}}\n ports:\n - containerPort: 8080\n"
}'Returns HTTP 201 with the stored template on success.
To update an existing template in place use PUT /templates/{id} (same body,
omit "id"). Existing instances are not re-rendered; they pick up the new
template on the next deploy.
To create a variant of an existing template without writing it from scratch:
curl -fsS -X POST "$PODMAN_API_URL/templates/my-app/clone" \
-H "Authorization: Bearer $PODMAN_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"new_id": "my-app-staging"}'Then PUT /templates/my-app-staging to adjust the clone.
Check that parameter substitution renders correctly before committing to a deploy:
curl -fsS "$PODMAN_API_URL/templates/my-app/render?slug=test&image=registry.example.com/my-app:latest"Returns the rendered Pod YAML. Useful in CI to catch template errors early.
Template management endpoints require the templates:write scope.
Add it alongside instances:write for pipelines that also register templates:
scopes: [hosts:read, instances:write, templates:write]Domains are optional. If the template does not declare ingress: in its metadata
or ingress is not enabled on the daemon, omit the domains field and the deploy
works fine — the pod is accessible on its published host ports only.
When domains is present the daemon:
- Joins the pod to the shared ingress network
- Regenerates and reloads the host-wide Caddyfile (one file for all domains on that host, sorted for stability)
- Obtains a TLS certificate via ACME automatically (requires the domain to resolve to the host's public IP and ports 80/443 to be reachable)
See Deploying for how to enable ingress (-ingress-network, -caddy-image,
-acme-email flags).