Implementation engineers Scripts deploy/phase0 · deploy/phase1 · deploy/argocd

Implementation guide

This guide installs Courier from nothing to a working pull request flow, in the order the pieces depend on each other. Every step names the script that does it and the check that proves it worked. Values shown are from the reference environment; replace hostnames, account IDs and group names with your own.

Prerequisites

NeedReference environmentNotes
Kubernetes clusterk3s v1.36, single nodeCilium ingress, cert-manager with Let's Encrypt DNS-01
GitOpsArgoCD with the ApplicationSet controllerApp-of-apps repository for platform services
Identity providerAuthentik 2026.8An API token able to create groups, service accounts, roles and OAuth providers (setup only)
VaultHashiCorp Vault Community Edition 2.0.4 (chart 0.34.1)OpenBao is API-compatible for everything used
Auto-unseal keyAWS KMS key in us-east-1About USD 1 per month; avoids manual unseal after every restart
Git hostingGitHub, public repositoryBranch protection, CODEOWNERS, Actions
Workstation toolskubectl, helm, python3, Go 1.26+, Docker, AWS CLIScripts run on a host with cluster access

Install plan

Each stage depends on the previous one. The phase folders in the repository follow the same order.

  1. vault

    Deploy Vault with KMS auto-unseal and a public hostname.

  2. identity provider

    Create the Vault login client, team groups and test identities.

  3. vault access

    Initialize Vault; map identity groups to per-team policies; prove group-gated reads.

  4. controller

    Give the controller its own vault and IdP identities; build and deploy it; run the acceptance test.

  5. pull requests

    Register the ArgoCD project and ApplicationSet; protect the branch; create the approval label.

  6. workload access

    Install External Secrets; create per-team vault roles; let ArgoCD place a store in every team namespace.

  7. image

    Build the controller in CI, push to the registry, and pin the deployment to the image digest.

  8. backstage

    Add the request template, team groups and client entities to the catalog; give Backstage a GitHub token and read access to OAuthClients.

1. Vault with auto-unseal

Create the unseal key and a narrowly scoped IAM user

The IAM user may only encrypt, decrypt and describe with this one key. Its access key goes straight into a Kubernetes Secret and is never printed.

aws kms create-key --description "Courier: Vault auto-unseal" --tags TagKey=project,TagValue=courier
aws kms create-alias --alias-name alias/courier-vault-unseal --target-key-id <key-id>
aws iam create-user --user-name courier-vault-unseal
aws iam put-user-policy --user-name courier-vault-unseal --policy-name vault-awskms-unseal \
  --policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow",
    "Action":["kms:Encrypt","kms:Decrypt","kms:DescribeKey"],"Resource":"<key-arn>"}]}'

# Secret keys: AWS_REGION, VAULT_AWSKMS_SEAL_KEY_ID, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
kubectl create namespace vault
kubectl -n vault create secret generic vault-awskms --from-env-file=/dev/stdin

Deploy the chart

Values live in deploy/phase0/vault-values.yaml: a single Raft replica on local disk, seal "awskms" {} reading its settings from the Secret, the UI behind the ingress, and an audit volume. Add it to your app-of-apps as an ArgoCD Application for chart vault version 0.34.1 from https://helm.releases.hashicorp.com.

Create DNS for the Vault hostname, pointing at the ingress. Use the current domain, not a legacy internal zone.

Use local disk, not NFS

Keep Vault's data and audit volumes off NFS. A hard-mounted NFS volume can hang the node on restart, and Vault is exactly the service you do not want waiting on it.

2. Identity provider

deploy/phase0/authentik-setup.sh

Runs inside the Authentik server pod, is safe to re-run, and creates:

  • OAuth provider and application vault (confidential), with redirect URIs for the Vault UI and CLI and with grant types authorization_code, refresh_token and client_credentials
  • Groups vault-admins, team-alpha and team-bravo, each bound to the application
  • Service accounts courier-svc-alpha and courier-svc-bravo for automated tests
  • Secrets vault/vault-oidc and vault/courier-phase0-testers
