Skip to content
Tej Pochiraju edited this page Jun 27, 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)
BASE_DOMAIN e.g. preview.example.com

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 }}"},
              "domains": ["pr-${{ gitea.event.pull_request.number }}.${{ env.BASE_DOMAIN }}"]
            }'

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 }}"},
              "domains": ["app.example.com"]
            }'

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"

Caddy routes for the deleted instance are removed automatically on the next apply to that host (or on the next daemon reconcile cycle).

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.

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

Clone this wiki locally