Phase 7 — Vault HA + External Secrets Operator (ESO)¶
Deploy HashiCorp Vault (two-tier HA pattern) and External Secrets Operator so all cluster credentials are stored in Vault and synced to Kubernetes Secrets via ESO — no plaintext tokens in Git or K8s manifests.
Architecture¶
Active deployment: Standalone
This cluster uses the Standalone topology for Main Vault (single pod, storage "file").
The Raft HA topology is documented below as the production-correct alternative.
Standalone (active)¶
┌─────────────────────────────────────────────────────┐
│ Root Vault (standalone, 1 pod) │
│ Namespace: vault-root │
│ Storage: Raft (1 node, 5Gi) │
│ Seal: AUTO after first init (DIY unsealer) │
│ Purpose: Transit engine only — issues unseal key │
│ No ingress — cluster-internal only │
│ Init keys: ~/hashicorp_vault/vault-root-init-mod-auh1-dev.json │
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ vault-root-unsealer (Deployment, 1 pod) │ │
│ │ Polls every 15s → vault status exit code │ │
│ │ If sealed: reads keys from K8s Secret │ │
│ │ (optional volume mount, hot-reloads) │ │
│ └─────────────────────────────────────────────┘ │
└────────────────────┬────────────────────────────────┘
│ Transit auto-unseal
▼
┌─────────────────────────────────────────────────────┐
│ Main Vault (standalone, 1 pod) │
│ Namespace: vault │
│ Storage: File (1 × 10Gi csi-rbd-sc) │
│ Seal: AUTO via Root Vault Transit │
│ Purpose: All application secrets (KV v2) │
│ Ingress: vault.mod.auh1.dev.dir │
│ vault.cl1.sq4.aegis.internal │
└─────────────────────────────────────────────────────┘
│
ESO ClusterSecretStore (Kubernetes auth)
│
ExternalSecret CRs → K8s Secrets (auto-rotated)
| Component | Chart | Version | Namespace | Seal |
|---|---|---|---|---|
| Root Vault | vault |
0.32.0 |
vault-root |
Manual (5 shares / threshold 3) |
| Main Vault standalone (active) | vault |
0.32.0 |
vault |
Auto (Transit → Root Vault) |
| ESO | external-secrets |
0.14.4 |
external-secrets |
— |
Raft HA (production-correct alternative)¶
Not deployed — documented for production reference
Raft HA is the production-correct topology. It is not used in this cluster (single-zone, non-critical workloads). Apply these differences to migrate.
┌─────────────────────────────────────────────────────┐
│ Root Vault (unchanged — same as standalone) │
└────────────────────┬────────────────────────────────┘
│ Transit auto-unseal
▼
┌─────────────────────────────────────────────────────┐
│ Main Vault HA (5-node Raft) │
│ Namespace: vault │
│ Storage: Raft (5 × 10Gi csi-rbd-sc) │
│ Seal: AUTO via Root Vault Transit │
│ PDB: minAvailable: 3 (survives 2 node failures) │
│ Anti-affinity: 1 pod per node (required) │
│ Purpose: All application secrets (KV v2) │
└─────────────────────────────────────────────────────┘
Differences from standalone:
| Item | Standalone (active) | Raft HA (production) |
|---|---|---|
server.standalone.enabled |
true |
false |
server.ha.enabled |
false |
true, replicas: 5 |
| Storage backend | storage "file" |
storage "raft" + 5× retry_join |
| PDB | None | minAvailable: 3 in manifests/vault/vault-pdb.yaml |
| Anti-affinity | None | requiredDuringScheduling — 1 pod per node |
| Node failure tolerance | 0 (single pod) | 2 of 5 (quorum = 3) |
| Manual unseal events | Rare (Transit handles it) | Rare (Transit handles it) |
| Data durability | Single PVC — PVC failure = data loss | Replicated across 5 nodes |
Additional values needed for Raft HA:
server:
ha:
enabled: true
replicas: 5
raft:
enabled: true
setNodeId: true
config: |
# ... same listener and seal blocks ...
storage "raft" {
path = "/vault/data"
retry_join { leader_api_addr = "https://vault-0.vault-internal:8200"
leader_ca_cert_file = "/vault/userconfig/vault-tls/ca.crt" }
retry_join { leader_api_addr = "https://vault-1.vault-internal:8200"
leader_ca_cert_file = "/vault/userconfig/vault-tls/ca.crt" }
retry_join { leader_api_addr = "https://vault-2.vault-internal:8200"
leader_ca_cert_file = "/vault/userconfig/vault-tls/ca.crt" }
retry_join { leader_api_addr = "https://vault-3.vault-internal:8200"
leader_ca_cert_file = "/vault/userconfig/vault-tls/ca.crt" }
retry_join { leader_api_addr = "https://vault-4.vault-internal:8200"
leader_ca_cert_file = "/vault/userconfig/vault-tls/ca.crt" }
}
service_registration "kubernetes" {}
disruptionBudget:
enabled: false # managed in manifests/vault/vault-pdb.yaml
affinity: |
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app.kubernetes.io/name: vault
topologyKey: kubernetes.io/hostname
| Component | Chart | Version | Namespace | Seal |
|---|---|---|---|---|
| Root Vault | vault |
0.32.0 |
vault-root |
Manual (5 shares / threshold 3) |
| Main Vault HA | vault |
0.32.0 |
vault |
Auto (Transit → Root Vault) |
| ESO | external-secrets |
0.14.4 |
external-secrets |
— |
Why two Vaults?¶
| Question | Answer |
|---|---|
| Why not just manual unseal on Main Vault? | Manual unseal is 1 operation for 1 pod — manageable. Root Vault + Transit is kept because it eliminates that 1 manual step after any pod restart, making recovery fully automatic. |
| Why not cloud KMS? | Air-gapped environment — no AWS/Azure/GCP KMS reachable. |
| Why is Root Vault manually unsealed? | Root Vault is a dedicated single-purpose service that is never restarted in steady state. Manual unseal of 1 pod is acceptable; it is not a secret store for applications. |
| Why standalone and not Raft for Main Vault? | This is a non-HA air-gap environment. Raft requires 3+ pods for quorum, adding complexity without benefit on a single-tenant cluster. Standalone uses storage "file" — simpler config, simpler recovery, no peer coordination needed. |
| Where are unseal keys stored? | ~/hashicorp_vault/vault-root-init-mod-auh1-dev.json on bastion. Production requirement: distribute 5 key shares to 5 separate operators — no single person holds all shares. threshold=3 means any 3 of 5 can unseal. |
| What happens if Root Vault node fails? | Main Vault pod already running continues operating (unseal key in memory). A restarted Main Vault pod cannot auto-unseal until Root Vault is back. See troubleshooting → Root Vault Node Failure Recovery. |
| Production-correct alternative? | In production, Main Vault would be 5-node Raft with PDB minAvailable: 3 and pod anti-affinity. Standalone is a known deviation — acceptable here because workload data is non-critical and the cluster is single-zone. |
Production Standards Applied¶
| Standard | Implementation |
|---|---|
| Transit auto-unseal | Root Vault Transit — no manual unseal on restart |
| Transit token | Static K8s Secret, transit/ policy only (least-privilege) |
| TLS | cert-manager internal-ca-issuer, auto-rotated |
| Ingress | Both vault.mod.auh1.dev.dir and vault.cl1.sq4.aegis.internal |
| Storage | csi-rbd-sc (Ceph RBD, RWO) — 10Gi Main Vault, 5Gi Root |
| Audit logging | Enabled post-init via CLI (vault audit enable file) — not in HCL config |
| Root Vault auto-recovery | DIY unsealer Deployment — polls every 15s, auto-unseals on restart |
| Unseal keys | K8s Secret (vault-root-unseal-keys), RBAC-locked to unsealer SA only — never in Git |
| PSA namespace label | baseline — required because vault-helm StatefulSet declares IPC_LOCK |
| Pod security hardening | Manual restricted-equivalent: runAsNonRoot, allowPrivilegeEscalation: false, capabilities.drop: [ALL], seccompProfile: RuntimeDefault — set in values file, not enforced by PSA |
| mlock | disable_mlock = true in HCL — IPC_LOCK not requested or used; no security loss (swap disabled on all nodes) |
| Deviation | Standalone (1 pod, file storage) — production would use 5-node Raft with PDB and anti-affinity |
Prerequisites¶
| Item | Required state |
|---|---|
| ArgoCD | Running — Reloader ✅ (Phase 8 done) |
| cert-manager | Running with internal-ca-issuer ClusterIssuer |
| Harbor | dockerhub/hashicorp/vault:1.21.2 and ghcr/external-secrets/external-secrets:v0.14.4 present |
| GitLab Helm registry | vault 0.32.0 + external-secrets 0.14.4 charts pushed |
| DNS | vault.mod.auh1.dev.dir resolves to HAProxy VIP |
harbor-pull-secret |
Manually copied to vault-root and vault namespaces (see below) |
Pre-step — Namespace creation, PSA labels, and harbor-pull-secret¶
Namespace creation and PSA labeling are fully automated via managedNamespaceMetadata
in each ArgoCD Application yaml. No manual kubectl create namespace or kubectl label
commands needed — ArgoCD creates the namespace with the correct labels on first sync.
harbor-pull-secret must be present before vault pods can pull images. Copy it
to both namespaces once before the first ArgoCD sync:
for NS in vault-root vault; do
kubectl create namespace ${NS} 2>/dev/null || true
kubectl get secret harbor-pull-secret -n argocd -o json \
| python3 -c "import sys,json; s=json.load(sys.stdin); s['metadata']={'name':s['metadata']['name']}; print(json.dumps(s))" \
| kubectl apply -n ${NS} -f -
done
kubectl get secret harbor-pull-secret -n vault-root
kubectl get secret harbor-pull-secret -n vault
After ESO + Step 7.11b: no more manual copy
Once the ClusterExternalSecret is running, ESO owns and rotates harbor-pull-secret
in these namespaces automatically. The manual copy above is only required for the
initial bootstrap before ESO exists.
Why baseline and not restricted?
PSA restricted blocks the IPC_LOCK capability at the admission level —
the pod never reaches the container runtime. This happens even though we set
disable_mlock = true in the Vault HCL config, because the vault-helm
StatefulSet template still declares IPC_LOCK in its container spec.
baseline allows IPC_LOCK while still blocking host namespaces, privilege
escalation, and all other dangerous capabilities. There is no security loss
on Kubernetes nodes where swap is disabled.
Security context is manually hardened to compensate
Using baseline PSA means the cluster no longer enforces restricted security
policy on these namespaces. To compensate, the values file explicitly sets
restricted-equivalent security context on every vault pod:
server:
securityContext: # pod-level
runAsNonRoot: true
runAsUser: 100
runAsGroup: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
containerSecurityContext: # container-level
allowPrivilegeEscalation: false
capabilities:
drop: [ALL] # drops IPC_LOCK too — mlock disabled in HCL
seccompProfile:
type: RuntimeDefault
Net security posture:
| Control | Enforced by |
|---|---|
| No privilege escalation | containerSecurityContext.allowPrivilegeEscalation: false |
| No extra capabilities | capabilities.drop: [ALL] |
| Non-root user | runAsNonRoot: true, runAsUser: 100 |
| Seccomp profile | seccompProfile.type: RuntimeDefault |
| mlock disabled | disable_mlock = true in HCL (swap is off on all nodes) |
| IPC_LOCK allowed by PSA | Yes — but Vault does not request or use it (disable_mlock = true) |
The only gap vs PSA restricted: baseline does not validate these settings at
admission — a misconfigured pod could still run without them. This is accepted because
the values file is Git-controlled and ArgoCD-managed.
Vault Helm Chart 0.32.0 — Known Gotchas¶
Discovered empirically during deployment. These are not in official docs.
Correct environment variable keys¶
The chart uses two separate keys — extraEnv does not exist in 0.32.0 and is silently ignored:
| Key | Type | Use case |
|---|---|---|
server.extraEnvironmentVars |
dict (key: value) |
Plain env vars (paths, flags) |
server.extraSecretEnvironmentVars |
list of objects | Values injected from K8s Secrets |
server:
extraEnvironmentVars:
VAULT_CACERT: /vault/userconfig/vault-tls/ca.crt # ← dict, not list
extraSecretEnvironmentVars: # ← list of objects
- envName: VAULT_TOKEN
secretName: vault-transit-token
secretKey: token
To verify what keys the chart actually accepts:
helm pull vault --repo https://helm.releases.hashicorp.com --version 0.32.0
tar -xzf vault-0.32.0.tgz
grep -n "extraEnvironment\|extraEnv\|extraSecret" vault/values.yaml
Transit seal — inject token via VAULT_TOKEN env var (not in HCL)¶
vault-helm 0.32.0 performs a plain cp of the HCL config file — no envsubst.
Variable substitution does not work in the seal stanza:
# WRONG — ${...} passed literally, vault sees it as the literal string
seal "transit" { token = "${VAULT_TRANSIT_TOKEN}" } # → 403 invalid token
# WRONG — env() is not a valid HCL function in this context
seal "transit" { token = env("VAULT_TOKEN") } # → HCL parse error
Correct approach: omit token = entirely from the seal stanza.
Vault's Transit seal automatically reads the VAULT_TOKEN environment variable.
Inject the token via extraSecretEnvironmentVars with envName: VAULT_TOKEN.
server:
extraSecretEnvironmentVars:
- envName: VAULT_TOKEN # Transit seal reads this automatically
secretName: vault-transit-token
secretKey: token
ha:
raft:
config: |
seal "transit" {
address = "https://vault-root.vault-root.svc.cluster.local:8200"
key_name = "autounseal"
mount_path = "transit/"
tls_ca_cert = "/vault/userconfig/vault-tls/ca.crt"
# token = ... ← DO NOT ADD — vault reads VAULT_TOKEN env var
}
service_registration must be removed for standalone¶
service_registration "kubernetes" {} enables Kubernetes pod annotations for Vault
HA leader/standby state. It is only valid when the storage backend supports HA
(Raft, Consul). With storage "file" it causes an immediate crash:
This block must be absent from standalone HCL config. It is safe to include in Raft HA config.
PDB — do not add one for standalone¶
The chart creates a PDB by default when ha.enabled: true. With standalone.enabled: true
the chart PDB is not created. Do not add a custom PDB manifest either — a PDB with
any minAvailable > 0 on a 1-pod deployment permanently blocks node drains.
Step 7.1 — Push Charts to GitLab Helm Registry¶
From your Mac:
cd /Users/tarun.mittal/Documents/096_RKE/scripts/harbor-mirror/helm-charts
export GITLAB_TOKEN=$(vault kv get -field=token secret/gitlab/migration-token)
python3 gitlab-push-charts.py -c gitlab-charts.yaml --only vault
python3 gitlab-push-charts.py -c gitlab-charts.yaml --only external-secrets
Verify:
curl -sk --header "PRIVATE-TOKEN: ${GITLAB_TOKEN}" \
"https://gitlab.cl1.sq4.aegis.internal/api/v4/projects/14/packages?package_type=helm" \
| python3 -m json.tool | grep '"name"'
# Expected: "vault", "external-secrets" in output
Step 7.2 — Mirror Image to Harbor¶
cd /Users/tarun.mittal/Documents/096_RKE/scripts/harbor-mirror
python3 core42-mirror-image.py --image hashicorp/vault:1.21.2
python3 core42-mirror-image.py --image ghcr.io/external-secrets/external-secrets:v0.14.4
Verify:
curl -sk -u 'robot$ask:<password>' \
"https://harbor.cl1.sq4.aegis.internal/api/v2.0/projects/hashicorp/repositories" \
| python3 -m json.tool | grep '"name"'
Step 7.3 — Pre-requisite: TLS Certificates (GitOps, sync-wave -1)¶
Both Vault deployments need a TLS secret before pods start. These are managed
as Certificate CRs in the manifests/ directory with sync-wave: "-1" so
cert-manager issues them before the StatefulSet starts (wave 0).
Files already in mod-ask-devops:
manifests/vault-root/vault-root-tls-certificate.yaml→ secretvault-root-tlsinvault-rootmanifests/vault/vault-tls-certificate.yaml→ secretvault-tlsinvault
The Main Vault TLS cert covers all 5 pod hostnames via the wildcard SAN *.vault-internal:
dnsNames:
- vault
- vault.vault
- vault.vault.svc
- vault.vault.svc.cluster.local
- vault-internal
- "*.vault-internal" ← covers vault-0 through vault-4
- vault.mod.auh1.dev.dir
- vault.cl1.sq4.aegis.internal
Why *.vault-internal?
Vault Raft peers communicate peer-to-peer via vault-N.vault-internal:8201.
A single wildcard SAN covers all 5 peers without listing each hostname individually.
Adding replicas later does not require a cert re-issue.
Step 7.4 — Commit and Deploy Root Vault (ArgoCD)¶
All files for Root Vault and Main Vault are already committed to mod-ask-devops:
applications/cluster-addons/vault/
├── values-mod-auh1-dev-ask.yaml ← Helm values
└── manifests/
└── vault-tls-certificate.yaml ← cert-manager Certificate CR
applications/cluster-addons/vault-root/
├── values-mod-auh1-dev-ask.yaml ← Helm values
└── manifests/
├── vault-root-tls-certificate.yaml ← cert-manager Certificate CR
└── vault-root-unsealer.yaml ← SA + Role + RoleBinding + Deployment
environment/auh1/dev/02-argocd-application/cluster-addons/vault-root.yaml
environment/auh1/dev/02-argocd-application/cluster-addons/vault.yaml
Why a manifests/ subdirectory inside the application folder?
ArgoCD's path source applies every yaml file in the target directory as a
Kubernetes manifest. If the path pointed directly at applications/cluster-addons/vault/,
ArgoCD would try to apply values-mod-auh1-dev-ask.yaml as a K8s resource and fail.
The manifests/ subdirectory keeps the ArgoCD path source cleanly separated from
the Helm values file.
No PDB for standalone
No PDB manifest is present. A PDB with minAvailable: 3
on a 1-pod deployment permanently blocks node drains — do not add one.
harbor-pull-secret is a manual prerequisite — not in Git
The secret contains registry credentials and must never be committed. It is created once manually (see Pre-step above) and lives only in the cluster. The pre-step must be re-run for any new cluster or after namespace deletion.
Push to GitLab — ArgoCD's cluster-addons ApplicationSet picks up vault-root and vault Applications automatically.
Deploy Root Vault FIRST — do not sync Main Vault until Transit is configured
Main Vault depends on Root Vault's Transit seal. If Main Vault pods start before the Transit token K8s Secret exists, they will stay sealed and restart continuously.
Correct sequence:
1. Sync vault-root Application only
2. Initialize and unseal Root Vault (Steps 7.5–7.6)
3. Create the vault-transit-token K8s Secret (Step 7.7)
4. Sync vault Application (Step 7.8)
Trigger Root Vault sync from ArgoCD UI or CLI:
argocd app sync vault-root --assumeYes
kubectl get pods -n vault-root -w
# Wait for vault-root-0 Running (will be 0/1 — sealed, expected)
Step 7.5 — Initialize Root Vault¶
# Initialize (5 shares, threshold 3) — tee ensures output is saved AND visible
kubectl exec -n vault-root vault-root-0 -- \
env VAULT_CACERT=/vault/userconfig/vault-root-tls/ca.crt \
vault operator init \
-key-shares=5 \
-key-threshold=3 \
-format=json | tee ~/hashicorp_vault/vault-root-init-mod-auh1-dev.json
# Verify file has content
ls -la ~/hashicorp_vault/vault-root-init-mod-auh1-dev.json
Store the init file securely
This file contains all 5 unseal key shares and the root token. It must not remain
on bastion disk in production — encrypt it (GPG, Vault, HSM) and distribute the
5 key shares to 5 separate operators. In this deployment, ~/hashicorp_vault/vault-root-init-mod-auh1-dev.json
on bastion is the storage location.
Step 7.6 — Unseal Root Vault¶
Root Vault has 1 pod, requires 3 of 5 key shares:
UNSEAL_1=$(python3 -c "import sys,json; d=json.load(open('${HOME}/hashicorp_vault/vault-root-init-mod-auh1-dev.json')); print(d['unseal_keys_b64'][0])")
UNSEAL_2=$(python3 -c "import sys,json; d=json.load(open('${HOME}/hashicorp_vault/vault-root-init-mod-auh1-dev.json')); print(d['unseal_keys_b64'][1])")
UNSEAL_3=$(python3 -c "import sys,json; d=json.load(open('${HOME}/hashicorp_vault/vault-root-init-mod-auh1-dev.json')); print(d['unseal_keys_b64'][2])")
ROOT_TOKEN=$(python3 -c "import sys,json; d=json.load(open('${HOME}/hashicorp_vault/vault-root-init-mod-auh1-dev.json')); print(d['root_token'])")
kubectl exec -n vault-root vault-root-0 -- vault operator unseal ${UNSEAL_1}
kubectl exec -n vault-root vault-root-0 -- vault operator unseal ${UNSEAL_2}
kubectl exec -n vault-root vault-root-0 -- vault operator unseal ${UNSEAL_3}
# Verify
kubectl exec -n vault-root vault-root-0 -- vault status
# Expected: Initialized=true, Sealed=false
Step 7.6b — Create Unseal Keys Secret (one-time, post-init)¶
After initializing Root Vault, store 3 of the 5 key shares in a K8s Secret so the DIY unsealer can auto-recover after any future pod restart.
UNSEAL_1=$(python3 -c "import json; d=json.load(open('${HOME}/hashicorp_vault/vault-root-init-mod-auh1-dev.json')); print(d['unseal_keys_b64'][0])")
UNSEAL_2=$(python3 -c "import json; d=json.load(open('${HOME}/hashicorp_vault/vault-root-init-mod-auh1-dev.json')); print(d['unseal_keys_b64'][1])")
UNSEAL_3=$(python3 -c "import json; d=json.load(open('${HOME}/hashicorp_vault/vault-root-init-mod-auh1-dev.json')); print(d['unseal_keys_b64'][2])")
kubectl create secret generic vault-root-unseal-keys \
-n vault-root \
--from-literal=key1=${UNSEAL_1} \
--from-literal=key2=${UNSEAL_2} \
--from-literal=key3=${UNSEAL_3}
This Secret is NEVER committed to Git
vault-root-unseal-keys contains real key material — it lives only in the cluster.
The unsealer Deployment in manifests/vault-root/vault-root-unsealer.yaml IS in Git;
the Secret itself is not. The Secret is created manually once, after Root Vault init.
How the unsealer works — no pod restart needed
The unsealer mounts the Secret as a volume (not env vars) with optional: true.
This means:
- The unsealer pod starts immediately even before the Secret exists
- Kubernetes hot-reloads mounted secret volumes within ~60s of Secret creation
- The shell loop re-reads the key files on every 15s iteration — picks up new values automatically
- No pod restart needed when the Secret is created
RBAC lock-down: the unsealer ServiceAccount can only get the vault-root-unseal-keys
Secret — no other secrets in the namespace are accessible to it.
Vault exit codes:
- 0 = unsealed and active — loop skips
- 1 = error (not yet initialized, connection refused) — loop skips
- 2 = sealed — loop sends 3 unseal keys
Step 7.7 — Configure Root Vault Transit Engine¶
This creates the Transit key and a minimal-privilege token that Main Vault uses for
auto-unseal. The token is stored in a K8s Secret in the vault namespace.
# Login to Root Vault
kubectl exec -n vault-root vault-root-0 -- \
env VAULT_TOKEN=${ROOT_TOKEN} vault login ${ROOT_TOKEN}
# Enable Transit secrets engine
kubectl exec -n vault-root vault-root-0 -- \
env VAULT_TOKEN=${ROOT_TOKEN} \
vault secrets enable transit
# Create auto-unseal key (aes256-gcm96)
kubectl exec -n vault-root vault-root-0 -- \
env VAULT_TOKEN=${ROOT_TOKEN} \
vault write -f transit/keys/autounseal
# Create least-privilege policy — transit encrypt/decrypt only
kubectl exec -n vault-root vault-root-0 -- \
env VAULT_TOKEN=${ROOT_TOKEN} \
vault policy write autounseal-policy - << 'POLICY'
path "transit/encrypt/autounseal" { capabilities = ["update"] }
path "transit/decrypt/autounseal" { capabilities = ["update"] }
POLICY
# Create a periodic token (never expires, renewable)
TRANSIT_TOKEN=$(kubectl exec -n vault-root vault-root-0 -- \
env VAULT_TOKEN=${ROOT_TOKEN} \
vault token create \
-policy=autounseal-policy \
-period=87600h \
-format=json | python3 -c "import sys,json; print(json.load(sys.stdin)['auth']['client_token'])")
echo "Transit token: ${TRANSIT_TOKEN}"
# Enable audit logging (post-init — not in HCL config file)
kubectl exec -n vault-root vault-root-0 -- \
env VAULT_TOKEN=${ROOT_TOKEN} \
vault audit enable file file_path=/vault/logs/audit.log
Create the K8s Secret that Main Vault reads for auto-unseal:
kubectl create namespace vault 2>/dev/null || true
kubectl create secret generic vault-transit-token \
-n vault \
--from-literal=token=${TRANSIT_TOKEN}
kubectl get secret vault-transit-token -n vault -o jsonpath='{.data.token}' | base64 -d
# Should print the transit token
Why a static K8s Secret for the transit token?
At the time Main Vault starts, ESO is not yet deployed — there is no mechanism
to inject a Vault-sourced secret into a pod that needs that same Vault to unseal.
The transit token K8s Secret is a deliberate bootstrap exception. Once ESO is
running and Main Vault is initialized, the token can be rotated and the secret
updated via kubectl patch.
Step 7.8 — Deploy Main Vault (ArgoCD)¶
Main Vault Application was committed in Step 7.4 alongside Root Vault. Now that
vault-transit-token secret exists, sync it:
argocd app sync vault --assumeYes
kubectl get pods -n vault -w
# Wait for vault-0 Running (0/1 — sealed pre-init, expected)
The pod transitions from 0/1 Running to 1/1 Running automatically once initialized.
No manual unseal needed — Root Vault Transit handles it.
Step 7.9 — Initialize Main Vault¶
With Transit auto-unseal, initialization only needs to run once — no unseal keys are required (Transit handles that):
# Wait for vault-0 to be Running (not necessarily Ready)
kubectl exec -n vault vault-0 -- vault status 2>/dev/null | grep -E "Initialized|Sealed"
# Initialize — use tee so output is BOTH displayed and saved
# Shell > redirect creates an empty file before the command runs;
# if kubectl exec fails silently, the file stays empty and keys are lost.
kubectl exec -n vault vault-0 -- \
env VAULT_CACERT=/vault/userconfig/vault-tls/ca.crt \
vault operator init \
-recovery-shares=5 \
-recovery-threshold=3 \
-format=json | tee ~/hashicorp_vault/vault-main-init-mod-auh1-dev.json
# Verify file has content (must be > 0 bytes)
ls -la ~/hashicorp_vault/vault-main-init-mod-auh1-dev.json
python3 -c "import json; d=json.load(open('${HOME}/hashicorp_vault/vault-main-init-mod-auh1-dev.json')); print('root_token:', d['root_token'][:8], '...')"
Recovery keys, not unseal keys
When Transit auto-unseal is active, vault operator init generates recovery keys
not unseal keys. Recovery keys are used only to regenerate a root token if it is
lost — normal unseal is automatic via Transit.
Wait for the pod to become Ready:
kubectl get pods -n vault -w
# Expected: vault-0 — 1/1 Running
# Quick verify
kubectl exec -n vault vault-0 -- \
env VAULT_CACERT=/vault/userconfig/vault-tls/ca.crt \
vault status
# Expected: Initialized=true, Sealed=false, Storage Type=file
Step 7.10 — Configure Main Vault (GitOps bootstrap Job)¶
Vault configuration (KV v2, Kubernetes auth, ESO policy/role) is managed as a PostSync ArgoCD Job that re-runs on every sync. All commands are idempotent — re-applying does no harm.
Files in applications/cluster-addons/vault/manifests/:
| File | Purpose |
|---|---|
vault-bootstrap-config.yaml |
ConfigMap — idempotent bootstrap shell script |
vault-bootstrap-job.yaml |
PostSync Job — reads token from vault-bootstrap-token secret |
Pre-step — Create the bootstrap token secret (one-time, not in Git)¶
The Job needs a VAULT_TOKEN to authenticate to Vault. Create a K8s Secret from
the root token (use root token only for initial bootstrap — rotate to a limited
admin token in production):
MAIN_ROOT=$(python3 -c "import json; d=json.load(open('${HOME}/hashicorp_vault/vault-main-init-mod-auh1-dev.json')); print(d['root_token'])")
kubectl create secret generic vault-bootstrap-token \
-n vault \
--from-literal=token=${MAIN_ROOT}
This secret is never committed to Git
vault-bootstrap-token contains a high-privilege token. It lives only in the
cluster. In production, replace the root token with a dedicated admin token
that has only sys/ write capabilities.
VAULT_CACERT required on every exec
Main Vault has TLS enabled. Every kubectl exec command must pass
env VAULT_CACERT=/vault/userconfig/vault-tls/ca.crt or the CLI returns
x509: certificate signed by unknown authority.
Sync and verify¶
Push the new manifest files and sync the vault Application. The PostSync Job runs automatically after sync completes:
# Push (from Mac)
git add applications/cluster-addons/vault/manifests/
git commit -m "feat: vault bootstrap job (idempotent PostSync)"
git push
# Sync (from bastion)
argocd app sync vault --assumeYes
# Watch the bootstrap job
kubectl get jobs -n vault -w
kubectl logs -n vault job/vault-bootstrap -f
Expected log output:
=== Vault bootstrap starting ===
KV v2 enabled at secret/ (or: already enabled — skipping)
Kubernetes auth enabled (or: already enabled — skipping)
Kubernetes auth configured
eso-read policy written
eso role written
=== Vault bootstrap complete ===
Verify after job completes:
MAIN_ROOT=$(python3 -c "import json; d=json.load(open('${HOME}/hashicorp_vault/vault-main-init-mod-auh1-dev.json')); print(d['root_token'])")
kubectl exec -n vault vault-0 -- \
env VAULT_CACERT=/vault/userconfig/vault-tls/ca.crt \
env VAULT_TOKEN=${MAIN_ROOT} \
vault secrets list
# Expected: cubbyhole/, identity/, secret/, sys/
kubectl exec -n vault vault-0 -- \
env VAULT_CACERT=/vault/userconfig/vault-tls/ca.crt \
env VAULT_TOKEN=${MAIN_ROOT} \
vault auth list
# Expected: kubernetes/, token/
Enable audit logging (one-time, manual)¶
Audit logging is enabled via CLI — not included in the bootstrap Job because the audit log path is environment-specific:
kubectl exec -n vault vault-0 -- \
env VAULT_CACERT=/vault/userconfig/vault-tls/ca.crt \
env VAULT_TOKEN=${MAIN_ROOT} \
vault audit enable file file_path=/vault/data/audit.log
Adding per-app policies (pattern)¶
For each application that needs secrets from Vault, add its policy to bootstrap.sh
in the ConfigMap. The Job re-runs on the next argocd app sync vault and applies
the new policy idempotently:
# Example: add a new app policy to vault-bootstrap-config.yaml
vault policy write myapp-read - << 'POLICY'
path "secret/data/myapp/*" { capabilities = ["read"] }
path "secret/metadata/myapp/*" { capabilities = ["read", "list"] }
POLICY
vault write auth/kubernetes/role/myapp \
bound_service_account_names=myapp \
bound_service_account_namespaces=myapp \
policies=myapp-read \
ttl=1h
Step 7.10b — Verify Vault Access¶
Before deploying ESO, confirm Main Vault is fully operational — CLI, UI, and a basic KV read/write. This is the go/no-go gate before handing off to ESO.
CLI access (kubectl exec)¶
MAIN_ROOT=$(python3 -c "import json; d=json.load(open('${HOME}/hashicorp_vault/vault-main-init-mod-auh1-dev.json')); print(d['root_token'])")
# Status check
kubectl exec -n vault vault-0 -- \
env VAULT_CACERT=/vault/userconfig/vault-tls/ca.crt \
vault status
# Expected: Initialized=true, Sealed=false, Storage Type=file
# Auth check
kubectl exec -n vault vault-0 -- \
env VAULT_CACERT=/vault/userconfig/vault-tls/ca.crt \
env VAULT_TOKEN=${MAIN_ROOT} \
vault token lookup
# Expected: policies=[default root], display_name=root
KV read/write smoke test¶
# Write a test secret
kubectl exec -n vault vault-0 -- \
env VAULT_CACERT=/vault/userconfig/vault-tls/ca.crt \
env VAULT_TOKEN=${MAIN_ROOT} \
vault kv put secret/test/smoke key=hello
# Read it back
kubectl exec -n vault vault-0 -- \
env VAULT_CACERT=/vault/userconfig/vault-tls/ca.crt \
env VAULT_TOKEN=${MAIN_ROOT} \
vault kv get secret/test/smoke
# Expected: key = hello
# Clean up
kubectl exec -n vault vault-0 -- \
env VAULT_CACERT=/vault/userconfig/vault-tls/ca.crt \
env VAULT_TOKEN=${MAIN_ROOT} \
vault kv delete secret/test/smoke
UI access (ingress)¶
Open in browser: https://vault.mod.auh1.dev.dir
- Login method: Token
- Token: value of
${MAIN_ROOT}from init file
Expected: Vault UI loads, secret/ KV v2 mount visible in left sidebar.
Certificate trust
The UI cert is issued by internal-ca-issuer. If your browser shows an untrusted
cert warning, import the internal CA into your OS trust store or accept the exception.
Verify Kubernetes auth is wired up¶
kubectl exec -n vault vault-0 -- \
env VAULT_CACERT=/vault/userconfig/vault-tls/ca.crt \
env VAULT_TOKEN=${MAIN_ROOT} \
vault auth list
# Expected: kubernetes/ entry present
kubectl exec -n vault vault-0 -- \
env VAULT_CACERT=/vault/userconfig/vault-tls/ca.crt \
env VAULT_TOKEN=${MAIN_ROOT} \
vault read auth/kubernetes/role/eso
# Expected: bound_service_account_names=[external-secrets], policies=[eso-read]
✅ All checks pass → proceed to Step 7.11 (ESO).
Step 7.11 — Deploy ESO via ArgoCD¶
Both files are already committed in mod-ask-devops:
applications/cluster-addons/external-secrets/
└── values-mod-auh1-dev-ask.yaml
environment/auh1/dev/02-argocd-application/cluster-addons/
└── external-secrets.yaml
Pre-step — Verify image exists in Harbor¶
ESO uses the ghcr/external-secrets/external-secrets project in Harbor. Verify the
image is present before pushing:
# From bastion
curl -sk -u 'robot$ask:<password>' \
"https://harbor.cl1.sq4.aegis.internal/api/v2.0/projects/ghcr/repositories/external-secrets%2Fexternal-secrets/artifacts?page_size=10" \
| python3 -m json.tool | grep '"name"'
# Expected: "v0.14.4" tag visible
If the image is missing, mirror it first:
cd /Users/tarun.mittal/Documents/096_RKE/scripts/harbor-mirror
python3 core42-mirror-image.py --image ghcr.io/external-secrets/external-secrets:v0.14.4
Pre-step — Namespace creation and PSA labeling (fully automated via ArgoCD)¶
No kubectl create namespace or kubectl label commands needed.
The ArgoCD Application uses managedNamespaceMetadata to create the namespace and
apply PSA labels in the same sync:
syncPolicy:
syncOptions:
- CreateNamespace=true
managedNamespaceMetadata:
labels:
pod-security.kubernetes.io/enforce: baseline
pod-security.kubernetes.io/warn: baseline
This applies to all three applications: external-secrets, vault, and vault-root.
Adding a new app namespace with baseline PSA is the same pattern — add
managedNamespaceMetadata to its ArgoCD Application yaml and push.
Pre-step — Bootstrap harbor-pull-secret (one-time only for external-secrets namespace)¶
The external-secrets namespace is a chicken-and-egg exception: ESO must pull its
own image from Harbor to start, but ESO is what manages harbor-pull-secret via the
ClusterExternalSecret going forward. A one-time manual copy is required for this
namespace only. After Step 7.11b is complete, ESO maintains it automatically.
# Bastion — run once before pushing
kubectl get secret harbor-pull-secret -n argocd -o json \
| python3 -c "import sys,json; s=json.load(sys.stdin); s['metadata']={'name':s['metadata']['name']}; print(json.dumps(s))" \
| kubectl apply -n external-secrets -f -
kubectl get secret harbor-pull-secret -n external-secrets
# Expected: secret/harbor-pull-secret
vault and vault-root do NOT need this manual step
Those namespaces already have harbor-pull-secret from initial deployment.
After Step 7.11b completes, ESO takes ownership of those secrets too — future
rotations only require updating Vault, not touching any namespace manually.
Push and sync¶
# Push from Mac
cd /Users/tarun.mittal/Documents/modgpt/mod-ask-devops
git add applications/cluster-addons/external-secrets/ \
environment/auh1/dev/02-argocd-application/cluster-addons/external-secrets.yaml
git commit -m "feat: deploy ESO v0.14.4 via ArgoCD"
git push
# ArgoCD auto-syncs (automated syncPolicy) — or trigger manually:
argocd app sync external-secrets --assumeYes
# Verify pods
kubectl get pods -n external-secrets -w
# Expected: 3 pods Running — external-secrets (controller), webhook, cert-controller
Verify ESO is healthy¶
kubectl get pods -n external-secrets
# NAME READY STATUS RESTARTS
# external-secrets-<hash> 1/1 Running 0
# external-secrets-webhook-<hash> 1/1 Running 0
# external-secrets-cert-controller-<hash> 1/1 Running 0
kubectl get crds | grep external-secrets.io
# Expected: clustersecretstores, externalsecrets, secretstores, etc.
Step 7.11b — Harbor Pull Secret via ESO (ClusterExternalSecret)¶
Production-correct pattern
Instead of manually copying harbor-pull-secret to every namespace, store Harbor
robot account credentials in Vault once and let ESO create the secret automatically
in every namespace that needs it via a ClusterExternalSecret. Adding a new app
namespace only requires adding the namespace to the selector list — no manual copy.
Why a ClusterExternalSecret?¶
| Manual copy | ClusterExternalSecret (label-based) |
|---|---|
kubectl get secret ... \| kubectl apply -n <ns> per namespace |
Add harbor-pull-secret: "true" label to namespace → ESO creates it automatically |
| Edit an explicit namespace list for each new app | No list — label the namespace, ESO picks it up |
| Credentials duplicated in every namespace | Single source of truth in Vault |
| Rotation: re-copy to every namespace | Rotation: vault kv put secret/harbor/pull-secret password=<new> → ESO propagates within refreshInterval |
| Drift: namespace might have stale credentials | ESO reconciles continuously |
Chicken-and-egg bootstrap note¶
external-secrets namespace is the one exception: it needs harbor-pull-secret to pull the ESO image before ESO runs. The manual pre-step in Step 7.11 covers this once. After ESO is running, the ClusterExternalSecret manages it going forward (including after namespace deletion/recreation).
Step 1 — Store Harbor credentials in Vault¶
Harbor robot account credentials are stored in ~/harbor-images/harbor-robot.json on
bastion1. Read directly from the file — token never appears in terminal history.
# Run on bastion1 from ~/harbor-images/
cd ~/harbor-images
MAIN_ROOT=$(python3 -c "import json; d=json.load(open('${HOME}/hashicorp_vault/vault-main-init-mod-auh1-dev.json')); print(d['root_token'])")
HARBOR_USER=$(python3 -c "import json; d=json.load(open('harbor-robot.json')); print(d.get('name', d.get('username')))")
HARBOR_PASS=$(python3 -c "import json; d=json.load(open('harbor-robot.json')); print(d.get('secret', d.get('token', d.get('password'))))")
kubectl exec -n vault vault-0 -- \
env VAULT_CACERT=/vault/userconfig/vault-tls/ca.crt \
env VAULT_TOKEN=${MAIN_ROOT} \
vault kv put secret/harbor/pull-secret \
username="${HARBOR_USER}" \
password="${HARBOR_PASS}"
Verify (username only — do not print password):
kubectl exec -n vault vault-0 -- \
env VAULT_CACERT=/vault/userconfig/vault-tls/ca.crt \
env VAULT_TOKEN=${MAIN_ROOT} \
vault kv get -field=username secret/harbor/pull-secret
# Expected: robot$ask
Init file is on bastion1, not bastion2
vault-main-init-mod-auh1-dev.json is at ~/hashicorp_vault/ on bastion1.
If running from bastion2, either SSH to bastion1 first or fetch the root token via:
MAIN_ROOT=$(ssh cloud-user@bastion1 "python3 -c \"import json; print(json.load(open('${HOME}/hashicorp_vault/vault-main-init-mod-auh1-dev.json'))['root_token'])\"")
Credential rotation
When you rotate the Harbor robot account token, update harbor-robot.json on bastion1
then re-run the HARBOR_PASS + vault kv put commands above. ESO propagates the new
secret to all namespaces within refreshInterval: 1h — no kubectl commands needed.
Step 2 — Update bootstrap policy¶
The ESO bootstrap policy already allows secret/data/apps/* and secret/data/argocd/*.
Harbor credentials live at secret/harbor/* — add this path to the eso-read policy
in applications/cluster-addons/vault/manifests/vault-bootstrap-config.yaml:
The updated policy section (already applied in the committed ConfigMap):
After committing vault-bootstrap-config.yaml, sync the vault app to re-run the bootstrap Job.
Step 3 — ClusterExternalSecret (GitOps)¶
File: applications/cluster-addons/external-secrets/manifests/harbor-pull-secret-ces.yaml
This manifest is included in the ESO ArgoCD Application via a third source. It is applied
in the same sync as the ESO Helm chart — the ClusterExternalSecret CRD comes from the
chart, so the manifest applies cleanly in the same wave.
The secret will show Not Ready until Step 7.12 (ClusterSecretStore) is created.
Once vault-backend ClusterSecretStore is READY, ESO begins syncing immediately.
Any namespace labeled harbor-pull-secret: "true" automatically gets the secret.
No list to maintain — just add the label to the ArgoCD Application's managedNamespaceMetadata.
# After pushing (Step 7.11 push includes this file):
kubectl get clustersecretstore vault-backend
# Wait for READY=True (after Step 7.12)
kubectl get externalsecret -n vault
kubectl get externalsecret -n vault-root
kubectl get externalsecret -n external-secrets
# Expected: NAME=harbor-pull-secret, STATUS=SecretSynced, READY=True
# Verify secret was created
kubectl get secret harbor-pull-secret -n vault
kubectl get secret harbor-pull-secret -n vault-root
Step 7.12 — ClusterSecretStore (GitOps, sync-wave 1)¶
The ClusterSecretStore is managed as a GitOps manifest at
applications/cluster-addons/external-secrets/manifests/vault-backend-css.yaml.
It is deployed automatically by the external-secrets ArgoCD Application in sync-wave 1,
after the ESO CRDs are installed (wave 0 / default) and before the ClusterExternalSecret
(wave 2).
Sync wave ordering within the external-secrets Application:
| Wave | Resource | Depends on |
|---|---|---|
| 0 (default) | ESO Helm chart — CRDs, Deployments, RBAC | nothing |
| 1 | ClusterSecretStore/vault-backend |
ESO CRDs |
| 2 | ClusterExternalSecret/harbor-pull-secret |
ClusterSecretStore |
No kubectl apply needed — push the manifest and ArgoCD syncs it.
Verify after sync:
kubectl get clustersecretstore vault-backend
# Expected: READY=True
kubectl get clustersecretstore vault-backend -o jsonpath='{.status.conditions[0].message}'
# Expected: Vault health check succeeded (or similar)
Why vault.vault.svc.cluster.local not vault.mod.auh1.dev.dir?
ESO connects to Vault from inside the cluster. Using the internal service FQDN
avoids routing through the ingress, HAProxy, and external DNS — faster and more
reliable. The CA cert comes directly from the vault-tls secret issued by cert-manager.
ClusterSecretStore will show Not Ready until Vault Kubernetes auth is configured
The eso-read policy and eso Kubernetes auth role are created by the Vault bootstrap
Job (PostSync on the vault Application). If ESO syncs before the vault bootstrap Job
has run, the store will show Not Ready temporarily and recover automatically once
the Job completes.
Step 7.12b — End-to-End Test (verified 2026-06-11)¶
Run after every fresh deployment or ESO upgrade to confirm the full chain is healthy.
# 1. ClusterSecretStore — must be READY=True before anything else works
kubectl get clustersecretstore vault-backend
# NAME AGE STATUS CAPABILITIES READY
# vault-backend 3m33s Valid ReadWrite True
# 2. ClusterExternalSecret — must be READY=True, store=vault-backend
kubectl get clusterexternalsecret harbor-pull-secret
# NAME STORE REFRESH INTERVAL READY
# harbor-pull-secret vault-backend 1h True
# 3. harbor-pull-secret present in all target namespaces
kubectl get secret harbor-pull-secret -n vault
kubectl get secret harbor-pull-secret -n vault-root
kubectl get secret harbor-pull-secret -n external-secrets
# Expected: all 3 — TYPE=kubernetes.io/dockerconfigjson, DATA=1
# 4. Confirm secret content has correct registry and username
kubectl get secret harbor-pull-secret -n vault \
-o jsonpath='{.data.\.dockerconfigjson}' | base64 -d | python3 -m json.tool
# Expected:
# {
# "auths": {
# "harbor.cl1.sq4.aegis.internal": {
# "username": "robot$ask",
# "password": "...",
# "auth": "..."
# }
# }
# }
All checks passed (2026-06-11):
| Check | Result |
|---|---|
ClusterSecretStore/vault-backend |
READY=True, Valid, ReadWrite |
ClusterExternalSecret/harbor-pull-secret |
READY=True, store=vault-backend |
harbor-pull-secret in vault |
✅ kubernetes.io/dockerconfigjson |
harbor-pull-secret in vault-root |
✅ kubernetes.io/dockerconfigjson |
harbor-pull-secret in external-secrets |
✅ kubernetes.io/dockerconfigjson |
How to test label-based automatic distribution (verified 2026-06-11)¶
Create a temporary namespace with the label and watch ESO react:
kubectl create namespace eso-test
kubectl label namespace eso-test harbor-pull-secret=true
kubectl get secret -n eso-test -w
# Expected: harbor-pull-secret appears within seconds — no manifest, no kubectl copy
# NAME TYPE DATA AGE
# harbor-pull-secret kubernetes.io/dockerconfigjson 1 4s
# Clean up
kubectl delete namespace eso-test
Verified result (2026-06-11): secret appeared in 4 seconds after label was applied.
Onboarding any new app namespace to Harbor:
# In the ArgoCD Application yaml — nothing else needed
syncPolicy:
managedNamespaceMetadata:
labels:
harbor-pull-secret: "true"
ArgoCD creates the namespace with the label → ESO creates harbor-pull-secret within seconds.
No list to maintain, no manual copy steps.
How to test credential rotation¶
# On bastion1 — update Vault with new token (reads from harbor-robot.json after rotation)
cd ~/harbor-images
HARBOR_PASS=$(python3 -c "import json; d=json.load(open('harbor-robot.json')); print(d.get('secret', d.get('token', d.get('password'))))")
kubectl exec -n vault vault-0 -- \
env VAULT_CACERT=/vault/userconfig/vault-tls/ca.crt \
env VAULT_TOKEN=${MAIN_ROOT} \
vault kv put secret/harbor/pull-secret \
username='robot$ask' \
password="${HARBOR_PASS}"
# ESO propagates within refreshInterval (1h), or force immediate refresh:
kubectl annotate clusterexternalsecret harbor-pull-secret \
force-sync=$(date +%s) --overwrite
# Verify new token in one namespace
kubectl get secret harbor-pull-secret -n vault \
-o jsonpath='{.data.\.dockerconfigjson}' | base64 -d | python3 -m json.tool
Step 7.13 — ArgoCD Repo Credential (intentionally NOT in ESO/Vault)¶
Design decision: ArgoCD repo credentials must NOT be Vault/ESO-managed
The circular dependency problem:
Vault sealed
→ ESO cannot read secret/argocd/gitlab-creds
→ ArgoCD repo credential missing or stale
→ ArgoCD cannot pull GitOps manifests from GitLab
→ Cannot sync the Vault fix via ArgoCD
→ DEADLOCK — platform unrecoverable without manual intervention
ArgoCD is the bootstrap layer — it deploys and recovers everything else including Vault. The dependency chain must flow strictly downward:
ArgoCD (bootstrap — must survive Vault outage)
↓ deploys
Vault + ESO (secrets layer)
↓ provides secrets to
Applications (app layer)
ArgoCD cannot sit below the thing it is responsible for recovering.
What to put in Vault/ESO and what not to:
| Secret | Where | Reason |
|---|---|---|
| ArgoCD GitLab repo credential | Static K8s Secret (manual, not in Git) | Bootstrap layer — must survive Vault outage |
harbor-pull-secret |
ESO → Vault | Infrastructure secret, non-critical if briefly stale |
| App database passwords, API keys | ESO → Vault | Application layer — correct home for Vault |
| Vault transit token | Static K8s Secret | Bootstrap exception — Vault needs this to unseal |
ArgoCD repo credential stays as a static K8s Secret. It is:
- Created once manually (not in Git — it contains a live PAT token)
- Rotated manually:
kubectl patch secret -n argocd <name> --type=merge -p '{"stringData":{"password":"<new-token>"}}' - Never deleted — losing it locks ArgoCD out of GitLab
To find and verify the current credential:
kubectl get secrets -n argocd -l argocd.argoproj.io/secret-type=repo-creds
# Shows the current static repo credential — verify it exists before any maintenance
# Verify ArgoCD can still reach GitLab
argocd repo list --grpc-web
# Expected: https://gitlab.cl1.sq4.aegis.internal/ STATUS=Successful
Summary Checklist¶
Pre-flight
- [x] vault-root and vault namespaces labeled pod-security.kubernetes.io/enforce=baseline
- [x] harbor-pull-secret copied to vault-root namespace
- [x] harbor-pull-secret copied to vault namespace
Root Vault
- [x] vault chart pushed to GitLab Helm registry (0.32.0)
- [x] hashicorp/vault:1.21.2 image mirrored to Harbor
- [x] Root Vault deployed — vault-root-0 Running
- [x] Root Vault initialized — ~/hashicorp_vault/vault-root-init-mod-auh1-dev.json saved securely
- [x] Root Vault unsealed (manually, first time only) — Sealed: false
- [x] vault-root-unseal-keys K8s Secret created (3 key shares, never in Git)
- [x] DIY unsealer pod Running — verified auto-recovery in 16s (tested 2026-06-11)
- [x] Transit engine enabled, autounseal key created
- [x] autounseal-policy created (encrypt/decrypt only)
- [x] Transit token created (periodic, 87600h)
- [x] Audit logging enabled on Root Vault
Main Vault
- [x] vault-transit-token K8s Secret created in vault namespace
- [x] Main Vault deployed standalone — vault-0 1/1 Running
- [x] Main Vault initialized — ~/hashicorp_vault/vault-main-init-mod-auh1-dev.json saved securely
- [x] vault status shows Initialized=true, Sealed=false, Storage Type=file
- [x] UI accessible at https://vault.mod.auh1.dev.dir (fix: backend-protocol: HTTPS annotation required)
- [x] KV v2 enabled at secret/
- [x] Kubernetes auth configured (kubernetes_host=https://kubernetes.default.svc)
- [x] eso-read policy created (note: requires -i flag on kubectl exec for heredoc stdin)
- [x] eso Kubernetes auth role created
- [x] vault-bootstrap-token K8s Secret created (not in Git)
- [x] Bootstrap Job + ConfigMap committed to applications/cluster-addons/vault/manifests/
- [ ] argocd/gitlab-creds stored in Vault
- [x] Audit logging enabled on Main Vault (vault audit enable file file_path=/vault/data/audit.log)
ESO
- [ ] external-secrets chart pushed to GitLab Helm registry (0.14.4)
- [x] ghcr/external-secrets/external-secrets:v0.14.4 image present in Harbor (verified 2026-06-11)
- [ ] harbor-pull-secret manually copied to external-secrets namespace (one-time bootstrap)
- [ ] external-secrets namespace labeled pod-security.kubernetes.io/enforce=baseline
- [x] ESO values file committed — applications/cluster-addons/external-secrets/values-mod-auh1-dev-ask.yaml
- [x] ESO ArgoCD Application committed — environment/auh1/dev/02-argocd-application/cluster-addons/external-secrets.yaml
- [ ] ESO deployed — 3 pods Running (controller, webhook, cert-controller)
Harbor Pull Secret via ESO
- [x] Harbor robot credentials stored in Vault at secret/harbor/pull-secret (read from ~/harbor-images/harbor-robot.json on bastion1, version 3)
- [x] Bootstrap policy updated — secret/data/harbor/* added to eso-read
- [x] ClusterExternalSecret committed — applications/cluster-addons/external-secrets/manifests/harbor-pull-secret-ces.yaml
- [x] managedNamespaceMetadata added to vault, vault-root, external-secrets ArgoCD Applications — no manual kubectl label needed
- [x] ClusterSecretStore committed — applications/cluster-addons/external-secrets/manifests/vault-backend-css.yaml (sync-wave 1)
- [ ] ClusterSecretStore/vault-backend — READY=True (after ArgoCD sync)
- [ ] harbor-pull-secret synced to vault, vault-root, external-secrets namespaces via ESO
ArgoCD Repo Credential - [x] Intentionally kept as static K8s Secret — circular dependency: ArgoCD cannot depend on Vault (which it deploys and recovers)
Troubleshooting¶
Pod Scheduling and Startup¶
| Symptom | Cause | Fix |
|---|---|---|
vault-root-0 or vault-N → Pending with PSA policy violation |
PSA restricted blocks IPC_LOCK at admission |
Label namespaces baseline: kubectl label namespace vault pod-security.kubernetes.io/enforce=baseline --overwrite (same for vault-root) |
vault-root-0 stuck 0/1 Running (readiness probe failing, not initialized) |
Sealed — expected state after first deploy | Run Step 7.6 unseal commands |
Main Vault pods stuck 0/1 Running after sync |
vault-transit-token secret missing in vault namespace |
Run Step 7.7 kubectl create secret command |
ImagePullBackOff on vault pods |
harbor-pull-secret not in vault namespace |
Run Pre-step harbor-pull-secret copy commands; ArgoCD cannot propagate secrets |
| Node drain blocked — pod vault-0 cannot be evicted | A PDB with minAvailable > 0 was applied on standalone (1 pod) — always violated |
Delete any PDB in the vault namespace: kubectl delete pdb -n vault --all |
Configuration Errors¶
| Symptom | Cause | Fix |
|---|---|---|
service_registration is configured, but storage does not support HA — pod CrashLoopBackOff |
service_registration "kubernetes" {} present in HCL but storage "file" does not support HA — only valid with Raft/Consul |
Remove service_registration "kubernetes" {} from standalone config |
Main Vault pods CrashLoopBackOff with HCL parse error: Unknown token IDENT env |
env() function used in seal stanza HCL — not supported |
Remove token = env(...) from seal stanza; use extraSecretEnvironmentVars with envName: VAULT_TOKEN |
Main Vault pods CrashLoopBackOff with 403 permission denied on Transit seal |
${VAULT_TRANSIT_TOKEN} passed literally — vault-helm 0.32.0 does not envsubst |
Remove token = from seal stanza entirely; Vault reads VAULT_TOKEN env var automatically |
VAULT_CACERT and/or VAULT_TOKEN missing from pod env |
Wrong key extraEnv or server.extraEnv used (silently ignored in chart 0.32.0) |
Use server.extraEnvironmentVars (dict) for plain values; server.extraSecretEnvironmentVars (list) for K8s Secret values |
x509: certificate signed by unknown authority in kubectl exec commands |
VAULT_CACERT not set in exec environment | Either extraEnvironmentVars.VAULT_CACERT is missing from values, or pass env VAULT_CACERT=<path> explicitly in the exec command |
audit {} block causes HCL parse error |
Audit devices cannot be configured via HCL config file — CLI only | Remove audit {} block from HCL config; enable via vault audit enable file file_path=/vault/logs/audit.log post-init |
path is already in use when running vault secrets enable transit |
Transit was already enabled in a previous run | Skip — verify: kubectl exec -n vault-root vault-root-0 -- env VAULT_TOKEN=<tok> vault secrets list |
policy parameter not supplied or empty on vault policy write |
kubectl exec without -i flag — heredoc stdin not passed into container |
Add -i flag: kubectl exec -i -n vault vault-0 -- vault policy write eso-read - << 'POLICY' |
Root Vault Auto-Recovery (verified)¶
| Symptom | Cause | Fix |
|---|---|---|
storage.raft.fsm: raft FSM db file has wider permissions than needed: needed=-rw------- existing=-rw-rw---- |
Raft data file on PVC has group-write bit set — Vault warns but continues normally | Harmless in this context — PVC is pod-exclusive. No action needed. |
Verified recovery sequence (tested 2026-06-11):
vault-root-0 deleted
→ pod restarted (sealed, exit code 2)
→ DIY unsealer detected within 15s
→ sent 3 unseal keys
→ Root Vault active at 16s
→ Transit engine remounted automatically
→ Main Vault unaffected (already holds unseal key in memory)
Initialization and Unsealing¶
| Symptom | Cause | Fix |
|---|---|---|
Init file is 0 bytes after kubectl exec ... > file |
Shell > creates/truncates the file BEFORE the command runs. If kubectl exec fails (pod not ready, TLS error), stdout is empty → file stays empty. Second attempt finds vault already initialized → error to stderr → file empty again |
Always use tee: kubectl exec ... vault operator init -format=json \| tee file.json. Output goes to both terminal and file; if command fails, you see the error instead of a silent empty file |
| Symptom | Cause | Fix |
|---|---|---|
PermissionError: /root/vault-root-init-mod-auh1-dev.json |
Running as cloud-user; /root/ is not writable |
Use ~/hashicorp_vault/ (cloud-user home). Always write init files to ${HOME}/hashicorp_vault/. |
Main Vault pods log stored unseal keys are supported, but none were found |
Expected pre-init state — vault awaiting first vault operator init |
Run Step 7.9 initialization. This message is normal for sealed-but-not-initialized pods. |
| Root Vault pod restarted — Main Vault new pods cannot unseal | Root Vault sealed after restart; DIY unsealer recovers automatically within ~15s | Wait for unsealer loop to detect and unseal. If unseal keys secret missing, re-create it (Step 7.6b). Already-running Main Vault pods are unaffected. |
Unsealer logs Root Vault sealed but unseal keys not yet available — waiting |
vault-root-unseal-keys Secret not yet created |
Run Step 7.6b to create the secret. The unsealer hot-reloads volume within ~60s — no pod restart needed. |
Raft and Cluster¶
| Symptom | Cause | Fix |
|---|---|---|
| Raft peers not joining after init | TLS cert missing or ca.crt SAN mismatch |
kubectl get secret vault-tls -n vault -o jsonpath='{.data}' — verify ca.crt, tls.crt, tls.key all present; check SANs include *.vault-internal |
Only 1 of 5 pods shows 1/1 Running after init |
Remaining pods haven't joined Raft yet | Wait 30–60s; check logs: kubectl logs -n vault vault-1 for retry_join activity |
UI / Ingress¶
| Symptom | Cause | Fix |
|---|---|---|
HTTP ERROR 400 on https://vault.mod.auh1.dev.dir |
nginx terminates TLS and forwards plain HTTP to Vault, but Vault listener has tls_disable = 0 — it expects HTTPS |
Add nginx.ingress.kubernetes.io/backend-protocol: "HTTPS" to ingress annotations in values file |
ESO¶
| Symptom | Cause | Fix |
|---|---|---|
ClusterSecretStore READY=False |
K8s auth misconfigured | vault read auth/kubernetes/config — kubernetes_host must be https://kubernetes.default.svc |
ExternalSecret SecretSyncedError |
ESO SA not bound to Vault role | Verify bound_service_account_names=external-secrets and bound_service_account_namespaces=external-secrets in auth/kubernetes/role/eso |