Groups claim

Vault needs a groups claim in tokens. In the reference environment it comes from a custom scope mapping named oauth-groups. Confirm yours exists before continuing.

3. Vault access model

deploy/phase0/vault-bootstrap.sh   # init, auth methods, policies, identity groups, sample secrets
deploy/phase0/verify.sh            # must print ALL CHECKS PASSED

On first run Vault is initialized with recovery keys, and the keys and root token are written once to ~/courier-vault-init.json (mode 600). The script then configures:

  • KV v2 at kv/ and a file audit device
  • oidc/ for people (role human) and jwt/ for machines (role machine)
  • Policies vault-admin, one per team, and courier (write-only)
  • For each IdP group, two external vault groups: team-alpha aliased on oidc/ and team-alpha-jwt aliased on jwt/
Immediately after init

Move ~/courier-vault-init.json to offline storage and delete it with shred -u. Once OIDC admin login works, revoke the root token. Use vault operator generate-root with the recovery keys if you ever need one again.

Prove automatic unsealing: kubectl -n vault delete pod vault-0. It must return with Sealed false and no one entering keys.

4. Controller

deploy/phase1/authentik-controller-account.sh  # scoped IdP identity → Secret courier-system/courier-authentik
deploy/phase1/vault-kubernetes-auth.sh         # auth/kubernetes/role/courier → policy courier
deploy/phase1/deploy-controller.sh             # build image, import into k3s, apply CRD + RBAC + manager
deploy/phase1/acceptance.sh                    # must print PHASE 1 ACCEPTANCE PASSED

What the identities can do

IdentityGranted
Authentik service account courier-controllerRole courier-controller: add, change, delete and view OAuth2 providers, applications and policy bindings; view groups, flows, certificate key pairs and scope mappings
Vault role courierBound to service account courier-system/courier-controller-manager; policy courier only; 1 hour token TTL

Controller configuration is set by deploy/phase1/controller/manager-wiring.yaml; every flag is listed in the reference.

Optional developer check against the real systems: deploy/phase1/integration-test.sh.

Image source

The overlay pins ghcr.io/paimonsoror/courier-controller by digest (see section 7). deploy-controller.sh remains for building and importing a local image when no registry is available.

5. Pull request workflow

Register with ArgoCD

Add one Application to your app-of-apps with source path deploy/argocd and destination namespace argocd. It creates:

ObjectPurpose
AppProject courier-requestsOnly OAuthClient objects (plus namespaces), only in team-*, only from this repository
ApplicationSet courier-requestsOne application courier-requests-<team> per directory under requests/; automated sync with prune
Application courier-controllerKeeps the controller manifests in sync with deploy/phase1/controller

Protect the repository

In GitHub, under Settings → Branches → rule for main:

  • Require a pull request before merging, with at least one approval
  • Require review from Code Owners
  • Require status checks to pass: Validate requests
  • Do not allow bypassing the above settings

Create the label security-approved, and restrict who can apply it to the identity or security team. Replace the users in .github/CODEOWNERS with your GitHub teams.

Onboard a team

  1. identity provider

    Create the group (for example team-payments).

  2. vault

    Add the team to the group list in vault-bootstrap.sh and re-run it to create the team policy and both identity groups.

  3. repository

    Add requests/team-payments/ with its first request, and a CODEOWNERS line for the team's approvers. ArgoCD creates both the request application and the team-access application for the new directory.

  4. vault

    Re-run deploy/phase2/vault-team-roles.sh so the team's External Secrets store can sign in.

Known gap

Team onboarding in vault is still a script edit. A Team resource that manages the policy and identity groups is planned.

6. Workload access

Install External Secrets

Add chart external-secrets version 2.10.0 from https://charts.external-secrets.io to your app-of-apps, namespace external-secrets, with installCRDs: true and sync options CreateNamespace=true and ServerSideApply=true.

Order matters

Wait until the External Secrets webhook is ready before ArgoCD creates any SecretStore. An automated sync that fails because a CRD or webhook is missing is not retried for the same Git revision, so the team-access applications would stay failed until the next commit or a manual sync.

