Phase 26 β HashiCorp Vault β Secrets Management
Vault replaces the ~/.xxx-admin credential-file pattern on the controller with a proper secrets management layer: encrypted at rest, access audited, credentials injectable directly into pods via the Vault Agent Injector.
What Vault Solvesβ
β Without Vault:
~/.argocd-admin, ~/.grafana-admin, ~/.harbor-admin ...
(mode 600 files on the controller β secure but unaudited, not rotatable)
kubectl get secret -o yaml
β data: base64(plaintext) β anyone with cluster access can read
β With Vault:
Secrets encrypted at rest (AES-256-GCM via Raft)
Every read/write logged with who/when/from where
Pods receive secrets as injected files β no Kubernetes Secret needed
Credentials rotatable without redeploying apps
Architectureβ
vault-0 (StatefulSet, 1 replica)
β storage: Raft integrated (no external etcd)
β PVC: data-vault-0 (5Gi, Longhorn)
β TLS: disabled internally (handled by NGINX Ingress + minicloud CA)
β
βββ vault-agent-injector (Deployment)
β Mutating webhook β patches pods with Vault Agent sidecar when
β vault.hashicorp.com/agent-inject: "true" annotation present
β
βββ NGINX Ingress
vault.10.0.0.200.nip.io β port 8200
vault.devandre.sbs β port 8200 (via Cloudflare Tunnel)
Installβ
helm repo add hashicorp https://helm.releases.hashicorp.com
helm repo update hashicorp
helm install vault hashicorp/vault \
--namespace vault \
--create-namespace \
--values /home/ktayl/minicloud-ktaylorganisation/ansible/helm-values/vault-values.yaml \
--wait --timeout 5m
vault-values.yaml:
server:
ha:
enabled: false
standalone:
enabled: true
config: |
ui = true
listener "tcp" {
tls_disable = 1
address = "[::]:8200"
cluster_address = "[::]:8201"
}
storage "raft" {
path = "/vault/data"
node_id = "vault-0"
}
service_registration "kubernetes" {}
dataStorage:
enabled: true
size: 5Gi
storageClass: longhorn
accessMode: ReadWriteOnce
resources:
requests:
memory: 256Mi
cpu: 250m
limits:
memory: 512Mi
cpu: 500m
injector:
enabled: true
resources:
requests:
memory: 64Mi
cpu: 50m
limits:
memory: 128Mi
cpu: 250m
ui:
enabled: true
serviceType: ClusterIP
Initialize and Unsealβ
Run once after first install. The pod will show 0/1 until initialized and unsealed β the readiness probe queries /v1/sys/health.
kubectl exec -n vault vault-0 -- vault operator init \
-key-shares=3 \
-key-threshold=2 \
-format=json
Save all output immediately:
# On controller β mode 600, never committed
cat > ~/.vault-unseal-key-1 << 'EOF'
<unseal_keys_hex[0]>
EOF
cat > ~/.vault-unseal-key-2 << 'EOF'
<unseal_keys_hex[1]>
EOF
cat > ~/.vault-unseal-key-3 << 'EOF'
<unseal_keys_hex[2]>
EOF
cat > ~/.vault-root-token << 'EOF'
<root_token>
EOF
chmod 600 ~/.vault-unseal-key-* ~/.vault-root-token
Unseal (needs 2 of 3 keys):
KEY1=$(cat ~/.vault-unseal-key-1 | tr -d '\n')
KEY2=$(cat ~/.vault-unseal-key-2 | tr -d '\n')
kubectl exec -n vault vault-0 -- vault operator unseal "$KEY1"
kubectl exec -n vault vault-0 -- vault operator unseal "$KEY2"
# Verify: Sealed = false, HA Mode = active
kubectl exec -n vault vault-0 -- vault status
Unseal is required after every pod restart. Vault starts sealed β it cannot serve requests until unsealed with the threshold number of keys. This is by design: an attacker with access to the pod cannot read encrypted data without the keys.
Ingress + TLSβ
# TLS cert from minicloud-ca
cat <<EOF | kubectl apply -f -
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: vault-tls
namespace: vault
spec:
secretName: vault-tls
dnsNames:
- vault.10.0.0.200.nip.io
duration: 2160h
renewBefore: 720h
privateKey:
algorithm: ECDSA
size: 256
issuerRef:
name: minicloud-ca
kind: ClusterIssuer
group: cert-manager.io
EOF
# NGINX Ingress
cat <<EOF | kubectl apply -f -
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: vault
namespace: vault
spec:
ingressClassName: nginx
rules:
- host: vault.10.0.0.200.nip.io
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: vault
port:
number: 8200
- host: vault.devandre.sbs
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: vault
port:
number: 8200
tls:
- hosts:
- vault.10.0.0.200.nip.io
secretName: vault-tls
- hosts:
- vault.devandre.sbs
secretName: vault-tls
EOF
Cloudflare Tunnel (~/.cloudflared/config.yml) β add before the catch-all rule:
- hostname: vault.devandre.sbs
service: https://10.0.0.200
originRequest:
noTLSVerify: true
originServerName: vault.10.0.0.200.nip.io
~/.local/bin/cloudflared tunnel route dns minicloud vault.devandre.sbs
Verification (regression)β
# Both pods Running
kubectl get pods -n vault
# vault-0 1/1 Running
# vault-agent-injector-... 1/1 Running
# PVC bound on Longhorn
kubectl get pvc -n vault
# Vault health
kubectl exec -n vault vault-0 -- vault status
# Initialized: true Sealed: false HA Mode: active
# Public endpoint
curl -s https://vault.devandre.sbs/v1/sys/health | python3 -c \
'import sys,json; d=json.load(sys.stdin); print("initialized:", d["initialized"], "sealed:", d["sealed"])'
Unseal After Restartβ
Every time vault-0 pod restarts (node reboot, OOM kill, rolling update), Vault starts sealed. Unseal manually:
KEY1=$(ssh controller "cat ~/.vault-unseal-key-1 | tr -d '\n'")
KEY2=$(ssh controller "cat ~/.vault-unseal-key-2 | tr -d '\n'")
ssh controller "kubectl exec -n vault vault-0 -- vault operator unseal '$KEY1'"
ssh controller "kubectl exec -n vault vault-0 -- vault operator unseal '$KEY2'"
Or run entirely on the controller:
KEY1=$(cat ~/.vault-unseal-key-1 | tr -d '\n')
KEY2=$(cat ~/.vault-unseal-key-2 | tr -d '\n')
kubectl exec -n vault vault-0 -- vault operator unseal "$KEY1"
kubectl exec -n vault vault-0 -- vault operator unseal "$KEY2"
Done Whenβ
β vault-0 and vault-agent-injector both 1/1 Running
β PVC data-vault-0 Bound on Longhorn (5Gi)
β vault status: Initialized=true, Sealed=false, HA Mode=active
β https://vault.10.0.0.200.nip.io/ui reachable (Tailscale)
β https://vault.devandre.sbs/v1/sys/health returns initialized=true
β Unseal keys saved at ~/.vault-unseal-key-{1,2,3} mode 600 on controller
β Root token saved at ~/.vault-root-token mode 600 on controller
KV v2 Secrets Engineβ
TOKEN=$(cat ~/.vault-root-token | tr -d '\n')
kubectl exec -n vault vault-0 -- sh -c "
export VAULT_TOKEN=$TOKEN
export VAULT_ADDR=http://127.0.0.1:8200
vault secrets enable -path=secret kv-v2
"
Platform admin credentials stored under secret/platform/:
vault kv put secret/platform/argocd password='...'
vault kv put secret/platform/grafana password='...'
vault kv put secret/platform/harbor password='...'
vault kv put secret/platform/minio password='...'
vault kv put secret/platform/authentik api_token='...' bootstrap_password='...'
Demo workload secret under secret/platform-demo/:
vault kv put secret/platform-demo/config \
api_key=demo-api-key-a3f8b2c1 \
environment=production \
log_level=info
Kubernetes Auth Backendβ
TOKEN=$(cat ~/.vault-root-token | tr -d '\n')
kubectl exec -n vault vault-0 -- sh -c "
export VAULT_TOKEN=$TOKEN
export VAULT_ADDR=http://127.0.0.1:8200
vault auth enable kubernetes
vault write auth/kubernetes/config kubernetes_host=https://10.0.0.2:6443
"
Policy (/tmp/platform-demo-policy.hcl):
path "secret/data/platform-demo/*" {
capabilities = ["read"]
}
kubectl cp /tmp/platform-demo-policy.hcl vault/vault-0:/tmp/platform-demo-policy.hcl
kubectl exec -n vault vault-0 -- sh -c "
export VAULT_TOKEN=$TOKEN
export VAULT_ADDR=http://127.0.0.1:8200
vault policy write platform-demo /tmp/platform-demo-policy.hcl
vault write auth/kubernetes/role/platform-demo \
bound_service_account_names=platform-demo \
bound_service_account_namespaces=gitops-demo \
policies=platform-demo \
ttl=1h
"
Vault Agent Injector β platform-demo Demoβ
The Vault Agent Injector runs as a mutating webhook. When a pod has vault.hashicorp.com/agent-inject: "true", a vault-agent sidecar is automatically injected at admission time. The sidecar authenticates using the pod's ServiceAccount token, fetches the secret, and writes it to /vault/secrets/.
manifests/platform-demo/00-deployment.yaml (in minicloud-gitops):
apiVersion: v1
kind: ServiceAccount
metadata:
name: platform-demo
namespace: gitops-demo
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: platform-demo
namespace: gitops-demo
spec:
template:
metadata:
annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: "platform-demo"
vault.hashicorp.com/agent-inject-secret-config: "secret/data/platform-demo/config"
vault.hashicorp.com/agent-inject-template-config: |
{{- with secret "secret/data/platform-demo/config" -}}
API_KEY={{ .Data.data.api_key }}
ENVIRONMENT={{ .Data.data.environment }}
LOG_LEVEL={{ .Data.data.log_level }}
{{- end }}
spec:
serviceAccountName: platform-demo
# ... rest of spec
Verify injection:
# Pods show 2/2 (app container + vault-agent sidecar)
kubectl get pods -n gitops-demo -l app=platform-demo
# Read the injected secret from the sidecar container
# (platform-demo is a scratch image β no shell in the app container)
kubectl exec -n gitops-demo <pod> -c vault-agent -- cat /vault/secrets/config
# API_KEY=demo-api-key-a3f8b2c1
# ENVIRONMENT=production
# LOG_LEVEL=info
ArgoCD Proxy Fixβ
Cluster nodes route outbound HTTPS through the MAAS Squid proxy at 10.0.0.1:8000. ArgoCD's repo-server needs this to reach GitHub:
Add to argocd-values.yaml:
repoServer:
env:
- name: HTTPS_PROXY
value: http://10.0.0.1:8000
- name: HTTP_PROXY
value: http://10.0.0.1:8000
- name: NO_PROXY
value: 10.0.0.0/8,127.0.0.1,localhost,.svc,.cluster.local
Then upgrade: helm upgrade argo-cd argo/argo-cd -n argocd --values argocd-values.yaml --wait
Done Whenβ
β vault-0 and vault-agent-injector both 1/1 Running
β PVC data-vault-0 Bound on Longhorn (5Gi)
β vault status: Initialized=true, Sealed=false, HA Mode=active
β KV v2 engine at secret/: 5 platform credentials + platform-demo config stored
β Kubernetes auth enabled, role platform-demo configured
β platform-demo pods show 2/2 (vault-agent sidecar injected)
β /vault/secrets/config readable inside platform-demo pods
β ArgoCD sync working (Synced Healthy Succeeded) via Squid proxy
β https://vault.devandre.sbs/v1/sys/health returns initialized=true