#DevOps

Kubernetes ConfigMaps and Secrets: Configuration and Secret Management

Configuration should not be hard-coded into images: ConfigMaps store nonsensitive settings, while Secrets store credentials. This article covers creation and mounting, env vs. volume, base64 and encryption with etcd, KMS, and external operators, ConfigMap hot updates and the subPath trap, and production secret management with External Secrets Operator.

Key Points

  • Topic Focus: DevOps
  • Suitable for: indie hackers, full-stack engineers, AI application developers
  • Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Author @admin·Published July 26, 2026·Updated July 26, 2026

Kubernetes ConfigMaps and Secrets: Configuration and Secret Management

Let me start with an incident I experienced firsthand. One team's application could not connect to its database. The investigation found that the DB password had changed, but the application was still using the old one because ENV DB_PASSWORD=xxx had been hard-coded into the Dockerfile. Rebuilding the image required the entire CI/CD pipeline, and deployment took half an hour. The entire service returned 502 errors in the meantime. During the postmortem, someone from another team raised a hand: "We didn't put our password in the image. We put it in a K8s Secret YAML file, but committed that YAML to Git with the value stored in plaintext."

Two approaches, two failures. The first couples configuration to the image: changing a setting requires rebuilding the image and rerunning the pipeline. The second couples secrets to code: if the repository leaks, every secret in every environment leaks with it.

K8s provides two APIs as the remedy: ConfigMap for nonsensitive configuration such as log levels, feature flags, and nginx.conf, and Secret for sensitive data such as DB passwords, API keys, and TLS certificates. However, both APIs have plenty of pitfalls: environment variables injected from a ConfigMap do not update dynamically, subPath mounts never update, base64 in a Secret is not encryption, and values are stored unencrypted in etcd by default. This article unpacks the entire chain, from why configuration should be externalized to a production-grade External Secrets Operator and Vault setup you can apply directly.

1. First Principle: Neither Configuration nor Secrets Belong in an Image

Start with the right mental model. An image should be an environment-independent artifact. The same app:1.2.3 image should run unchanged in dev, staging, and prod; only the configuration and secrets injected at startup should differ.

This principle has several layers, each grounded in painful incidents:

  • Immutability vs. mutability. Images pass through registries, are cached, signed, and distributed. They are naturally suited to "build once, deploy many times." Embedding environment-specific values such as a DB host, credentials, or feature flags makes mutable data depend on an immutable carrier. Every configuration change then requires rebuilding the image, pushing it to the registry, and restarting every Pod. Externalize the configuration, and a one-line ConfigMap change takes effect in seconds, except for environment-variable injection as discussed later. This is a prerequisite for cloud-native "configuration as code."
  • Security. Developers run docker pull on images locally, images are pushed to third-party registries, and images are included in disaster-recovery backups. A leak at any of those points also exposes every secret hard-coded into the image. A secret in an image is a ticking time bomb.
  • Auditing and rotation. Secrets have lifetimes and must be rotated regularly. If a secret is in an image, one rotation means rebuilding the image and performing a full rolling restart. If it is in a Secret, update one Secret object and perform a rolling restart. If it comes from an external Vault as a dynamic secret, you do not even need to modify the Secret.

K8s provides ConfigMap and Secret to implement this principle. Their API shapes are almost identical: both are key-value maps. There are only two differences: Secret values are base64-encoded for sensitive data, and Secrets have type constraints, such as Opaque, kubernetes.io/tls, and kubernetes.io/dockerconfigjson. We will examine each in turn.

2. ConfigMap: A Carrier for Nonsensitive Configuration

A ConfigMap is simply a set of key-value pairs that supplies configuration to Pods. One point must be explicit: it is not encrypted, and anyone who can run kubectl get cm can see its plaintext contents. Store only nonsensitive data in it. Putting a DB password in a ConfigMap is one of the most common beginner mistakes.

Three ways to create a ConfigMap

The most direct approach is imperative creation, which is useful for quick validation:

bash
# Create from literal values
kubectl create configmap app-config \
  --from-literal=LOG_LEVEL=info \
  --from-literal=FEATURE_NEW_ONBOARDING=true \
  --from-literal=MAX_CONNECTIONS=100

# Create from a file (key = filename, value = file contents)
kubectl create configmap nginx-config --from-file=nginx.conf

# Create from a directory (every file in the directory becomes a key)
kubectl create configmap app-config --from-file=./config/

