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
| Need | Reference environment | Notes |
|---|---|---|
| Kubernetes cluster | k3s v1.36, single node | Cilium ingress, cert-manager with Let's Encrypt DNS-01 |
| GitOps | ArgoCD with the ApplicationSet controller | App-of-apps repository for platform services |
| Identity provider | Authentik 2026.8 | An API token able to create groups, service accounts, roles and OAuth providers (setup only) |
| Vault | HashiCorp Vault Community Edition 2.0.4 (chart 0.34.1) | OpenBao is API-compatible for everything used |
| Auto-unseal key | AWS KMS key in us-east-1 | About USD 1 per month; avoids manual unseal after every restart |
| Git hosting | GitHub, public repository | Branch protection, CODEOWNERS, Actions |
| Workstation tools | kubectl, helm, python3, Go 1.26+, Docker, AWS CLI | Scripts 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.
- vault
Deploy Vault with KMS auto-unseal and a public hostname.
- identity provider
Create the Vault login client, team groups and test identities.
- vault access
Initialize Vault; map identity groups to per-team policies; prove group-gated reads.
- controller
Give the controller its own vault and IdP identities; build and deploy it; run the acceptance test.
- pull requests
Register the ArgoCD project and ApplicationSet; protect the branch; create the approval label.
- workload access
Install External Secrets; create per-team vault roles; let ArgoCD place a store in every team namespace.
- image
Build the controller in CI, push to the registry, and pin the deployment to the image digest.
- 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.
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 typesauthorization_code,refresh_tokenandclient_credentials - Groups
vault-admins,team-alphaandteam-bravo, each bound to the application - Service accounts
courier-svc-alphaandcourier-svc-bravofor automated tests - Secrets
vault/vault-oidcandvault/courier-phase0-testers
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 (rolehuman) andjwt/for machines (rolemachine)- Policies
vault-admin, one per team, andcourier(write-only) - For each IdP group, two external vault groups:
team-alphaaliased onoidc/andteam-alpha-jwtaliased onjwt/
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
| Identity | Granted |
|---|---|
Authentik service account courier-controller | Role courier-controller: add, change, delete and view OAuth2 providers, applications and policy bindings; view groups, flows, certificate key pairs and scope mappings |
Vault role courier | Bound 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.
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:
| Object | Purpose |
|---|---|
AppProject courier-requests | Only OAuthClient objects (plus namespaces), only in team-*, only from this repository |
ApplicationSet courier-requests | One application courier-requests-<team> per directory under requests/; automated sync with prune |
Application courier-controller | Keeps 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
- identity provider
Create the group (for example
team-payments). - vault
Add the team to the group list in
vault-bootstrap.shand re-run it to create the team policy and both identity groups. - 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. - vault
Re-run
deploy/phase2/vault-team-roles.shso the team's External Secrets store can sign in.
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.
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:
| Object | Purpose |
|---|---|
AppProject courier-team-access | Only ServiceAccount and SecretStore (plus the namespace), only in team-* |
ApplicationSet courier-team-access | One 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
- developer
Merge the change; wait for Controller image to succeed.
- developer
Resolve the digest of
sha-<short commit>(registry UI, orcrane digest ghcr.io/paimonsoror/courier-controller:sha-<short>). - developer
Update the digest in the overlay in a pull request. After merge, ArgoCD rolls out the new controller.
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.
| Piece | Where | Purpose |
|---|---|---|
| Request template | backstage/templates/oauth-client/ | Form plus skeleton; renders the request and its catalog entry and opens a pull request |
| Team groups | backstage/catalog/teams.yaml | One Group per team, spec.type: courier-team, which the form's team picker filters on |
| Client entities | requests/<team>/<name>.catalog.yml | Resource 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
| Access | How |
|---|---|
| Open pull requests on the Courier repository | Fine-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 status | ClusterRole 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.ymlmust have a matching request in the same team directory. - It must be a
backstage.io/v1alpha1Resourcenamed<team>-<name>, typeoauth-client, owned bygroup: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
| Check | Command | Expected |
|---|---|---|
| Vault unsealed with KMS | kubectl -n vault exec vault-0 -- vault status | Seal Type awskms, Sealed false |
| Group-gated reads | deploy/phase0/verify.sh | ALL CHECKS PASSED |
| Alias per group per mount | list identity/group-alias/id | Each IdP group once on oidc/ and once on jwt/ |
| Controller delivers | deploy/phase1/acceptance.sh | PHASE 1 ACCEPTANCE PASSED |
| Repository valid | go run ./cmd/courier validate requests | N request(s) valid |
| Sync healthy | kubectl -n argocd get applications -l app.kubernetes.io/part-of=courier | All Synced and Healthy |
| Requests ready | kubectl get oauthclients -A | READY True for every request |
| Merged request is usable by its team only | deploy/phase2/check-access.sh <team> <name> | ACCESS CHECK PASSED |
| Everything a request created, per platform | deploy/phase2/inventory.sh <team> <name> | Objects, Authentik events, vault audit entries |
| Python sample | samples/python/verify.sh <team> <name> | PYTHON SAMPLE VERIFIED |
| External Secrets per team | deploy/phase2/verify-external-secrets.sh | EXTERNAL SECRETS VERIFIED |
| Backstage can read client status | kubectl auth can-i list oauthclients.courier.sororlab.dev --as=system:serviceaccount:backstage:backstage -A | yes |
| Self-service works end to end | Submit 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 everywhere | deploy/phase4/verify-rotation.sh | ROTATION VERIFIED: same client ID, old secret rejected, new secret accepted, External Secrets copy refreshed |
| Adoption takes over a hand-built client | deploy/phase4/create-legacy-client.sh before the PR, deploy/phase4/verify-adoption.sh after | ADOPTION VERIFIED: client ID kept, emailed secret rejected, other teams denied |
| Resync does not rewrite unchanged clients | Authentik events for the client over 30 minutes | No model_updated events unless the request changed |
| Controller runs the pinned image | kubectl -n courier-system get pod -o jsonpath='{.items[0].status.containerStatuses[0].imageID}' | The digest in the overlay |
The walkthrough follows pull request #1 through every one of these checks, with the outputs captured at the time.
Day-2 operations
| Task | How |
|---|---|
| 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 | Set|
| Upgrade the controller | Update 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 token | Delete token courier-controller-api in Authentik and re-run authentik-controller-account.sh, then restart the controller |
| Remove a stuck object | Fix 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.
| Symptom | Cause | Fix |
|---|---|---|
invalid_grant for client credentials; Authentik logs Invalid grant_type for provider | Authentik 2026.x providers have an explicit grant-type allow-list, which is empty when created through the API | Always 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 first | One 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 login | The mount is configured with an OIDC client ID and secret, which disables JWT-role validation | Keep separate oidc/ and jwt/ mounts |
Vault log warning no key-id in common with currently healthy seals on first unseal | Normal once, right after initialization | None |
Image build fails with gpg: decryption failed: No secret key | Docker's credential store cannot unlock in a non-interactive session, even for public images | Build with an empty DOCKER_CONFIG=$(mktemp -d) |
| A setup script re-run tries to recreate an existing Authentik application | The applications list endpoint ignores ?slug= | Look applications up with core/applications/<slug>/ |
Request Ready=False, reason NameConflict | An application with slug <team>-<name> exists and was not created by Courier | Rename 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 token | Authentik's client_credentials grant with a service account's username and app password ignores client_secret | Do 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 URIs | Controllers before 8e454ad left out redirect_uri_type, which Authentik fills in, so every resync saw a difference | Run the controller image at or after sha-8e454ad |
| Vault hostname resolves on some machines only | Record created in the wrong zone, or the secondary DNS server has not synced | Point the record at the ingress name in the current domain; check zone transfer to secondaries |
| Vault entry version number climbs every few minutes | Controllers before 2ed3a9d rewrote metadata on every resync | Run the pinned image at or after sha-2ed3a9d; metadata is now written only when the request changes |
Team SecretStore not Ready | No Kubernetes auth role for the team | Run deploy/phase2/vault-team-roles.sh |
ExternalSecret SecretSyncedError: could not get secret data from provider | Path belongs to another team, or the request is not Ready | Check the vault audit log for permission denied against the store's identity; check the request status |
| Form's team picker is empty | Team groups not in the catalog, or missing spec.type: courier-team | Check the teams.yaml location and its allow: [Group] rule |
| Create fails at Open the pull request | No or insufficient GitHub token | Fine-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 page | No .catalog.yml, or the catalog has not refreshed yet | Add the entry (the form does); wait a few minutes or refresh the location |
| Kubernetes tab shows the cluster but no OAuthClient | Missing customResources config, RBAC, or the client label | Check all three; courier validate catches a missing label |
| Team-access applications stuck after installing External Secrets | Automated sync failed before the CRD or webhook existed and is not retried for the same revision | Sync the applications once by hand, or push a new commit |
Hardening checklist
The proof of value takes shortcuts that production should not.
| Item | Proof of value | Production |
|---|---|---|
| Root token | Kept in the init file for test scripts | revoke after admin OIDC login works; move scripts to an admin login |
| Traffic inside the cluster | TLS terminates at the ingress; Vault listens on plain HTTP in-cluster | Enable Vault listener TLS and use https:// for VAULT_ADDR |
Vault vault-admin policy | path "*" with every capability | Split into scoped operator policies |
| Audit | File audit device on a volume | Ship to a central log platform with retention and alerts |
| Controller image | Built in GitHub Actions, public on GHCR, pinned by digest | Also sign the image and verify signatures at admission |
| Credentials in Kubernetes Secrets | Only where a team adds an ExternalSecret | Encryption at rest for Secrets; tight RBAC on Secrets in team namespaces; prefer direct vault reads |
| Branch protection | Configured by hand | Managed as code, with bypass disabled for administrators |
| Kubernetes auth audience | Unset (Vault warns) | Set audience on the courier role and in the projected token |