Skip to content
Tej Pochiraju edited this page Aug 15, 2026 · 5 revisions

CI/CD with podman-api

podman-api exposes everything a CI pipeline needs to deploy preview, staging, and production instances from a Forgejo (or any other) pipeline.

How it works

PUT /hosts/{host}/instances/{template}/{slug} is the one endpoint you need. It:

  1. Pulls the container image (unless ?skip_pull=true)
  2. Renders the template with the parameters you supply
  3. Creates or replaces the pod in-place (idempotent)
  4. Waits for container healthchecks to pass (up to the daemon's verify timeout)
  5. If the template declares ingress: and you pass domains, 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.

Mint a CI bearer key

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:write allows 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, add templates:write.

Forgejo workflow examples

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_HOST must match the DNS target. If the domain resolves to engine-1's IP, set DEPLOY_HOST=engine-1. Deploying to the wrong host results in a running pod that is unreachable at the domain.

Ingress: podman-api managed vs. existing Caddy

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:

  1. Your template must expose a port parameter bound to hostIP: 127.0.0.1 so the pod publishes a unique host port:

    ports:
      - containerPort: 80
        hostPort: {{.port}}
        hostIP: 127.0.0.1
  2. Pass that port in the deploy parameters:

    { "parameters": { "slug": "dev", "image": "...", "port": 31029 } }
  3. 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 3100031999 range are conventionally used for podman-api managed apps.

Deploy a preview on every PR push

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 }}"}
            }'

Promote to production on merge

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 }}"}
            }'

Tear down preview on PR close

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.

Reading the response

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.

Registering a template

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.

Option A — YAML file (ship with the binary)

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: 8080

Templates 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.

Parameter types

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.

Option B — API (runtime, no redeploy needed)

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.

Clone and customise

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.

Preview before deploying

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.

Required scope

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]

Ingress / Caddy

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).