For production, declarative YAML committed to Git through GitOps is preferable. Note that a ConfigMap uses plaintext data whose values must be base64-encoded, or stringData, whose values are plaintext strings that the API encodes automatically:

yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
  namespace: prod
data:
  LOG_LEVEL: "info"
  FEATURE_NEW_ONBOARDING: "true"
  MAX_CONNECTIONS: "100"
  # You can also include an entire configuration file
  application.yml: |
    server:
      port: 8080
    spring:
      datasource:
        hikari:
          maximum-pool-size: 20

My recommendation is to use data for literal values and also use data with multiline strings for complete configuration files. Use stringData sparingly. Although it looks conveniently like plaintext in YAML, it can mislead people into thinking it is an "unencrypted Secret," blurring the boundary between ConfigMaps and Secrets.

Mounting method 1: Environment-variable injection (env / envFrom)

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: app
spec:
  template:
    spec:
      containers:
        - name: app
          image: app:1.2.3
          # Inject one key into one environment variable
          env:
            - name: LOG_LEVEL
              valueFrom:
                configMapKeyRef:
                  name: app-config
                  key: LOG_LEVEL
          # Inject the entire ConfigMap as multiple environment variables
          envFrom:
            - configMapRef:
                name: app-config

A single envFrom entry turns every key in the ConfigMap into an environment variable, which is convenient. There is one trap: key names must follow environment-variable naming rules—letters, digits, and underscores, and they cannot begin with a digit. Otherwise, K8s silently skips those keys. You can see an error in the events, but the Pod starts normally, and you may spend hours before discovering that a key such as feature-flag.new was never injected.

Mounting method 2: Volume mount, with one file per key

yaml
apiVersion: apps/v1
kind: Deployment
spec:
  template:
    spec:
      containers:
        - name: app
          image: app:1.2.3
          volumeMounts:
            - name: config-vol
              mountPath: /etc/app
              readOnly: true
      volumes:
        - name: config-vol
          configMap:
            name: app-config

Once mounted this way, every key in the ConfigMap becomes a file under /etc/app/. The contents of /etc/app/LOG_LEVEL are info, while /etc/app/application.yml contains the YAML shown above. The application reads its configuration through file APIs.

Volume mounts have several clear advantages over environment-variable injection:

  • Complete configuration files such as nginx.conf and application.yml require a volume; multiline content does not fit well in environment variables
  • They support hot updates, as discussed in the next section
  • They decouple configuration from code more completely; many frameworks, including Spring Boot and Next.js, natively support reading configuration from files

3. ConfigMap Hot Updates: Environment Variables Do Not Update, Volumes Do, and subPath Does Not

This is the most common ConfigMap failure mode. You change a ConfigMap, but the application still receives the old value. Why? The answer lies in the K8s update mechanism.

Environment-variable injection: never updates. The kubelet writes environment variables into the process environment when the container starts. Once the process is running, they do not change. After you modify the ConfigMap, running Pods still have the old values and must be restarted before the change takes effect. This is the fundamental limitation of environment-variable injection.

Volume mounts: update automatically, with a delay. The kubelet periodically synchronizes the latest ConfigMap contents into the volume mounted by the Pod. The default interval is one minute and is controlled by the kubelet's --sync-frequency. The file contents change, but your application must actively watch for file changes or reread the files periodically to notice them. Otherwise, the files change while the application continues using values cached in memory.

text
ConfigMap change timeline:
  T+0s    : kubectl apply the new ConfigMap
  T+0~60s : kubelet writes the new contents into the volume (the ..data symlink points to the new version directory)
  T+?     : the application rereads the file and sees the new value (depends on application code)

The intermediate T+? is not deterministic. If your application reads configuration into memory only at startup, it continues using the startup value even after the volume file changes. The complete hot-update chain is ConfigMap update → kubelet synchronizes the volume → application detects the change → application reloads. The application must implement the final step. Many systems such as Nginx require an inotify watcher plus a reload signal. Alternatively, use a controller such as Reloader to watch ConfigMap and Secret changes and automatically trigger a rolling restart of the Deployment.

subPath mounts: never update—the biggest trap

If you want to mount only one file from a ConfigMap at a specific path, the following configuration feels natural:

yaml
volumeMounts:
  - name: config-vol
    mountPath: /etc/nginx/nginx.conf    # Mount a single file here
    subPath: nginx.conf                 # Use only the nginx.conf key from the ConfigMap

This looks perfectly reasonable, but a file mounted with subPath never receives hot updates. A subPath mount is effectively a bind mount of one file at the target path, bypassing the ConfigMap volume's symlink-based update mechanism. The kubelet synchronizes the entire volume directory, but the fixed inode referenced by subPath does not change with it.

