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 CI | Pattern B — spec in repo | |
|---|---|---|
| Source of truth | The application spec stored in Control Tower | The application spec YAML in the repo |
| What CI does on each release | PATCH only the image and version fields | PUT/replace the full application spec |
| Spec contents in the repo | Only image/version snippets in the workflow | Full application.yaml checked in |
| Easier to keep secrets/site-specific bits out of the repo | Yes | No — they end up in the spec file |
Full change history visible in git log | No (history lives in CT, which keeps few revisions) | Yes |
| Risk of repo and CT drifting | Low (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.netAVASSA_TENANT— e.g.telcoAVASSA_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 matchbound-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:
- Mint a GitHub OIDC token.
- Exchange it for an Avassa token via
/v1/jwt-login. - Push a new application spec or patch (Pattern A or B).
- 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 suredeployhasneeds: build.- JWT login denied (403) —
aud,iss, role name, or one of thebound-claimsdoes not match. Compare the OIDC token claims (usejqto inspect, or temporarily enableverbose-logging: trueon 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-tokenmissing (404 from the OIDC request) — thepermissions.id-token: writeblock 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-ttlshort (15 minutes is a good default). - Constrain
bound-claimsto exactly the repo and ref you intend (repository,ref,ref_type, optionallyenvironment). - 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-urltrust 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).