Storing GeoServer Secrets in Vault with Kubernetes Auth
This guide moves the rendering engine’s database credentials out of Kubernetes manifests and into Vault, authenticated by the pod’s own service-account identity rather than by a stored token. It belongs to Secrets Management for Geospatial Platforms, within the Core Portal Architecture & Security Boundaries framework.
Prerequisites
- A Kubernetes cluster with a service-account token issuer reachable by Vault, and cluster-admin rights to create roles.
- Vault 1.13 or newer, unsealed, with the Kubernetes auth method available to enable.
- GeoServer deployed as a StatefulSet or Deployment with its data directory on a persistent volume.
- A PostGIS instance whose application role you are willing to rotate as part of the migration, per rotating GeoServer credentials without downtime.
What the pod actually presents
The mechanism is worth understanding before configuring it, because the common failure is a role binding that is broader than intended. The pod presents its projected service-account token; Vault validates that token against the cluster’s public keys and reads the identity it asserts — namespace, service account name, and audience. The Vault role says which of those combinations may read which paths.
Step-by-step implementation
1. Give the engine its own service account
Never bind Vault roles to the default service account. A dedicated account is what makes the grant specific, and it costs three lines.
apiVersion: v1
kind: ServiceAccount
metadata:
name: geoserver
namespace: geoportal
automountServiceAccountToken: false # the projected volume below is explicit
2. Enable Kubernetes auth in Vault and bind a narrow role
vault auth enable -path=k8s-geoportal kubernetes
# Vault verifies tokens against the cluster's issuer and public keys.
vault write auth/k8s-geoportal/config \
kubernetes_host="https://kubernetes.default.svc:443" \
kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt \
disable_local_ca_jwt=false
# One role, one namespace, one service account, one audience. No wildcards.
vault write auth/k8s-geoportal/role/geoserver \
bound_service_account_names=geoserver \
bound_service_account_namespaces=geoportal \
audience=vault \
policies=geoserver-read \
ttl=1h \
max_ttl=4h
3. Write the policy at the narrowest path that works
# geoserver-read.hcl — read one path, list nothing, write nothing.
path "kv/data/geoportal/geoserver/postgis" {
capabilities = ["read"]
}
# Explicitly deny the parent so a future wildcard cannot widen this by accident.
path "kv/data/geoportal/*" {
capabilities = ["deny"]
}
4. Project the token and inject the secret
The Vault agent injector turns the annotations below into an init container that authenticates, fetches, and writes the value to a file the engine reads at start-up.
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: geoserver
namespace: geoportal
spec:
template:
metadata:
annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: "geoserver"
vault.hashicorp.com/auth-path: "auth/k8s-geoportal"
vault.hashicorp.com/agent-inject-secret-postgis: "kv/data/geoportal/geoserver/postgis"
# Render into the shape the engine expects, not raw JSON.
vault.hashicorp.com/agent-inject-template-postgis: |
{{- with secret "kv/data/geoportal/geoserver/postgis" -}}
PGUSER={{ .Data.data.username }}
PGPASSWORD={{ .Data.data.password }}
{{- end }}
vault.hashicorp.com/agent-inject-perms-postgis: "0400"
spec:
serviceAccountName: geoserver
containers:
- name: geoserver
image: geoserver:2.24.2
volumeMounts:
- name: sa-token
mountPath: /var/run/secrets/tokens
readOnly: true
volumes:
- name: sa-token
projected:
sources:
- serviceAccountToken:
path: vault-token
audience: vault # must match the role's audience
expirationSeconds: 3600
5. Point the datastore at the injected value
GeoServer stores datastore passwords in its own configuration, so the migration is not complete until the datastore is updated through the REST API to use the credential Vault now owns — editing the file directly bypasses the encoder and leaves the store unusable.
# Read the injected credential inside the pod and push it via REST.
kubectl exec -n geoportal geoserver-0 -- sh -c '
. /vault/secrets/postgis
curl -sf -u "$GS_ADMIN" -XPUT -H "Content-Type: application/json" \
-d "{\"dataStore\":{\"connectionParameters\":{\"entry\":[
{\"@key\":\"user\",\"$\":\"$PGUSER\"},
{\"@key\":\"passwd\",\"$\":\"$PGPASSWORD\"}]}}}" \
"http://localhost:8080/geoserver/rest/workspaces/geoportal/datastores/postgis.json"
'
What breaks, and when
The migration has a specific failure profile: everything works until something restarts at an inconvenient moment. Knowing which dependencies are now in the start-up path is the difference between a controlled rollout and a surprise.
The middle row is the one to plan for. A running pod holds its secret and is unaffected by Vault being unavailable; a restarting pod is not. That asymmetry means a Vault outage is invisible until the next deployment, node drain or crash — so the mitigation is to alert on the store’s health directly rather than to rely on the portal’s own symptoms.
Migrating without a window
The move from a manifest-held credential to a Vault-held one does not need an outage, because both can be valid at once for as long as you need. Run the two paths in parallel and cut over per component rather than all at once.
Do step four the same week, not the next quarter. A migration that stops after step three leaves both copies valid indefinitely, which is strictly worse than where it started: the same credential exists in the same places, plus a new one.
Verification
# 1. The role authenticates only from the intended service account
kubectl -n geoportal exec deploy/other-app -- \
vault write auth/k8s-geoportal/login role=geoserver \
jwt=@/var/run/secrets/tokens/vault-token
# expect: permission denied — the account name does not match
# 2. The engine's pod receives the rendered file, with tight permissions
kubectl -n geoportal exec geoserver-0 -- stat -c '%a %U %n' /vault/secrets/postgis
# expect: 400 and the container's user
# 3. The credential is absent from the pod environment and from the manifest
kubectl -n geoportal exec geoserver-0 -- env | grep -c PGPASSWORD
# expect: 0
kubectl -n geoportal get statefulset geoserver -o yaml | grep -ci password
# expect: 0
# 4. The datastore actually works with the injected credential
curl -sf -o /dev/null -w '%{http_code}\n' \
"https://portal.example.gov/geoserver/wfs?service=WFS&version=2.0.0&request=GetCapabilities"
# expect: 200
# 5. Vault records the read, with the workload identity attached
vault read sys/internal/counters/activity | head
# and: the audit log names auth/k8s-geoportal/geoserver, not a human
Troubleshooting matrix
| Symptom | Likely cause | Fix |
|---|---|---|
| Pod stuck in init with no error in the app container | The injector’s init container is retrying against an unreachable Vault | Read the init container’s logs; alert on init duration, not just readiness |
permission denied at login for the right service account |
Audience mismatch between the projected token and the Vault role | Make audience identical in both places; it is not checked by name elsewhere |
| Secret file present but the engine still uses the old password | The datastore configuration holds its own copy, updated only via REST | Push the credential through the REST API and reset the connection pool |
| Every workload in the namespace can read the secret | Role bound to the default service account | Bind to a dedicated account and set automountServiceAccountToken: false |
| Credential rotates in Vault but pods keep the old one | The value is read once at start-up | Roll the deployment on rotation, or add a template with a restart hook |
| Vault outage takes down a scale-up but not running pods | The fetch is in the start-up path with no cached fallback | Cache last-good on the node and stagger fetches across pods |
| Audit shows reads from an unexpected role | A second Vault role grants the same path | Add the explicit deny on the parent path and re-audit the policy set |
FAQ
Why not use a Kubernetes Secret and skip Vault entirely?
A cluster Secret is a genuine improvement over a value in a manifest, and for a small single-cluster portal it may be enough. What it lacks is rotation machinery, an access audit trail, and a trust boundary that survives cluster compromise — anyone who can read Secrets in the namespace can read the credential, permanently and without a record.
Does this remove the need to rotate the database password?
No, but it makes rotation cheap: the value exists in one place, so changing it is a single write plus a reload of the consumers. The overlap discipline is unchanged — create the new role, let both be valid, cut over, then revoke — and it is described in full in the rotation guide linked above.
Should the engine use dynamic database credentials instead of a static one?
It is attractive and it interacts badly with connection pooling: a pooled connection can outlive the lease that opened it, and the failure appears as intermittent authentication errors under load rather than at renewal. If you use dynamic credentials, set the pool’s maximum connection lifetime comfortably below the lease, and test it under sustained traffic rather than in a quiet staging environment.
What happens during a Vault upgrade or reseal?
Running pods are unaffected because they already hold their secret. Anything that restarts during the window will not start. Plan Vault maintenance as a change window for the portal’s deployments even though the portal itself stays up, and avoid scheduling node upgrades or autoscaling changes across the same period.
Is the injected file safe on the node?
It lives in an in-memory volume that is removed when the pod terminates, with permissions restricted to the container’s user — which is materially better than an environment variable, since it does not appear in process listings, crash dumps, or the diagnostic bundles that support tooling collects. It is still readable by anyone who can exec into the pod, so pod-exec permission remains a credential-equivalent grant.
Related
- Sealing GeoNode Secrets for GitOps — the alternative when a secret must live in version control.
- Auditing Secret Access for Agency Compliance — turning the store’s log into an answer.
- Kubernetes StatefulSets for PostGIS Databases — the database this credential reaches.
Up one level: Secrets Management for Geospatial Platforms.