This trap has affected countless teams. You change nginx.conf, apply it, and see the new ConfigMap in kubectl describe, yet kubectl exec still shows stale or completely unchanged file contents. You assume caching, a stuck kubelet, or an API server problem and lose half a day investigating. Memorize the conclusion:

The three tiers of ConfigMap hot updates: volume mounts update with a one-minute delay > environment-variable injection does not update and requires a Pod restart = subPath mounts do not update and require a Pod restart.

If you need a single-file mount with hot updates, mount the entire directory and have the application read the relevant file, use a symlink technique, or configure Reloader to trigger rolling restarts. Do not keep fighting subPath.

4. Secret: A Carrier for Sensitive Data

The Secret API looks almost identical to the ConfigMap API, but three key differences must be understood first.

First, base64 is encoding, not encryption. This is the second most common misconception among K8s beginners; the first is that "a namespace is a network boundary." Values in a Secret's data field are base64-encoded, but base64 is encoding, not encryption. A single command, echo 'cGFzc3dvcmQxMjM=' | base64 -d, restores the original value. Anyone who can retrieve the contents with kubectl get secret xxx -o yaml can decode them immediately. Base64 exists only to allow binary data such as TLS certificates and private keys to be represented safely in JSON/YAML, not to provide confidentiality.

yaml
apiVersion: v1
kind: Secret
metadata:
  name: app-secrets
type: Opaque
data:
  # The value is base64-encoded (echo -n 'password123' | base64 => cGFzc3dvcmQxMjM=)
  DB_PASSWORD: cGFzc3dvcmQxMjM=
stringData:
  # You can also put plaintext in stringData; the API server encodes it before storing it in data
  # This is easier for people to write, but get -o yaml still displays base64
  API_KEY: sk_live_abc123

Practical advice: use stringData when writing YAML because plaintext is easier to review, but never commit YAML containing stringData to Git. Once such a file enters the repository, the plaintext secret is in the repository. In production, Secrets should be generated dynamically by the External Secrets Operator described in the next section and should never enter Git at all.

Second, the type field. Secrets have several predefined types that affect validation and the behavior of certain controllers:

typePurpose
OpaqueArbitrary key-value data and the default type; use it for DB passwords and API keys
kubernetes.io/tlsTLS certificates; must contain the tls.crt and tls.key keys, and is used by Ingress for HTTPS
kubernetes.io/dockerconfigjsonPrivate-registry credentials as auths JSON, used with imagePullSecrets
kubernetes.io/basic-authA username/password pair (username / password)
kubernetes.io/service-account-tokenAn SA token managed automatically by K8s; do not create it manually

Opaque is sufficient for most application scenarios. Use dockerconfigjson to pull private images and tls for HTTPS entry points.

Third, mounting works the same way as with ConfigMaps, through environment-variable injection or volume mounts, with the same traps: environment variables do not update dynamically, and neither do subPath mounts. Secrets have one additional detail: when mounted as a volume, each file has mode 0644 by default, so every container in the same Pod can read it. For security-sensitive workloads, set defaultMode:

yaml
volumes:
  - name: secret-vol
    secret:
      secretName: app-secrets
      defaultMode: 0400    # Only the owner (root) can read it; group and others have no permissions
      items:
        - key: DB_PASSWORD
          path: db_password   # Mounted as /etc/secrets/db_password

In addition, Secret volumes use tmpfs, an in-memory filesystem. Secrets are never written to the node's disk, and the tmpfs disappears when the Pod stops. This is an implicit security advantage of Secrets over ConfigMaps.

5. Secret Security: Unprotected by Default

In a default K8s installation, Secrets are stored in etcd without encryption, apart from base64 encoding. Any user who can run kubectl get secret, and anyone who can access etcd, can read every secret directly. That is the default state, and it is inadequate for production. At minimum, take the following three steps to harden it for production.

Hardening 1: Enable Encryption at Rest for etcd

Since K8s 1.13, the API server can be configured to encrypt Secret data before writing it to etcd. Create /etc/kubernetes/encryption-config.yaml:

yaml
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
  - resources:
      - secrets              # Encrypt Secret resources
      - configmaps           # Optional: ConfigMaps can also be encrypted
    providers:
      - secretbox:           # XChaCha20-Poly1305, a fast and recommended software implementation
          keys:
            - name: key1
              secret: <32-byte base64 random value>
      - aesgcm:              # Alternative: AES-GCM, which performs well but requires regular key rotation
          keys:
            - name: key1
              secret: <base64>
      - identity: {}         # Must come last as an unencrypted fallback for reading legacy plaintext data

