Skip to main content

Automatic deployment from GitHub Actions

This how-to shows how a GitHub Actions workflow can build a container image, log in to an Avassa Control Tower with the workflow's GitHub OIDC token, and roll the new image out to an existing deployment — without any long-lived Avassa credentials in the repository.

When to use this how-to

This guide targets a simple, common shape:

  • One application, one container.
  • One deployment in one Control Tower.
  • One GitHub repository builds the image and triggers the rollout.

If you are deploying multiple applications, multiple deployments, or across multiple Control Towers, the same building blocks apply but you will likely want to factor the workflow differently (matrix builds, reusable workflows, separate roles per environment).

Where the application specification lives — choose first

Before you wire anything up, decide which of two patterns you want. Both are supported; they differ only in what the CI job changes and where the source of truth lives.

Pattern A — patch from CIPattern B — spec in repo
Source of truthThe application spec stored in Control TowerThe application spec YAML in the repo
What CI does on each releasePATCH only the image and version fieldsPUT/replace the full application spec
Spec contents in the repoOnly image/version snippets in the workflowFull application.yaml checked in
Easier to keep secrets/site-specific bits out of the repoYesNo — they end up in the spec file
Full change history visible in git logNo (history lives in CT, which keeps few revisions)Yes
Risk of repo and CT driftingLow (CI only touches a couple of fields)None as long as CI is the only writer

Pattern A keeps the repository free of operational details and is a good fit when the spec evolves through Control Tower (UI, supctl) and only the image changes per build. Pattern B is the more conventional GitOps shape and is a good fit when you want every configuration change to go through a pull request.

The rest of this guide shows the shared setup and then provides the deploy step for each pattern. The example application is called winecellar; substitute your own name throughout.

1. Configure JWT auth in Avassa for GitHub OIDC

GitHub Actions can mint a short-lived OIDC token per workflow run. We configure Strongbox to trust GitHub's OIDC issuer and to grant a specific role when the token's claims match.

1.1 Create the JWT auth method

supctl create strongbox authentication jwt <<'EOF'
name: github-actions
discovery-url: https://token.actions.githubusercontent.com/.well-known/openid-configuration
issuer: https://token.actions.githubusercontent.com
EOF

Fields with sensible defaults (jwks-tls-verify, jwks-refresh-interval, allowed-clock-skew, require-exp, verbose-logging) are left out; add them only if you need to override the defaults.

1.2 Create a deploy role

The role binds the OIDC token's claims to a Strongbox token with a specific policy. The audience and the repo/ref claims here are what constrains which GitHub workflow can use this role — get them wrong and any GitHub repo could log in.

supctl create strongbox authentication jwt github-actions roles <<'EOF'
name: deploy
# Must match the `audience=` query parameter the workflow uses when
# requesting the OIDC token (see step 6.1). Any string works as long
# as both sides agree.
bound-audiences:
- avassa-deploy

# Restricts who may use this role: only workflows running in this
# repository, on this ref. Tighten further with `ref_type: branch`,
# `environment: production`, etc., as needed.
bound-claims:
repository: jbevemyr/cellar

user-claim: sub
token-policies:
- ci-cd-deployer
token-ttl: 15m
token-max-ttl: 30m
token-num-uses: 20
EOF

Tip: use separate roles for branch-based deployments and tag/release deployments — different bound-claims, possibly different policies.

2. Create a least-privilege policy

The policy that the role hands out should allow only what the deploy job actually needs.

Pattern A (patch image/version only)