Per-team vault roles

deploy/phase2/vault-team-roles.sh
# role auth/kubernetes/role/team-alpha -> team-alpha/courier-secrets (policy team-alpha)
# role auth/kubernetes/role/team-bravo -> team-bravo/courier-secrets (policy team-bravo)

One role per directory under requests/, bound to that namespace's courier-secrets service account, carrying only that team's policy. Teams without a vault policy are skipped.

Per-team stores

The courier ArgoCD application also syncs:

ObjectPurpose
AppProject courier-team-accessOnly ServiceAccount and SecretStore (plus the namespace), only in team-*
ApplicationSet courier-team-accessOne application per team directory rendering deploy/team-access with team=<dir>

Verify

deploy/phase2/verify-external-secrets.sh    # EXTERNAL SECRETS VERIFIED
samples/python/verify.sh team-bravo report-exporter   # PYTHON SAMPLE VERIFIED

7. Controller image

Workflow .github/workflows/controller-image.yml runs on every push to main that touches Go code, go.mod or the Dockerfile. It pushes ghcr.io/paimonsoror/courier-controller with two tags: sha-<short commit> and main.

The deployment never follows a moving tag. deploy/phase1/controller/kustomization.yaml pins a digest:

images:
  - name: controller
    newName: ghcr.io/paimonsoror/courier-controller
    digest: sha256:ada7be47e996b26c10f7d358396ef2d267a0ee7cfc8e0534220eec9c5400e35e   # sha-2ed3a9d

Upgrade procedure

  1. developer

    Merge the change; wait for Controller image to succeed.

  2. developer

    Resolve the digest of sha-<short commit> (registry UI, or crane digest ghcr.io/paimonsoror/courier-controller:sha-<short>).

  3. developer

    Update the digest in the overlay in a pull request. After merge, ArgoCD rolls out the new controller.

Package visibility

New GitHub container packages are private by default. The cluster pulls anonymously, so the package must be public, or the cluster needs an image pull secret.

8. Backstage self-service

Everything that changes as teams and clients are added lives in the Courier repository, so Backstage never needs a rebuild for a new team or request.

PieceWherePurpose
Request templatebackstage/templates/oauth-client/Form plus skeleton; renders the request and its catalog entry and opens a pull request
Team groupsbackstage/catalog/teams.yamlOne Group per team, spec.type: courier-team, which the form's team picker filters on
Client entitiesrequests/<team>/<name>.catalog.ymlResource owned by the team; its label selector finds the OAuthClient

Backstage configuration

# app-config.production.yaml: catalog locations
- type: url
  target: https://github.com/paimonsoror/courier/blob/main/backstage/catalog/teams.yaml
  rules: [{ allow: [Group] }]
- type: url
  target: https://github.com/paimonsoror/courier/blob/main/backstage/templates/oauth-client/template.yaml
  rules: [{ allow: [Template] }]
- type: url
  target: https://github.com/paimonsoror/courier/blob/main/requests/*/*.catalog.yml
  rules: [{ allow: [Resource] }]

# app-config.yaml: show OAuthClients on the Kubernetes tab
kubernetes:
  customResources:
    - group: 'courier.sororlab.dev'
      apiVersion: 'v1alpha1'
      plural: 'oauthclients'

Frontend: add the scaffolder and org plugins (@backstage/plugin-scaffolder/alpha, @backstage/plugin-org/alpha) to the app's features. Backend: the scaffolder backend and its GitHub module.

Access Backstage needs

AccessHow
Open pull requests on the Courier repositoryFine-grained GitHub token limited to that repository: Contents read/write, Pull requests read/write. Store it as Secret backstage/backstage-github-token and expose it as GITHUB_TOKEN.
Read client statusClusterRole rule: get, list, watch on courier.sororlab.dev/oauthclients (and optionally external-secrets.io externalsecrets and secretstores)
# create the token secret without it touching shell history or the process list
read -rs GH_TOKEN
printf %s "$GH_TOKEN" | kubectl -n backstage create secret generic backstage-github-token --from-file=token=/dev/stdin
unset GH_TOKEN