Then add --encryption-provider-config to the kube-apiserver startup arguments and restart the API server. Important: existing data remains in plaintext in etcd after this configuration change and must be rewritten before encryption takes effect:

bash
# Rewrite every Secret so it is encrypted on disk under the new configuration
kubectl get secrets --all-namespaces -o json \
  | kubectl replace -f -

To verify it, read etcdctl get /registry/secrets/prod/app-secrets directly. The result should be ciphertext rather than plaintext.

Managed K8s services such as GKE, EKS, and AKS generally provide a one-click option for etcd encryption and use the cloud provider's KMS as the key provider, sparing you from managing keys yourself. Make sure this option is enabled in production.

Hardening 2: Restrict read access with RBAC

By default, every ServiceAccount in a namespace can read Secrets in that namespace through a role bound by default. This is too broad. In production, you should:

  • Create a dedicated ServiceAccount for each application
  • Use a least-privilege Role that permits access only to the Secrets the application actually needs
  • Remove or restrict the default binding for the system:service-accounts group
yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: app-secret-reader
  namespace: prod
rules:
  - apiGroups: [""]
    resources: ["secrets"]
    resourceNames: ["app-db-secret"]   # Permit access to this Secret only
    verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: app-secret-reader
  namespace: prod
subjects:
  - kind: ServiceAccount
    name: app            # Bind only to the app ServiceAccount
roleRef:
  kind: Role
  name: app-secret-reader
  apiGroup: rbac.authorization.k8s.io

Using resourceNames to name the exact Secret is critical. Do not take the shortcut of specifying resources: ["secrets"] without limiting the names; that effectively grants access to every secret in the namespace.

Hardening 3: Never Commit Secret YAML to Git

This has already been emphasized. Even with etcd encryption enabled, the stringData or data in a YAML file is still plaintext or base64 that can be decoded instantly. Once it enters Git, history never forgets it. Commit the External Secrets Operator's ExternalSecret resource instead, as described in the next section. It declares only the path to the secret in an external Vault and contains no secret value.

One more measure is auditing. Enable audit logs, record every get/list/watch operation on Secrets, and regularly review who is retrieving them. This lets you identify the source quickly if a secret leaks.

6. External Secrets Operator: The Right Approach to Production Secret Management

At this point, you may ask: if Secrets cannot enter Git, where do the Secret objects come from? The answer is to generate them dynamically with a controller. External Secrets Operator (ESO) is the current de facto standard: it is open source and works across clouds and backends.

Its operating model is straightforward. Store the real secrets in an external secret-management service, such as HashiCorp Vault, AWS Secrets Manager, GCP Secret Manager, Azure Key Vault, 1Password, or Doppler. ESO watches ExternalSecret resources in K8s. Based on each resource's declaration of where to retrieve specific keys, it fetches them from the external service and automatically synchronizes them into a native K8s Secret object.

text
┌──────────────────┐   pull    ┌───────────────────────────┐
│  Vault / AWS SM  │ ◄──────── │ External Secrets          │
│  (single source  │           │ Operator (ESO)            │
│   of truth)      │           │  - watches ExternalSecret │
│                  │ ────────► │  - reconciles periodically│
└──────────────────┘  write    └─────────────┬─────────────┘
                                             │ create/update
                                             ▼
                                ┌───────────────────────────┐
                                │ Native K8s Secret         │
                                │ app-db-secret             │
                                └─────────────┬─────────────┘
                                              │ mount / env
                                              ▼
                                          ┌────────┐
                                          │  Pod   │
                                          └────────┘

This architecture solves all the preceding problems:

  • Secrets never enter Git: the repository contains only an ExternalSecret, which declares a reference without a value, rather than the Secret itself
  • Single source of truth: secrets are centrally managed, rotated, and audited in Vault or Secrets Manager; K8s is only a consumer
  • Automatic synchronization: when an external secret changes, ESO automatically updates the K8s Secret during its next reconciliation, with no manual work
  • Consistency across environments: dev, staging, and prod use the same ExternalSecret template while referencing different paths

Install ESO with Helm:

bash
helm repo add external-secrets https://charts.external-secrets.io
helm install external-secrets external-secrets/external-secrets \
  -n external-secrets --create-namespace

Step 1: Configure a SecretStore to connect to the external secret service

