From Leaking Secrets to Full Automation: Managing Secrets with Vault in Kubernetes

KubernetesDevOpsCloudGitOps

captionless image

Do you still store API keys or passwords directly in your Kubernetes manifests?

It’s a common practice — but a risky one. Secrets often end up exposed in Git, their rotation is rarely automated, and access tracking quickly becomes hard to manage. In production, a single leak can compromise a service — or even your entire environment.

To solve this problem, HashiCorp Vault provides a centralized and secure way to manage secrets: built-in encryption, fine-grained access control through policies, and automatic credential rotation. When paired with the Vault Secrets Operator, it integrates seamlessly with Kubernetes. The operator synchronizes secrets from Vault into native Kubernetes Secret objects — so applications keep consuming them as usual, while the underlying management becomes secure, auditable, and dynamic.

In this hands-on guide, we’ll deploy Vault and the Vault Secrets Operator on a local cluster, store a secret in Vault, and then consume it from Kubernetes — starting with a static secret, and then moving to a dynamic one automatically generated by Vault.

You can find all the manifests and configuration files used in this guide on GitHub

Table of Contents

Before you begin


This guide uses a GitOps workflow powered by FluxCD, similar to the setup in my homelab. It allows you to keep a clean, readable, and version-controlled repository, where every cluster component is defined declaratively. Flux continuously applies and reconciles this desired state, eliminating the need for repetitive manual commands.

If you prefer not to use Flux, you can simply run the equivalent commands with Helm and kubectl. The behavior remains exactly the same — only the deployment method changes.

All the manifests used throughout this guide are available in the accompanying repository, which you can explore to follow along or reuse in your own setup.

Deploying Vault on Kubernetes


Installing Vault on Kubernetes is done through the official HashiCorp Helm chart, which is the recommended method to achieve a reliable and maintainable setup.

It’s also a good practice to isolate Vault in its own namespace, ensuring its resources remain clearly separated from the rest of the cluster.

Deploy Vault with Helm

We can now add the official Helm chart to our configuration:

apiVersion: source.toolkit.fluxcd.io/v1
kind: HelmRepository
metadata:
  name: vault
  namespace: vault
spec:
  interval: 24h
  url: https://helm.releases.hashicorp.com

Once the chart is referenced, we can install Vault in high-availability mode using Raft integrated storage, with TLS enabled and the web UI available for administration.

apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
  name: vault
  namespace: vault
spec:
  interval: 30m
  chart:
    spec:
      chart: "vault"
      version: "0.30.0"
      sourceRef:
        kind: HelmRepository
        name: vault
        namespace: vault
      interval: 12h
  install:
    crds: Create
  upgrade:
    crds: CreateReplace
  values:    
    injector:
      enabled: false
    server:
      affinity: ""
      ha:
        enabled: true
        replicas: 5
        raft: 
          enabled: true
          setNodeId: true
          config: |
              cluster_name = "vault-integrated-storage"
              storage "raft" {
                path    = "/vault/data/"
              }
              listener "tcp" {
                address = "[::]:8200"
                cluster_address = "[::]:8201"
                tls_disable = "true"
              }
              
              service_registration "kubernetes" {}

After the manifests are applied, Flux automatically deploys Vault into its dedicated namespace. The pods reach the Running state — however, Vault remains sealed: the instance is up, but still unusable until it’s manually unsealed using the keys generated during initialization.

captionless image

Initialize and Unseal Vault

To initialize Vault, open a shell inside the main container so you can interact directly with the Vault CLI:

kubectl exec -it vault-0 -n vault -- /bin/sh

Once inside the container, start the initialization process:

vault operator init

captionless image

This command generates several unseal keys and a root token. By default, Vault uses a key-sharing mechanism: a specific number of keys (typically 3 out of 5) are required to unseal the cluster. Be sure to store these keys and the root token in a secure location — never in Git or any shared repository.

You can now unseal Vault, starting with the main node:

vault operator unseal

If you run into a connection error while entering the unseal token, set the Vault address explicitly to the service DNS:

export VAULT_ADDR=http://vault.vault.svc.cluster.local:8200

Repeat the command on the other pods until the entire cluster is fully unsealed and operational.