Rules the validator adds for catalog entries

  • A .catalog.yml must have a matching request in the same team directory.
  • It must be a backstage.io/v1alpha1 Resource named <team>-<name>, type oauth-client, owned by group:default/<team>.
  • Its namespace and label-selector annotations must point at its own request, and the request must carry label courier.sororlab.dev/client: <name>.

ArgoCD only syncs *.yaml, so .catalog.yml files are never applied to the cluster.

Verification

CheckCommandExpected
Vault unsealed with KMSkubectl -n vault exec vault-0 -- vault statusSeal Type awskms, Sealed false
Group-gated readsdeploy/phase0/verify.shALL CHECKS PASSED
Alias per group per mountlist identity/group-alias/idEach IdP group once on oidc/ and once on jwt/
Controller deliversdeploy/phase1/acceptance.shPHASE 1 ACCEPTANCE PASSED
Repository validgo run ./cmd/courier validate requestsN request(s) valid
Sync healthykubectl -n argocd get applications -l app.kubernetes.io/part-of=courierAll Synced and Healthy
Requests readykubectl get oauthclients -AREADY True for every request
Merged request is usable by its team onlydeploy/phase2/check-access.sh <team> <name>ACCESS CHECK PASSED
Everything a request created, per platformdeploy/phase2/inventory.sh <team> <name>Objects, Authentik events, vault audit entries
Python samplesamples/python/verify.sh <team> <name>PYTHON SAMPLE VERIFIED
External Secrets per teamdeploy/phase2/verify-external-secrets.shEXTERNAL SECRETS VERIFIED
Backstage can read client statuskubectl auth can-i list oauthclients.courier.sororlab.dev --as=system:serviceaccount:backstage:backstage -Ayes
Self-service works end to endSubmit the Backstage form, merge the pull request, then deploy/phase2/check-access.sh <team> <name>Client on the team's Backstage page; ACCESS CHECK PASSED
Rotation replaces the secret everywheredeploy/phase4/verify-rotation.shROTATION VERIFIED: same client ID, old secret rejected, new secret accepted, External Secrets copy refreshed
Adoption takes over a hand-built clientdeploy/phase4/create-legacy-client.sh before the PR, deploy/phase4/verify-adoption.sh afterADOPTION VERIFIED: client ID kept, emailed secret rejected, other teams denied
Resync does not rewrite unchanged clientsAuthentik events for the client over 30 minutesNo model_updated events unless the request changed
Controller runs the pinned imagekubectl -n courier-system get pod -o jsonpath='{.items[0].status.containerStatuses[0].imageID}'The digest in the overlay
See it done

The walkthrough follows pull request #1 through every one of these checks, with the outputs captured at the time.

Day-2 operations

Set courier.sororlab.dev/rotate to a new value (a date or ticket number) by pull request. For a regular schedule, set rotation.maxAgeDays. Workloads must re-read the vault; External Secrets picks the change up at its next refresh.
TaskHow
Why is a request not ready?kubectl -n <team> describe oauthclient <name> for the condition reason; kubectl -n courier-system logs deploy/courier-controller-manager for detail
Force a new secret
Upgrade the controllerUpdate the image digest in the overlay by pull request (section 7); ArgoCD rolls it out
Who read a secret?Vault audit log at /vault/audit/audit.log; ship it to your log platform
Rotate the controller's IdP tokenDelete token courier-controller-api in Authentik and re-run authentik-controller-account.sh, then restart the controller
Remove a stuck objectFix the cause first. Removing the finalizer by hand skips deletion of the IdP client and the secret.

Troubleshooting

Each of these was hit while building the reference environment.