A SecretStore defines where to retrieve secrets. For example, with AWS Secrets Manager:

yaml
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
  name: aws-secrets-manager
  namespace: prod
spec:
  provider:
    aws:
      service: SecretsManager
      region: us-east-1
      auth:
        jwt:
          serviceAccountRef:
            name: external-secrets-sa   # Use IRSA (IAM Roles for Service Accounts)

This configuration authenticates with IRSA, an IAM role associated with a ServiceAccount, which is much safer than putting an AWS access key in a Secret. GCP uses Workload Identity, while Vault uses the K8s auth backend. All of these approaches exchange a Pod identity for external credentials, eliminating long-lived credentials.

Here is a SecretStore for Vault:

yaml
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
  name: vault-backend
  namespace: prod
spec:
  provider:
    vault:
      server: "https://vault.internal:8200"
      path: "kv"                # KV v2 mount path
      version: "v2"
      auth:
        kubernetes:
          mountPath: "kubernetes"
          role: "app-role"      # Role configured in Vault and bound to a K8s ServiceAccount
          serviceAccountRef:
            name: app

Step 2: Declare an ExternalSecret to specify the keys and their mappings

yaml
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: app-db-external-secret
  namespace: prod
spec:
  refreshInterval: 1h           # Synchronize every hour; external changes take effect within one hour
  secretStoreRef:
    name: vault-backend
    kind: SecretStore
  target:
    name: app-db-secret         # Name of the generated native K8s Secret
    creationPolicy: Owner       # ESO owns the Secret and deletes it with the ExternalSecret
  data:
    - secretKey: DB_PASSWORD    # Key in the K8s Secret
      remoteRef:
        key: kv/prod/app        # Vault path
        property: db_password   # Field in the JSON stored at that path
    - secretKey: DB_USER
      remoteRef:
        key: kv/prod/app
        property: db_user

After you apply this ExternalSecret, ESO reads {db_password: xxx, db_user: yyy} from the kv/prod/app path in Vault and generates a native K8s Secret named app-db-secret. It then reconciles automatically every hour. If the Vault values change, the K8s values are synchronized within an hour.

The application Pod consumes it exactly like an ordinary Secret, with no special handling:

yaml
spec:
  template:
    spec:
      containers:
        - name: app
          image: app:1.2.3
          env:
            - name: DB_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: app-db-secret
                  key: DB_PASSWORD
            - name: DB_USER
              valueFrom:
                secretKeyRef:
                  name: app-db-secret
                  key: DB_USER

The complete chain is now closed: manage the secret in Vault → ESO synchronizes it into a K8s Secret → the Pod reads it through an environment variable or volume → neither the Git repository nor the image contains a plaintext secret.

Choosing a refreshInterval requires a trade-off. Shorter intervals provide faster updates but generate more external API calls; AWS Secrets Manager charges per call. Longer intervals cost less but delay secret rotations. Common values range from 1h to 24h. For dynamic Vault secrets that are ephemeral, the interval can be reduced to minutes.

7. Practical Example: A Complete "Zero-Plaintext DB Password" Pipeline

Now combine everything above into an end-to-end production example. Requirement: the application in the prod namespace must connect to PostgreSQL, and the password must never appear in plaintext in the Git repository, image, or etcd. The complete solution is Vault for storage, ESO for synchronization, etcd encryption, restrictive RBAC, and read-only Secret access for the application.

Step 1: Write the DB password to Vault

bash
# On the Vault server, or through the vault CLI
vault kv put kv/prod/app \
  db_user=app_user \
  db_password=$(openssl rand -base64 24)

Generate a strong password with openssl rand and pass it through a command-line argument instead of writing it into a script file, preventing the password from entering shell history. Configure a Vault policy that allows only the prod/app K8s ServiceAccount to read this path:

hcl
path "kv/data/prod/app" {
  capabilities = ["read"]
}

Step 2: Configure SecretStore and ExternalSecret using the YAML from Section 6, then apply them to the cluster

bash
kubectl apply -f secretstore.yaml -f external-secret.yaml
# Wait for ESO to finish synchronizing
kubectl get externalsecret app-db-external-secret -n prod -w
# Synchronization succeeds when the READY column becomes True
kubectl get secret app-db-secret -n prod        # The generated native Secret appears

Step 3: Confirm that etcd encryption is enabled as described in Section 5

bash
# Check whether kube-apiserver has encryption-provider-config configured
ps aux | grep encryption-provider-config
# Test it by reading etcd directly
ETCDCTL_API=3 etcdctl get /registry/secrets/prod/app-db-secret
# The output should be ciphertext with a k8s:enc:secretbox: prefix, not plaintext