supctl create policy policies <<'EOF'
name: ci-cd-deployer
rest-api:
rules:
- path: /v1/config/applications/winecellar
operations:
update: allow # PATCH the image/version fields
- path: /v1/state/applications/winecellar
operations:
read: allow
- path: /v1/state/applications/winecellar/**
operations:
read: allow
EOF

Pattern B (replace the full spec)

supctl create policy policies <<'EOF'
name: ci-cd-deployer
rest-api:
rules:
- path: /v1/config/applications/winecellar
operations:
create: allow # First push
update: allow # PUT replaces the spec
- path: /v1/state/applications/winecellar
operations:
read: allow
- path: /v1/state/applications/winecellar/**
operations:
read: allow
EOF

In both cases the policy does not need write access to /v1/config/application-deployments/.... We use a wildcard deployment (next step) so the deployment object never changes from CI.

Tenant-level policies must also allow these paths.

3. Create a wildcard deployment in Control Tower

Create the deployment once, with application-version: "*". The deployment then always tracks the latest application spec version, and CI never has to touch the deployment object.

supctl create application-deployments <<'EOF'
name: winecellar
application: winecellar
application-version: "*"
placement:
match-site-labels: "system/type = edge"
EOF

See Application versioning for the full matrix of application-version behaviour. The short version: with "*", every new spec version pushed by CI becomes the live deployment automatically.

4. Add GitHub repository secrets

In your GitHub repository, go to Settings → Secrets and variables → Actions and add the following as repository secrets (or organization-level secrets if shared):

  • AVASSA_API_URL — e.g. https://api.production.example.avassa.net
  • AVASSA_TENANT — e.g. telco
  • AVASSA_JWT_AUTH — e.g. github-actions (the auth name from 1.1)
  • AVASSA_JWT_ROLE — e.g. deploy (the role name from 1.2)
  • AVASSA_AUDIENCE — e.g. avassa-deploy (must match bound-audiences)
  • AVASSA_APPLICATION — e.g. winecellar

Use secrets rather than variables even for non-sensitive values; that way the workflow log redacts them automatically.

5. Grant the workflow permission to mint OIDC tokens

For GitHub to issue an OIDC token to the workflow, the deploy job — i.e. the jobs.deploy: block in your workflow YAML, the part that runs after your build job and talks to Avassa — must declare id-token: write. Without this, the OIDC request in step 6.1 returns 404.

You set this in the workflow file itself, not in the GitHub UI. Add the permissions: block at the job level:

jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # required for GitHub OIDC
steps:
...

If your repository or organization restricts the default GITHUB_TOKEN permissions (Settings → Actions → General → Workflow permissions), the job-level id-token: write still applies — it is a grant, not a request to elevate. There is no UI toggle to enable OIDC; declaring the permission in the workflow is enough.

6. The deploy job

These are the four things the deploy job does, regardless of pattern:

  1. Mint a GitHub OIDC token.
  2. Exchange it for an Avassa token via /v1/jwt-login.
  3. Push a new application spec or patch (Pattern A or B).
  4. Read back the application state and assert the new image is live.

A complete workflow file is in section 7; the snippets below explain each step in isolation.

6.1 Request a GitHub OIDC token

OIDC_TOKEN="$(curl -sS --fail-with-body \
-H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${AVASSA_AUDIENCE}" \
| jq -r '.value')"

ACTIONS_ID_TOKEN_REQUEST_TOKEN and ACTIONS_ID_TOKEN_REQUEST_URL are injected by GitHub when id-token: write is set.

6.2 Exchange the OIDC token for an Avassa token

AVASSA_TOKEN="$(curl -sS --fail-with-body \
-X POST "${AVASSA_API_URL}/v1/jwt-login" \
-H "Content-Type: application/json" \
-d @- <<EOF | jq -r '.token'
{"tenant":"${AVASSA_TENANT}","jwt-auth":"${AVASSA_JWT_AUTH}","role":"${AVASSA_JWT_ROLE}","jwt":"${OIDC_TOKEN}"}
EOF
)"

6.3 Pattern A — patch image and version

The Git tag (GITHUB_REF_NAME for tag-triggered workflows, or the short SHA for branch builds) is what we send as both the application version and the image tag — that way the immutable application-version in Control Tower matches the immutable image tag in the registry, and there is no ambiguity about what is running.

APP_VERSION="${GITHUB_REF_NAME}" # e.g. v1.2.3
IMAGE_REPO="${IMAGE_REPO:-ghcr.io/${GITHUB_REPOSITORY}}"
IMAGE="${IMAGE_REPO}:${APP_VERSION}"

curl -sS --fail-with-body \
-X PATCH "${AVASSA_API_URL}/v1/config/applications/${AVASSA_APPLICATION}" \
-H "Authorization: Bearer ${AVASSA_TOKEN}" \
-H "Content-Type: application/yaml" \
--data-binary @- <<EOF
version: "${APP_VERSION}"
services:
- name: "${AVASSA_APPLICATION}"
containers:
- name: "${AVASSA_APPLICATION}"
image: ${IMAGE}
EOF

Override IMAGE_REPO (e.g. via a workflow env: entry) if your registry path is not ghcr.io/<org>/<repo> — for example IMAGE_REPO=ghcr.io/${GITHUB_REPOSITORY}/api for a multi-image repo or IMAGE_REPO=registry.example.com/winecellar for a private registry.

The wildcard deployment (step 3) picks up the new version automatically — no further API call is needed.

6.3 Pattern B — replace the full application spec

Keep application.yaml in the repository alongside your code. CI substitutes the current image/version and PUTs the whole spec.

application.yaml (checked into the repo):

name: winecellar
version: "${APP_VERSION}"
services:
- name: winecellar
containers:
- name: winecellar
image: ${IMAGE}
# ... probes, mounts, env, etc.

In CI:

APP_VERSION="${GITHUB_REF_NAME}"
IMAGE_REPO="${IMAGE_REPO:-ghcr.io/${GITHUB_REPOSITORY}}"
IMAGE="${IMAGE_REPO}:${APP_VERSION}"

envsubst < application.yaml | \
curl -sS --fail-with-body \
-X PUT "${AVASSA_API_URL}/v1/config/applications/${AVASSA_APPLICATION}" \
-H "Authorization: Bearer ${AVASSA_TOKEN}" \
-H "Content-Type: application/yaml" \
--data-binary @-

Use a templating tool you trust (envsubst, yq, helm template, …). Avoid hand-rolled sed substitutions on YAML.

6.4 Verify the new version is live

curl -sS --fail-with-body \
"${AVASSA_API_URL}/v1/state/applications/${AVASSA_APPLICATION}" \
-H "Authorization: Bearer ${AVASSA_TOKEN}" \
-H "Accept: application/json" \
| jq -e --arg v "${APP_VERSION}" '.application_version == $v'

Loop on this with a timeout if you want the job to wait for the new image to actually be pulled and started at the edge.

7. Complete workflow example

A full workflow for Pattern A. Adjust IMAGE_REPO, runs-on, and trigger filter to taste.

# .github/workflows/deploy.yml
name: Build and deploy to Avassa

on:
push:
tags:
- 'v*'

jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # push to ghcr.io
outputs:
image: ${{ steps.image.outputs.image }}
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: image
run: echo "image=ghcr.io/${{ github.repository }}:${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.image.outputs.image }}

deploy:
needs: build
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # required for GitHub OIDC, see step 5
env:
AVASSA_API_URL: ${{ secrets.AVASSA_API_URL }}
AVASSA_TENANT: ${{ secrets.AVASSA_TENANT }}
AVASSA_JWT_AUTH: ${{ secrets.AVASSA_JWT_AUTH }}
AVASSA_JWT_ROLE: ${{ secrets.AVASSA_JWT_ROLE }}
AVASSA_AUDIENCE: ${{ secrets.AVASSA_AUDIENCE }}
AVASSA_APPLICATION: ${{ secrets.AVASSA_APPLICATION }}
steps:
- name: Mint GitHub OIDC token
run: |
OIDC_TOKEN="$(curl -sS --fail-with-body \
-H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${AVASSA_AUDIENCE}" \
| jq -r '.value')"
echo "OIDC_TOKEN=${OIDC_TOKEN}" >> "$GITHUB_ENV"

- name: Exchange for Avassa token
run: |
AVASSA_TOKEN="$(curl -sS --fail-with-body \
-X POST "${AVASSA_API_URL}/v1/jwt-login" \
-H "Content-Type: application/json" \
-d "{\"tenant\":\"${AVASSA_TENANT}\",\"jwt-auth\":\"${AVASSA_JWT_AUTH}\",\"role\":\"${AVASSA_JWT_ROLE}\",\"jwt\":\"${OIDC_TOKEN}\"}" \
| jq -r '.token')"
echo "AVASSA_TOKEN=${AVASSA_TOKEN}" >> "$GITHUB_ENV"
echo "::add-mask::${AVASSA_TOKEN}"

- name: Patch application image and version
env:
APP_VERSION: ${{ github.ref_name }}
IMAGE: ghcr.io/${{ github.repository }}:${{ github.ref_name }}
run: |
curl -sS --fail-with-body \
-X PATCH "${AVASSA_API_URL}/v1/config/applications/${AVASSA_APPLICATION}" \
-H "Authorization: Bearer ${AVASSA_TOKEN}" \
-H "Content-Type: application/yaml" \
--data-binary @- <<EOF
version: "${APP_VERSION}"
services:
- name: "${AVASSA_APPLICATION}"
containers:
- name: "${AVASSA_APPLICATION}"
image: ${IMAGE}
EOF

- name: Verify deployment
env:
APP_VERSION: ${{ github.ref_name }}
run: |
for i in $(seq 1 30); do
curl -sS --fail-with-body \
"${AVASSA_API_URL}/v1/state/applications/${AVASSA_APPLICATION}" \
-H "Authorization: Bearer ${AVASSA_TOKEN}" \
| jq -e --arg v "${APP_VERSION}" '.application_version == $v' && exit 0
sleep 5
done
echo "Application did not converge to ${APP_VERSION}" >&2
exit 1

For Pattern B, replace the Patch application image and version step with the PUT-based step from section 6.3 and check application.yaml into the repository.

8. Common failure modes

  • manifest unknown — image tag not yet published in registry; the deploy job ran before or in parallel with the build job. Make sure deploy has needs: build.
  • JWT login denied (403)aud, iss, role name, or one of the bound-claims does not match. Compare the OIDC token claims (use jq to inspect, or temporarily enable verbose-logging: true on the JWT auth entry) with the role configuration.
  • Policy denied (403 on PATCH/PUT) — the role's policy or the tenant policy does not include the required path.
  • id-token missing (404 from the OIDC request) — the permissions.id-token: write block is not on the deploy job.
  • TLS errors — runner cannot reach the Avassa endpoint, or trusts a different CA than the API certificate is signed by.

9. Security baseline

  • Keep token-ttl short (15 minutes is a good default).
  • Constrain bound-claims to exactly the repo and ref you intend (repository, ref, ref_type, optionally environment).
  • Use a per-application policy with the narrowest paths possible; prefer per-app paths over wildcards.
  • Do not store long-lived Avassa tokens in GitHub secrets — the OIDC exchange is what makes this setup safe.
  • Rotate the JWT auth entry's discovery-url trust by re-running step 1 if GitHub publishes new signing keys (Strongbox refreshes the JWKS automatically, but an explicit re-create is a clean reset).