SymptomCauseFix
invalid_grant for client credentials; Authentik logs Invalid grant_type for providerAuthentik 2026.x providers have an explicit grant-type allow-list, which is empty when created through the APIAlways set grant_types. Courier's adapter does this from the request.
People sign in to Vault but get no team access, while machine logins work (or the reverse)A vault external group holds only one alias; the second mount's alias replaced the firstOne vault group per auth mount per IdP group, as vault-bootstrap.sh does. Count aliases after changing auth wiring.
error configuring token validator: unsupported config type on JWT loginThe mount is configured with an OIDC client ID and secret, which disables JWT-role validationKeep separate oidc/ and jwt/ mounts
Vault log warning no key-id in common with currently healthy seals on first unsealNormal once, right after initializationNone
Image build fails with gpg: decryption failed: No secret keyDocker's credential store cannot unlock in a non-interactive session, even for public imagesBuild with an empty DOCKER_CONFIG=$(mktemp -d)
A setup script re-run tries to recreate an existing Authentik applicationThe applications list endpoint ignores ?slug=Look applications up with core/applications/<slug>/
Request Ready=False, reason NameConflictAn application with slug <team>-<name> exists and was not created by CourierRename the request, retire the old application, or take it over with the courier.sororlab.dev/adopt annotation and the identity-approved label
An old or made-up client secret still gets a tokenAuthentik's client_credentials grant with a service account's username and app password ignores client_secretDo not use token issuance to test a secret. Call the revocation endpoint with HTTP Basic client_id:client_secret: 200 accepted, 401 rejected. Govern app passwords as the real credential.
Authentik logs a provider update every ten minutes for clients with redirect URIsControllers before 8e454ad left out redirect_uri_type, which Authentik fills in, so every resync saw a differenceRun the controller image at or after sha-8e454ad
Vault hostname resolves on some machines onlyRecord created in the wrong zone, or the secondary DNS server has not syncedPoint the record at the ingress name in the current domain; check zone transfer to secondaries
Vault entry version number climbs every few minutesControllers before 2ed3a9d rewrote metadata on every resyncRun the pinned image at or after sha-2ed3a9d; metadata is now written only when the request changes
Team SecretStore not ReadyNo Kubernetes auth role for the teamRun deploy/phase2/vault-team-roles.sh
ExternalSecret SecretSyncedError: could not get secret data from providerPath belongs to another team, or the request is not ReadyCheck the vault audit log for permission denied against the store's identity; check the request status
Form's team picker is emptyTeam groups not in the catalog, or missing spec.type: courier-teamCheck the teams.yaml location and its allow: [Group] rule
Create fails at Open the pull requestNo or insufficient GitHub tokenFine-grained token with Contents and Pull requests write on the Courier repository, in backstage-github-token; restart Backstage
Client missing from its team's Backstage pageNo .catalog.yml, or the catalog has not refreshed yetAdd the entry (the form does); wait a few minutes or refresh the location
Kubernetes tab shows the cluster but no OAuthClientMissing customResources config, RBAC, or the client labelCheck all three; courier validate catches a missing label
Team-access applications stuck after installing External SecretsAutomated sync failed before the CRD or webhook existed and is not retried for the same revisionSync the applications once by hand, or push a new commit

Hardening checklist

The proof of value takes shortcuts that production should not.

ItemProof of valueProduction
Root tokenKept in the init file for test scriptsrevoke after admin OIDC login works; move scripts to an admin login
Traffic inside the clusterTLS terminates at the ingress; Vault listens on plain HTTP in-clusterEnable Vault listener TLS and use https:// for VAULT_ADDR
Vault vault-admin policypath "*" with every capabilitySplit into scoped operator policies
AuditFile audit device on a volumeShip to a central log platform with retention and alerts
Controller imageBuilt in GitHub Actions, public on GHCR, pinned by digestAlso sign the image and verify signatures at admission
Credentials in Kubernetes SecretsOnly where a team adds an ExternalSecretEncryption at rest for Secrets; tight RBAC on Secrets in team namespaces; prefer direct vault reads
Branch protectionConfigured by handManaged as code, with bypass disabled for administrators
Kubernetes auth audienceUnset (Vault warns)Set audience on the courier role and in the projected token