Vault successfully unsealed — the node is active and part of the HA cluster (HA Enabled: true, HA Mode: active)

Vault successfully unsealed — the node is active and part of the HA cluster (HA Enabled: true, HA Mode: active)

Before configuring anything in Vault, you first need to authenticate using the root token that was generated during initialization. This token grants full administrative access, allowing you to enable secret engines, create policies, and set up authentication methods.

vault login

captionless image

Enable Kubernetes Auth and KV v2 Secrets Engine

Once Vault is initialized and unsealed, we can start configuring its core components. We’ll begin by enabling the Kubernetes authentication method and the KV v2 secrets engine.

Enable Kubernetes authentication: This method allows Vault to verify requests coming from Kubernetes ServiceAccounts.

vault auth enable -path kubernetes kubernetes
vault write auth/kubernetes/config \
kubernetes_host="https://$KUBERNETES_PORT_443_TCP_ADDR:443"

Enable the KV v2 secrets engine: This engine is used to store and manage static key-value secrets with built-in versioning.

vault secrets enable -path=kvv2 kv-v2

Installing the Vault Secrets Operator


Setting up the Vault Secrets Operator (VSO) is just as straightforward as deploying Vault itself. You simply add the official HashiCorp Helm chart to your configuration, then deploy the operator with a HelmRelease.

Deploy Vault with Helm

We can now add the official Helm chart to our configuration:

apiVersion: source.toolkit.fluxcd.io/v1
kind: HelmRepository
metadata:
  name: hashicorp
  namespace: vault-secrets-operator
spec:
  interval: 24h
  url: https://helm.releases.hashicorp.com

This HelmRelease installs the operator in its own namespace and defines a default connection to Vault. It specifies the Vault server address and the Kubernetes role that the operator will use to authenticate.

apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
  name: vault-secrets-operator
  namespace: vault-secrets-operator
spec:
  interval: 30m
  chart:
    spec:
      chart: "vault-secrets-operator"
      version: "0.10.0"
      sourceRef:
        kind: HelmRepository
        name: hashicorp
        namespace: vault-secrets-operator
      interval: 12h
  install:
    crds: Create
  upgrade:
    crds: CreateReplace
    force: true
  values:
    defaultVaultConnection:
      enabled: true
      address: "http://vault.vault.svc.cluster.local:8200"
      skipTLSVerify: true
    controller:
      manager:
        clientCache:
          persistenceModel: direct-encrypted
          storageEncryption:
            enabled: true
            mount: kubernetes
            keyName: vso-client-cache
            transitMount: demo-transit
            kubernetes:
              role: auth-role-operator
              serviceAccount: vault-secrets-operator-controller-manager
              tokenAudiences: ["vault"]

The configuration also enables the encrypted client cache — a feature that lets the operator securely store its Vault tokens locally. If the controller pod restarts, it can immediately resume syncing secrets without needing to re-authenticate, ensuring smooth and uninterrupted operation.

Behind the scenes, this caching mechanism relies on Vault’s Transit secrets engine, which handles encryption and decryption of the stored tokens. The key referenced in the HelmRelease (vso-client-cache) will be created in the next step.

Preparing the Transit Engine for the VSO Cache

For this encrypted caching mechanism to work, Vault needs an active Transit engine and a matching encryption key (vso-client-cache) defined in the HelmRelease.

We’ll now enable the Transit engine, create the key, and define a policy that allows the VSO controller to encrypt and decrypt its tokens securely.

Enable the Transit engine

vault secrets enable -path=demo-transit transit

Create the encryption key

vault write -force demo-transit/keys/vso-client-cache

Define a policy that allows encryption and decryption

vault policy write demo-auth-policy-operator - <<EOF
path "demo-transit/encrypt/vso-client-cache" {
  capabilities = ["create", "update"]
}
path "demo-transit/decrypt/vso-client-cache" {
  capabilities = ["create", "update"]
}
EOF

Create the Kubernetes role

vault write auth/kubernetes/role/auth-role-operator \
  bound_service_account_names=vault-secrets-operator-controller-manager \
  bound_service_account_namespaces=vault-secrets-operator \
  token_ttl=0 \
  token_period=120 \
  token_policies=demo-auth-policy-operator \
  audience=vault