Step 4: Use RBAC to ensure only the app ServiceAccount can read it

Apply the Role and RoleBinding from Section 5 that are restricted to app-db-secret. Verify the restriction:

bash
# Start a Pod with another ServiceAccount and attempt to read the Secret; access should be denied
kubectl auth can-i get secret app-db-secret \
  --as=system:serviceaccount:prod:other-app
# no

Step 5: Deploy the application

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: app
  namespace: prod
spec:
  replicas: 3
  selector:
    matchLabels: { app: app }
  template:
    metadata:
      labels: { app: app }
    spec:
      serviceAccountName: app           # Use a dedicated SA, not the default one
      containers:
        - name: app
          image: registry.example.com/app:1.2.3   # The image contains no secrets
          env:
            - name: DB_HOST
              valueFrom:
                configMapKeyRef:        # Nonsensitive configuration comes from a ConfigMap
                  name: app-config
                  key: DB_HOST
            - name: DB_PASSWORD
              valueFrom:
                secretKeyRef:           # Sensitive configuration comes from the Secret generated by ESO
                  name: app-db-secret
                  key: DB_PASSWORD
            - name: DB_USER
              valueFrom:
                secretKeyRef:
                  name: app-db-secret
                  key: DB_USER

Step 6: Practice rotation

Simulate a secret rotation by changing the password in Vault:

bash
vault kv patch kv/prod/app db_password=$(openssl rand -base64 24)

When the ESO refreshInterval elapses, or immediately after manually running kubectl annotate externalsecret app-db-external-secret -n prod force-sync=$(date +%s) --overwrite, the Secret is updated. The application must then either reload it itself through a volume mount and file watcher, or use Reloader to trigger a rolling restart of the Deployment, which is required when using environment-variable injection:

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: app
  annotations:
    reloader.stakater.com/search: "true"   # Watch related Secret/ConfigMap changes and restart automatically

Verify the entire chain:

CheckResult
Search the Git repository for plaintext db_passwordNone; it contains only the ExternalSecret reference
Inspect environment variables in the Docker image with docker historyNo secret; only ENV DB_HOST=...
Read /registry/secrets/prod/app-db-secret directly with etcdctlCiphertext, confirming that etcd encryption works
Test another ServiceAccount with kubectl auth can-idenied, confirming that RBAC restrictions work
Change the password in Vault and check whether the K8s Secret updates within 1hYes, confirming that ESO synchronization works

I consider this setup the minimum acceptable standard for production. Its essence is to fully decouple configuration changes from artifact builds and fully decouple secrets from code. An image can go a year without being rebuilt and still track configuration changes, while the repository can be forked ten thousand times without exposing a single secret.

Summary

  • The first principle is that neither configuration nor secrets belong in an image. Images should be environment-independent. External configuration completely decouples changing a setting from rebuilding an image. ConfigMaps store nonsensitive configuration, while Secrets store sensitive data. However, base64 is not encryption: anyone who can run get -o yaml can decode it.
  • ConfigMap hot updates have three tiers: volume mounts update after a delay of about one minute and require the application to detect the change; environment-variable injection does not update and requires a Pod restart; subPath mounts do not update, which is the biggest trap when mounting individual files.
  • Secrets are unprotected by default. Production environments must at least enable etcd encryption with EncryptionConfiguration and a rewrite, use RBAC restricted to the exact resourceName, and never commit YAML containing secret values to Git.
  • The production-grade solution is External Secrets Operator. Use Vault, AWS Secrets Manager, or GCP Secret Manager as the single source of truth. ESO watches ExternalSecret resources and synchronizes them into native K8s Secrets. Combine it with IRSA, Workload Identity, or the K8s auth backend for authentication without long-lived credentials. Git contains references, never values.
  • The complete chain is Vault storage → ESO synchronization → etcd encryption → restrictive RBAC → Pod reads the Secret through its ServiceAccount. Omitting any link defeats the entire design. Practice rotation regularly; waiting for an incident before rotating is not an option.

Closing thought: ConfigMaps and Secrets solve the problem of "where did the configuration go?", not "is the configuration secure?" The APIs handle the former for you; you must build the latter layer by layer with etcd encryption, RBAC, and an external secret service. Treating a K8s Secret as a safe is a mistake: it is only a locked display case, and the key still resides in etcd. The real safe is always Vault.

Related

Related Content