Secret Management
This document covers how secrets are managed in the homelab: where they are stored, how they flow into running pods, how to add secrets for new services, and how to rotate credentials.
Design Principles
- No secrets in git — no
SecretYAML files are committed. OnlyExternalSecretresources (which reference secret names, not values) live in git. - Infisical is the single source of truth — all application credentials live in one place with an audit trail.
- Terraform owns bootstrap secrets — a small set of credentials that Infisical itself needs to start (ENCRYPTION_KEY, AUTH_SECRET, postgres/redis passwords) are injected by Terraform from a local
terraform.tfvarsfile that is gitignored. - ESO bridges Infisical to Kubernetes — the External Secrets Operator watches
ExternalSecretresources and creates realSecretobjects in the cluster, polling Infisical every hour. - Least privilege — both ESO and Infisical run with non-root security contexts and read-only root filesystems where supported by their upstream charts. See each service's
README.mdfor security details.
Secret Layers
flowchart TD
subgraph git["Git (public, no secrets)"]
ES["ExternalSecret YAML\n(references key names only)"]
CSS_yaml["ClusterSecretStore YAML\n(references secret name only)"]
end
subgraph tfvars["terraform.tfvars (gitignored, local only)"]
TFVars["infisical_encryption_key\ninfisical_auth_secret\ninfisical_postgres_password\ninfisical_redis_password\ninfisical_machine_identity_client_id\ninfisical_machine_identity_client_secret\nargocd_oidc_client_secret"]
end
subgraph tf["Terraform state"]
TFState["bootstrap K8s Secrets\n(sensitive, local tfstate only)"]
end
subgraph cluster["Kubernetes Cluster"]
subgraph infisicalNs["infisical namespace"]
infisical_secrets["infisical-secrets\nENCRYPTION_KEY + AUTH_SECRET"]
end
subgraph esoNs["external-secrets namespace"]
machine_id["infisical-machine-identity\nclientId + clientSecret"]
end
subgraph argocdNs["argocd namespace"]
helm_secrets["infisical-helm-secrets\npostgres + redis passwords"]
end
subgraph authentikNs["authentik namespace"]
authentik_secret["authentik-secret\n(created by ESO)"]
end
subgraph monitoringNs["monitoring namespace"]
grafana_secret["grafana-secret\n(created by ESO)"]
end
subgraph openclawNs["openclaw namespace"]
openclaw_secret["openclaw-secret\n(created by ESO)"]
end
subgraph argocdNs2["argocd namespace"]
argocd_secret["argocd-secret\n(admin.password set by Helm)"]
end
end
subgraph infisical_store["Infisical (project: homelab / env: prod)"]
AUTHENTIK_SECRETS["AUTHENTIK_SECRET_KEY\nAUTHENTIK_BOOTSTRAP_PASSWORD\nAUTHENTIK_BOOTSTRAP_TOKEN\nAUTHENTIK_POSTGRES_PASSWORD"]
GRAFANA_SECRETS["GRAFANA_ADMIN_PASSWORD\nGRAFANA_OAUTH_CLIENT_SECRET"]
OPENCLAW_SECRETS["OPENCLAW_GATEWAY_TOKEN\nOPENROUTER_API_KEY\nGEMINI_API_KEY\nGITHUB_TOKEN\nDISCORD_BOT_TOKEN\nDISCORD_WEBHOOK_DEUTSCH\nDISCORD_WEBHOOK_ENGLISH\nDISCORD_WEBHOOK_ALERTS"]
end
TFVars --> TFState
TFState --> infisical_secrets
TFState --> machine_id
TFState --> helm_secrets
TFVars -- "argocd_oidc_client_secret\n→ Helm value" --> argocd_secret
CSS_yaml --> machine_id
machine_id -- "Universal Auth" --> infisical_store
AUTHENTIK_SECRETS --> authentik_secret
GRAFANA_SECRETS --> grafana_secret
OPENCLAW_SECRETS --> openclaw_secret
Bootstrap Secrets (Terraform-Managed)
These secrets are the "chicken-and-egg" exceptions — they cannot come from Infisical because Infisical itself needs them to start. All are created by terraform apply, live in terraform.tfstate (local only), and are never committed to git.
| K8s Secret | Namespace | Keys | Purpose |
|---|---|---|---|
infisical-secrets |
infisical |
ENCRYPTION_KEY, AUTH_SECRET |
Infisical app encryption and session signing |
infisical-helm-secrets |
argocd |
values.yaml (YAML blob) |
Postgres + Redis passwords passed to Infisical Helm chart via ArgoCD Application |
infisical-machine-identity |
external-secrets |
clientId, clientSecret |
ESO authenticates to Infisical using this Universal Auth identity |
argocd-secret |
argocd |
oidc.argocd.clientSecret |
ArgoCD OIDC client secret for Authentik SSO — set via Terraform set_sensitive Helm value. Not managed by ESO to avoid annotation-propagation conflicts. |
Infisical Project Structure
flowchart LR
subgraph infisical["Infisical"]
subgraph org["Organization"]
subgraph project["Project: homelab\nslug: homelab"]
subgraph prod["Environment: prod"]
subgraph path["Secret Path: /"]
s9["AUTHENTIK_SECRET_KEY"]
s10["AUTHENTIK_BOOTSTRAP_PASSWORD"]
s11["AUTHENTIK_BOOTSTRAP_TOKEN"]
s12["AUTHENTIK_POSTGRES_PASSWORD"]
s13["GRAFANA_ADMIN_PASSWORD"]
s14["GRAFANA_OAUTH_CLIENT_SECRET"]
s16["OPENCLAW_GATEWAY_TOKEN"]
s17["OPENROUTER_API_KEY"]
s18["GEMINI_API_KEY"]
s19["GITHUB_TOKEN"]
s20["DISCORD_BOT_TOKEN"]
s25["DISCORD_WEBHOOK_DEUTSCH"]
s26["DISCORD_WEBHOOK_ENGLISH"]
s27["DISCORD_WEBHOOK_ALERTS"]
end
end
end
subgraph identities["Machine Identities"]
MI["homelab-eso\nUniversal Auth"]
end
end
end
MI -- "Member role\non homelab project" --> project
ArgoCD OIDC client secret is the only secret managed via Terraform instead of ESO (to avoid annotation-propagation conflicts with
argocd-secret). All other secrets are pulled from Infisical by ESO.
The ClusterSecretStore in k8s/apps/external-secrets/cluster-secret-store.yaml is configured with:
projectSlug: homelabenvironmentSlug: prodsecretsPath: /
This means any ExternalSecret using this store references secrets by their key name directly (e.g., key: AUTHENTIK_SECRET_KEY).
How ExternalSecrets Work
Each application that needs secrets has an ExternalSecret resource in its kustomization directory. ArgoCD syncs the ExternalSecret to the cluster; ESO then creates the actual Secret.
sequenceDiagram
participant ArgoCD
participant ESO as ESO Controller
participant CSS as ClusterSecretStore
participant Infisical
participant K8s as Kubernetes
ArgoCD->>K8s: Apply ExternalSecret (from git)
ESO->>CSS: Validate store is ready
CSS->>Infisical: POST /api/v1/auth/universal-auth/login
Infisical-->>CSS: accessToken (JWT)
ESO->>Infisical: GET /api/v3/secrets/raw?workspaceSlug=homelab&environment=prod
Infisical-->>ESO: { AUTHENTIK_SECRET_KEY: "abc123", ... }
ESO->>K8s: Create/Update Secret "authentik-secret"
Note over ESO: Repeats every refreshInterval (1h)
Adding Secrets for a New Service
When deploying a new service that needs secrets, follow these steps:
Step 1: Add the secret to Infisical
Open https://holdens-mac-mini.story-larch.ts.net:8445, navigate to the homelab project → prod environment, and add your secret (e.g., MY_SERVICE_API_KEY).
Step 2: Create an ExternalSecret manifest
Create k8s/apps/my-service/external-secret.yaml:
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: my-service-secret
namespace: my-service
spec:
refreshInterval: 1h
secretStoreRef:
name: infisical
kind: ClusterSecretStore
target:
name: my-service-secret
creationPolicy: Owner
data:
- secretKey: API_KEY
remoteRef:
key: MY_SERVICE_API_KEY
Step 3: Add to kustomization
In k8s/apps/my-service/kustomization.yaml, add:
resources:
- external-secret.yaml
# ... other resources
Step 4: Reference the Secret in the Deployment
env:
- name: API_KEY
valueFrom:
secretKeyRef:
name: my-service-secret
key: API_KEY
Step 5: Push to git
ArgoCD will detect the new ExternalSecret and sync it. ESO will then create the K8s Secret within seconds. The Deployment will get the secret on next rollout.
Credential Rotation
Rotating the Machine Identity (ESO ↔ Infisical)
- In the Infisical UI, go to Settings → Machine Identities → create a new identity or generate new credentials for the existing one.
- Update
terraform/terraform.tfvars:infisical_machine_identity_client_id = "<new-client-id>" infisical_machine_identity_client_secret = "<new-client-secret>" - Apply:
Terraform updates only the
cd terraform && terraform applyinfisical-machine-identityK8s Secret. ESO picks up the new credentials on its next poll cycle (~30s).
Rotating the Infisical ENCRYPTION_KEY / AUTH_SECRET
Warning: Changing
ENCRYPTION_KEYrequires a data migration — all encrypted secrets in Infisical's database must be re-encrypted. Do this only if the key is compromised, and follow the Infisical key rotation guide first.
- Update
terraform/terraform.tfvarswith new values. - Run
terraform apply. - Restart the Infisical pod:
kubectl rollout restart deployment -n infisical -l app.kubernetes.io/component=infisical
Rotating the ArgoCD OIDC Client Secret
ArgoCD's OIDC client secret for Authentik SSO is managed through Terraform Helm values, not ESO.
- Generate a new client secret in Authentik (UI or API) for the
argocdprovider. - Update
terraform/terraform.tfvars:argocd_oidc_client_secret = "<new-secret>" - Apply:
Helm updates
cd terraform && terraform applyargocd-secretwith the new OIDC secret. ArgoCD picks it up on the next login — no pod restart required.
Updating an Application Secret
- In the Infisical UI, update the secret value in
homelab / prod. - ESO automatically reconciles within
refreshInterval(1 hour). To apply immediately:kubectl annotate externalsecret <name> -n <namespace> \ force-sync=$(date +%s) --overwrite - The K8s Secret is updated. Restart the consuming pod to pick up the new value:
kubectl rollout restart deployment <name> -n <namespace>
Troubleshooting
flowchart TD
Start["ExternalSecret shows SecretSyncedError"]
A["kubectl describe externalsecret <name> -n <ns>"]
Start --> A
A --> B{"Error message?"}
B -- "ClusterSecretStore not ready" --> C["kubectl get clustersecretstore infisical"]
C --> D{"Status?"}
D -- "InvalidProviderConfig\n401 Unauthorized" --> E["Machine identity credentials wrong\nor not in Terraform tfvars yet\n→ terraform apply"]
D -- "InvalidProviderConfig\n403 Forbidden" --> F["Machine identity not added\nto homelab project in Infisical UI\n→ Project → Access Control → Add Identity"]
D -- "InvalidProviderConfig\n404 Project not found" --> G["Project slug wrong\nCheck Infisical project settings\nEnsure slug = 'homelab'"]
D -- "Valid / Ready: True" --> H["Store is fine, force ExternalSecret refresh:\nkubectl annotate externalsecret <name> -n <ns> force-sync=$(date +%s) --overwrite"]
B -- "secret key not found" --> I["Key name doesn't exist in Infisical\n→ Add it in Infisical UI\n→ homelab / prod / AUTHENTIK_SECRET_KEY etc."]
| Symptom | Cause | Fix |
|---|---|---|
ClusterSecretStore shows InvalidProviderConfig 401 |
Wrong machine identity credentials | terraform apply with correct credentials in tfvars |
ClusterSecretStore shows InvalidProviderConfig 403 |
Machine identity not added to Infisical project | Infisical UI → Project → Access Control → Machine Identities → Add |
ClusterSecretStore shows InvalidProviderConfig 404 |
Wrong project slug | Infisical UI → Project Settings → confirm slug is homelab |
ExternalSecret shows SecretSyncedError after store becomes valid |
Cached old error state | kubectl annotate externalsecret <name> -n <ns> force-sync=$(date +%s) --overwrite |
| Pod can't start, missing secret keys | ExternalSecret not synced yet | kubectl get externalsecret -n <ns> — wait for SecretSynced: True |
| Infisical pod crashes on startup | infisical-secrets K8s Secret is wrong/missing |
Check kubectl get secret infisical-secrets -n infisical; re-run terraform apply |