This role allows the Vault Secrets Operator to authenticate via Kubernetes and use the Transit engine to encrypt its local cache.

Before the operator can start synchronizing secrets, we still need to create the corresponding connection on the Kubernetes side. This step defines how the operator authenticates with Vault — using its ServiceAccount and the role we just configured.

apiVersion: v1
kind: ServiceAccount
metadata:
  namespace: vault-secrets-operator
  name: vault-secrets-operator-controller-manager

---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultAuth
metadata:
  name: operator-auth
  namespace: vault-secrets-operator
spec:
  method: kubernetes
  mount: kubernetes
  vaultConnectionRef: default
  kubernetes:
    role: auth-role-operator
    serviceAccount: vault-secrets-operator-controller-manager
    audiences:
      - vault
  storageEncryption:
    mount: demo-transit
    keyName: vso-client-cache

This VaultAuth resource links the operator’s ServiceAccount to the Vault role, completing the authentication flow.

Once the role and policy are applied, the operator pods should be Running and ready to start synchronizing secrets between Vault and Kubernetes.

captionless image

Syncing a Secret from Vault to Kubernetes


Now that Vault and the Vault Secrets Operator (VSO) are up and running, let’s connect everything together and synchronize a real secret from Vault into Kubernetes.

Define an Access Policy

Policies in Vault define which paths can be accessed and what actions are allowed (read, list, write, etc.) for a given role or ServiceAccount.

vault policy write webapp - <<EOF
path "kvv2/data/webapp/config" {
  capabilities = ["read", "list"]
}
EOF

Create a Kubernetes Auth Role:

Next, create a Kubernetes authentication role that binds this policy to a specific ServiceAccount in your application namespace (app). This allows Vault to authenticate workloads in that namespace and issue tokens with the webapp policy.

vault write auth/kubernetes/role/role1 \
  bound_service_account_names=demo-app \
  bound_service_account_namespaces=app \
  policies=webapp \
  audience=vault \
  ttl=24h

Add a Test Secret

Let’s create a sample static secret inside Vault’s KV engine to verify the setup.

vault kv put kvv2/webapp/config username="static-user" password="static-password"

At this point, the trust relationship between Vault and Kubernetes is established: Vault can authenticate workloads through their ServiceAccounts. The Vault Secrets Operator can now retrieve and sync these secrets into Kubernetes.

We’ll use the webapp policy and its associated role in the next step to automatically inject this secret into the cluster.

Configure Kubernetes Authentication

The application will use a dedicated ServiceAccount. Create a VaultAuth resource that links this ServiceAccount to the Vault role (role1) created earlier.

apiVersion: v1
kind: ServiceAccount
metadata:
  namespace: vault-secrets-operator
  name: vault-secrets-operator-controller-manager

---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultAuth
metadata:
  name: operator-auth
  namespace: vault-secrets-operator
spec:
  method: kubernetes
  mount: kubernetes
  vaultConnectionRef: default
  kubernetes:
    role: auth-role-operator
    serviceAccount: vault-secrets-operator-controller-manager
    audiences:
      - vault
  storageEncryption:
    mount: demo-transit
    keyName: vso-client-cache

This resource tells the VSO how the application should authenticate with Vault.

Define the Secret to Sync

Next, create a VaultStaticSecret that specifies which secret to fetch from Vault, where to store it in Kubernetes, and which VaultAuth to use.

apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
  name: app-static-secret
  namespace: app
spec:
  type: kv-v2
  mount: kvv2
  path: webapp/config
  destination:
    name: app-kvv2-secret
    create: true
  refreshAfter: 10s
  vaultAuthRef: demo-app-auth

This manifest includes parameters like:

  • path: the secret location in Vault (webapp/config), destination.name: the name of the resulting Kubernetes Secret,
  • vaultAuthRef: the reference to the VaultAuth object,
  • refreshAfter: how often the operator checks for updates.

Verify the Synchronization Once the manifests are applied, the VSO retrieves the secret from Vault and creates the corresponding Kubernetes Secret in the app namespace. You should see your new secret listed.

captionless image

Test Live Updates

To confirm live synchronization, update the secret in Vault:

kubectl exec -it vault-0 -n vault - /bin/sh vault kv put kvv2/webapp/config username="static-user-2" password="updated-password"

After a few seconds (depending on refreshAfter), the secret in Kubernetes automatically updates — no manual intervention required.

captionless image

Dynamic Secrets: Automated Generation and Rotation


Static secrets remain valid until manually changed — the longer they live, the greater the risk they pose. With dynamic secrets, Vault generates unique, short-lived credentials on demand and automatically revokes them when they expire. This completely removes the need for manual password management and significantly strengthens security.

In this section, we’ll illustrate the concept using a PostgreSQL database. While deploying PostgreSQL itself isn’t the focus of this guide, you can check the corresponding files in the repository to see how it’s installed via Helm.

We’ll now enable Vault’s Database secrets engine and connect it to PostgreSQL, allowing Vault to generate dynamic, time-limited credentials automatically.

Enable the Database secrets engine

This engine allows Vault to handle user creation, rotation, and revocation for supported databases

vault secrets enable -path=demo-db database

Configure the PostgreSQL connection

This tells Vault where the database is and which initial credentials it can use to connect.

vault write demo-db/config/demo-db \
  plugin_name=postgresql-database-plugin \
  allowed_roles="dev-postgres" \
  connection_url="postgresql://{{username}}:{{password}}@postgresql.postgresql.svc.cluster.local:5432/postgres?sslmode=disable" \
  username="postgres" \
  password="secret-pass"

Create a role for temporary credentials

The role defines how long credentials remain valid and how they’re revoked when expired.

vault write demo-db/roles/dev-postgres \
  db_name=demo-db \
  creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; GRANT ALL PRIVILEGES ON DATABASE postgres TO \"{{name}}\";" \
  revocation_statements="REVOKE ALL ON DATABASE postgres FROM \"{{name}}\";" \
  default_ttl="1m" \
  max_ttl="1m"

Create an access policy for the application

This policy allows the application to read the generated credentials.

vault policy write demo-auth-policy-db - <<EOF
path "demo-db/creds/dev-postgres" {
  capabilities = ["read"]
}
EOF

Configure Kubernetes Access for Dynamic Secrets

To allow the application to retrieve the temporary PostgreSQL credentials generated by Vault, we need to create a Kubernetes authentication role that links its ServiceAccount to the appropriate Vault policies.

vault write auth/kubernetes/role/auth-role \
  bound_service_account_names=demo-app \
  bound_service_account_namespaces=app \
  token_ttl=0 \
  token_period=120 \
  token_policies="webapp,demo-auth-policy-db"\
  audience=vault

This role tells Vault that the ServiceAccount demo-app in the app namespace is authorized to request temporary PostgreSQL credentials.

Notice that the token_policies field includes two policies:

  • webapp: The policy we created earlier for the static secret
  • demo-auth-policy-db: The policy allowing access to dynamic database credentials.

By combining both, we can reuse the same VaultAuth object for both static and dynamic secrets — simplifying configuration and avoiding duplication. The last step is to declare a VaultDynamicSecret that will automatically sync the generated PostgreSQL credentials into Kubernetes.

apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultDynamicSecret
metadata:
  name: app-dynamic-secret
  namespace: app
spec:
  vaultAuthRef: demo-app-auth
  mount: demo-db
  path: creds/dev-postgres
  destination:
    create: true
    name: app-db-creds

Once the manifest is applied, the Vault Secrets Operator automatically retrieves the generated credentials, stores them in the app-db-creds Secret, and regenerates them on every rotation cycle.

Verify Automatic Rotation

To observe the rotation, open k9s in the app namespace and decode the app-db-creds secret to view its values:

captionless image

Wait until the TTL defined in Vault expires (about one minute in this example), then refresh the view. You’ll see that new credentials have been automatically generated and synchronized.

captionless image

Conclusion


With Vault and the Vault Secrets Operator, secret management in Kubernetes becomes both secure and effortless. Credentials are no longer stored in plain text, rotation happens automatically, and every access is governed by fine-grained policies. This integration strengthens security without adding operational overhead, fitting naturally into a GitOps and declarative workflow.

rayane.kadi10@gmail.com