ConfigMaps, Secrets & Configuration Management

Twelve-factor apps externalize config—but Kubernetes still needs a safe way to inject it at runtime. ConfigMaps hold non-sensitive key-value and file data; Secrets hold credentials (base64 is encoding, not encryption). Production clusters layer encryption at rest, Sealed Secrets or External Secrets for GitOps, and Vault for centralized secret lifecycle. ResourceQuotas and LimitRanges cap namespace consumption so one team cannot starve the cluster.

developer devops architect K8s 1.29+ OpenShift 4.x
CLI

ConfigMaps

ConfigMaps decouple configuration from container images. Store plain-text properties, JSON, YAML fragments, or entire config files as keys. They are not for passwords, API keys, or TLS private keys—use Secrets or an external secret backend for those.

Consumption patterns

Pattern Mechanism When to use
env valueFrom.configMapKeyRef Single key as one environment variable
envFrom configMapRef on container Import all keys as env vars (key names become var names)
volume mount volumes.configMap + volumeMounts Config files the app reads from disk (application.properties, nginx.conf)
args $(KEY) substitution in command / args Pass flags built from config values at pod start

Size and immutability

A ConfigMap cannot exceed 1 MiB total (sum of keys and values). Large configs belong in object storage, a git-sync sidecar, or a dedicated config server—not a giant ConfigMap. Set immutable: true when the ConfigMap will never change; the API server rejects updates, kubelet skips periodic relist for mounted volumes, and you avoid accidental hot-reload races.

Update behavior

When a ConfigMap changes, behavior depends on how it is consumed:

  • env / envFrom — Values are injected at pod creation only. Changing the ConfigMap does not update running pods; you must roll out a new ReplicaSet (change an annotation, bump image tag, or kubectl rollout restart).
  • volume mount — Kubelet syncs mounted ConfigMap files on a periodic interval (default ~60s, tunable via --sync-frequency). Files update in place via atomic symlink swap; apps must watch for changes or use Reloader. subPath mounts do not auto-update—avoid subPath for hot-reload configs.
  • projected volumes — Same sync semantics as configMap volumes when ConfigMap is a source.
yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
  namespace: payments
  labels:
    app: payment-api
data:
  LOG_LEVEL: info
  FEATURE_FLAGS: |
    enable_3ds=true
    max_retries=5
  application.yaml: |
    server:
      port: 8080
    spring:
      profiles:
        active: prod
immutable: true
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: payment-api
spec:
  replicas: 3
  selector:
    matchLabels:
      app: payment-api
  template:
    metadata:
      labels:
        app: payment-api
    spec:
      containers:
        - name: api
          image: payment-api:2.4.1
          envFrom:
            - configMapRef:
                name: app-config
          env:
            - name: POD_NAMESPACE
              valueFrom:
                fieldRef:
                  fieldPath: metadata.namespace
          volumeMounts:
            - name: config-files
              mountPath: /config
              readOnly: true
          args:
            - --log-level=$(LOG_LEVEL)
      volumes:
        - name: config-files
          configMap:
            name: app-config
            items:
              - key: application.yaml
                path: application.yaml
ConfigMap operations
$ kubectl create configmap app-config --from-literal=LOG_LEVEL=debug -n payments
$ kubectl create configmap nginx-conf --from-file=nginx.conf -n payments
$ kubectl get configmap app-config -o yaml
$ kubectl describe configmap app-config -n payments
$ kubectl rollout restart deployment/payment-api -n payments$ oc create configmap app-config --from-literal=LOG_LEVEL=debug -n payments
$ oc create configmap nginx-conf --from-file=nginx.conf -n payments
$ oc get configmap app-config -o yaml
$ oc describe configmap app-config -n payments
$ oc rollout restart deployment/payment-api -n payments
⚠️ Pitfall

subPath ConfigMap mounts: Mounting a single key with subPath freezes the file at pod start—kubelet will not propagate updates. Use a directory mount or env vars if you need hot reload; use Reloader or a rollout restart if you use env injection.

💡 Pro Tip

Pair Stakater Reloader or Argo CD sync waves with annotated Deployments (reloader.stakater.com/auto: "true") so ConfigMap/Secret changes trigger controlled rollouts instead of relying on kubelet file sync for apps that only read config at startup.

⚙️ Config

Kubelet sync interval is controlled by --sync-frequency (default 1m). Immutable ConfigMaps skip relist overhead—worth enabling for large fleets. For binary data, use binaryData (base64-encoded) instead of data.

Secrets

Secrets store sensitive strings and files. They use the same consumption patterns as ConfigMaps (env, envFrom, volumes) but are treated specially by the API: values are base64-encoded in the manifest and optionally encrypted in etcd at rest.

Built-in types

type Keys Purpose
Opaque User-defined Generic credentials, API tokens, connection strings (default type)
kubernetes.io/tls tls.crt, tls.key TLS certificates for Ingress, webhooks, mTLS
kubernetes.io/dockerconfigjson .dockerconfigjson Image pull credentials (imagePullSecrets)
kubernetes.io/service-account-token token, ca.crt, namespace Legacy SA token Secret (prefer projected SA tokens with expiry)
kubernetes.io/basic-auth username, password HTTP basic auth credentials

Base64 is not encryption

data values in a Secret manifest are base64-encoded for transport—anyone with kubectl get secret -o yaml or RBAC read access sees plaintext after decoding. RBAC, audit logging, and admission policy (OPA/Gatekeeper) are your first lines of defense.

Encryption at rest

Enable EncryptionConfiguration on the API server so etcd stores Secret (and optionally ConfigMap) values encrypted with AES-GCM or KMS providers (AWS KMS, GCP KMS, Azure Key Vault, HashiCorp Vault transit). This protects etcd backups and disk theft—it does not decrypt values inside running pods or for users with Secret read RBAC.

yaml
apiVersion: v1
kind: Secret
metadata:
  name: db-credentials
  namespace: payments
type: Opaque
stringData:
  username: app_user
  password: "change-me-in-vault-not-git"
  DATABASE_URL: postgres://app_user:change-me@db.internal:5432/payments
immutable: true
---
apiVersion: v1
kind: Secret
metadata:
  name: api-tls
  namespace: payments
type: kubernetes.io/tls
data:
  tls.crt: LS0tLS1CRUdJTi...  # base64 PEM
  tls.key: LS0tLS1CRUdJTi...  # base64 PEM
---
apiVersion: v1
kind: Secret
metadata:
  name: regcred
type: kubernetes.io/dockerconfigjson
data:
  .dockerconfigjson: eyJhdXRocyI6eyJyZWdpc3RyeS5leGFtcGxlLmNvbSI6e319fQ==

Rotation and lifecycle

  • Immutable Secrets — Same as ConfigMaps: no in-place updates; create db-credentials-v2, update references, roll out, delete old.
  • Dual-key period — During rotation, mount both old and new keys; app accepts either until cutover completes.
  • Automated rotation — Operators (External Secrets, Vault Agent) refresh Secrets on a schedule; pair with Reloader for rollouts.
  • Service account tokens — Bound tokens via projected volumes rotate automatically; avoid long-lived legacy token Secrets.

Never commit Secrets to git

Plain Secrets in a Git repo are a credential leak—even private repos get forked, indexed, and breached. Use External Secrets Operator (ESO) to sync from AWS Secrets Manager, GCP Secret Manager, Azure Key Vault, HashiCorp Vault, or 1Password into native Kubernetes Secrets at reconcile time. Git holds ExternalSecret CRs referencing remote keys, not the secret values themselves.

Secret operations
$ kubectl create secret generic db-credentials --from-literal=password='s3cr3t' -n payments
$ kubectl create secret tls api-tls --cert=tls.crt --key=tls.key -n payments
$ kubectl create secret docker-registry regcred --docker-server=registry.example.com --docker-username=user --docker-password=pass
$ kubectl get secret db-credentials -o jsonpath='{.data.password}' | base64 -d
$ kubectl get externalsecrets -A$ oc create secret generic db-credentials --from-literal=password='s3cr3t' -n payments
$ oc create secret tls api-tls --cert=tls.crt --key=tls.key -n payments
$ oc create secret docker-registry regcred --docker-server=registry.example.com --docker-username=user --docker-password=pass
$ oc get secret db-credentials -o jsonpath='{.data.password}' | base64 -d
$ oc get externalsecrets -A
🔒 Security

etcd encryption at rest is necessary but insufficient. Restrict Secret get/list/watch with tight RBAC, enable audit logging for Secret access, use encryption.kubernetes.io/v1 providers, and prefer ESO/Vault so values never appear in git or CI logs. Scan repos with gitleaks/trufflehog in CI.

🔬 Under the Hood

When a pod references a Secret volume, kubelet fetches the Secret from the API server (cached in its local store), writes files to /var/lib/kubelet/pods/<uid>/volumes/kubernetes.io~secret/ with 0400 permissions, and tmpfs-mounts them into the container. Secret data in etcd is only as safe as your encryption provider and backup policies.

📦 Real World

Teams migrating to ESO keep the same Deployment manifests—only the Secret source changes. A bank's payments namespace might have ExternalSecret CRs pointing to AWS Secrets Manager paths like prod/payments/db; Argo CD syncs the CR, ESO materializes the Secret, Reloader restarts pods on hash change.

Sealed Secrets (Bitnami)

Sealed Secrets lets you commit encrypted Secret payloads to git. Only the cluster's Sealed Secrets controller (holding the private key) can decrypt them into native Secrets— GitOps-friendly without storing plaintext in the repo.

How it works

  1. Install the Sealed Secrets controller in the cluster; it generates an RSA key pair.
  2. Fetch the cluster public key: kubeseal --fetch-cert.
  3. Encrypt a Secret manifest offline: kubeseal --format yaml < secret.yaml > sealedsecret.yaml.
  4. Commit the SealedSecret CR to git; Argo CD / Flux applies it.
  5. Controller decrypts and upserts a native Secret in the target namespace.

SealedSecrets are scoped: by default, encryption is bound to namespace and name—renaming either requires re-sealing. Cluster-wide sealing exists but is discouraged. Key rotation requires re-encrypting all SealedSecrets when the controller key rotates.

yaml
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
  name: db-credentials
  namespace: payments
spec:
  encryptedData:
    password: AgBy3i4OJSWK+PiTySYZZA9rO43cGDEQAx...
    username: AgBXGjY0YjE3YjQzN2Y5ZWRhYzE4...
  template:
    metadata:
      labels:
        app: payment-api
    type: Opaque
Sealed Secrets workflow
$ kubectl create secret generic db-credentials --from-literal=password=dev --dry-run=client -o yaml | kubeseal -o yaml > sealed-db.yaml
$ kubeseal --fetch-cert --controller-name=sealed-secrets --controller-namespace=kube-system > pub-cert.pem
$ kubectl apply -f sealed-db.yaml
$ kubectl get sealedsecret db-credentials -n payments
$ kubectl get secret db-credentials -n payments  # materialized by controller$ oc create secret generic db-credentials --from-literal=password=dev --dry-run=client -o yaml | kubeseal -o yaml > sealed-db.yaml
$ kubeseal --fetch-cert --controller-name=sealed-secrets --controller-namespace=kube-system > pub-cert.pem
$ oc apply -f sealed-db.yaml
$ oc get sealedsecret db-credentials -n payments
$ oc get secret db-credentials -n payments
⚖️ Trade-off

Sealed Secrets vs External Secrets: Sealed Secrets encrypt at commit time with a cluster-bound key—simple, no external dependency, but rotation means re-sealing every file and backing up the controller private key carefully. ESO pulls live from Vault/AWS at runtime—better for centralized audit and dynamic rotation, but requires network access and external system uptime.

🎯 Interview Tip

"How do you store secrets in GitOps?" — Never plain Secrets. Options: SealedSecrets (asymmetric encrypt with cluster pubkey), SOPS-encrypted files, or ExternalSecret CRs syncing from Vault/Secrets Manager. Mention scope (namespace/name binding), key rotation, and that etcd encryption at rest is orthogonal to git safety.

Vault integration

HashiCorp Vault is the enterprise standard for dynamic secrets, leasing, and audit trails. Kubernetes integrates via the Vault Agent Injector, Secrets Store CSI driver, and External Secrets Operator—pick based on whether the app reads files, env vars, or native Secrets.

Integration patterns

Pattern How it works Best for
Agent Injector Mutating webhook adds Vault Agent init + sidecar; renders templates to /vault/secrets/ Apps that read files; dynamic DB creds with auto-renewal
CSI Provider SecretProviderClass mounts Vault secrets as volumes via CSI Mount-only workloads; can sync to native K8s Secret optionally
External Secrets ExternalSecretSecretStore / ClusterSecretStore polls Vault Standard K8s Secret refs in Deployments; GitOps CR-driven

Authentication methods

  • Kubernetes auth — Pod's SA JWT sent to Vault; Vault validates against the cluster's TokenReview API and maps SA → Vault role/policy. No long-lived Vault tokens in manifests.
  • AppRolerole_id + secret_id for CI/CD and operators; secret_id delivered via wrapped token or K8s Secret (prefer short TTL).
  • Cloud IAM auth — AWS/GCP/Azure auth methods when Vault runs on the same cloud; pods assume IAM roles.
sequenceDiagram
  participant Pod as Pod (SA token)
  participant Agent as Vault Agent
  participant Vault as Vault
  participant API as K8s API

  Pod->>Agent: Start with projected SA JWT
  Agent->>Vault: login auth/kubernetes (role, jwt)
  Vault->>API: TokenReview (validate JWT)
  API-->>Vault: SA identity OK
  Vault-->>Agent: Short-lived Vault token
  Agent->>Vault: Read secret/data/payments/db
  Vault-->>Agent: username/password
  Agent->>Pod: Write /vault/secrets/db.env
yaml
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: db-credentials
  namespace: payments
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: vault-backend
    kind: ClusterSecretStore
  target:
    name: db-credentials
    creationPolicy: Owner
  data:
    - secretKey: password
      remoteRef:
        key: secret/data/payments/db
        property: password
    - secretKey: username
      remoteRef:
        key: secret/data/payments/db
        property: username
---
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata:
  name: vault-db
  namespace: payments
spec:
  provider: vault
  parameters:
    vaultAddress: https://vault.internal:8200
    roleName: payments-app
    objects: |
      - objectName: db-password
        secretPath: secret/data/payments/db
        secretKey: password
Vault on Kubernetes
$ kubectl get pods -n vault -l app.kubernetes.io/name=vault
$ kubectl get mutatingwebhookconfigurations | grep vault-agent-injector
$ kubectl annotate pod payment-api-xyz vault.hashicorp.com/agent-inject='true'
$ kubectl get secretproviderclass -n payments
$ kubectl describe externalsecret db-credentials -n payments$ oc get pods -n vault -l app.kubernetes.io/name=vault
$ oc get mutatingwebhookconfigurations | grep vault-agent-injector
$ oc annotate pod payment-api-xyz vault.hashicorp.com/agent-inject='true'
$ oc get secretproviderclass -n payments
$ oc describe externalsecret db-credentials -n payments
🔴 OpenShift

OpenShift installs Vault via certified Helm/OLM operators; SCCs may require anyuid or custom SCC for Vault server pods. Use oc to verify webhook reachability across the SDN—mutating webhooks must reach the injector service on port 443. Red Hat OpenShift Secrets Store CSI Driver Operator is available on OperatorHub for platform-managed CSI installs.

💡 Pro Tip

For dynamic database credentials, enable Vault's database secrets engine with short TTLs (e.g. 1h). Agent Injector renews leases and re-renders files; apps that only read creds at startup still need Reloader or a SIGHUP hook—design for rotation from day one.

Resource Quotas & LimitRanges

Multi-tenant clusters need guardrails. ResourceQuota caps aggregate namespace consumption; LimitRange sets per-container/pod defaults and bounds. PriorityClass ensures critical workloads preempt lower-priority pods when nodes are full. OpenShift Project templates bake these in at namespace creation.

ResourceQuota

Enforced at admission time when pods are created. Counts active resources—completed Jobs may still count toward count/jobs.batch until TTL cleans them up. Common hard limits:

Quota key Limits
requests.cpu / limits.cpuSum of CPU requests/limits across all pods
requests.memory / limits.memorySum of memory requests/limits
podsTotal pod count
persistentvolumeclaimsPVC count
services.loadbalancersCloud LB cost control
count/deployments.appsObject count by type

Pods must specify requests and limits when the quota tracks those resources (requests.cpu quota requires every container to declare CPU requests). Use scopeSelector for tiered quotas (e.g. only BestEffort pods).

LimitRange

Namespace-scoped defaults and enforcement per Pod or Container:

  • default — Applied when container omits limits.
  • defaultRequest — Applied when container omits requests.
  • min / max — Reject pods outside bounds at admission.
  • maxLimitRequestRatio — Cap burst (e.g. limit ≤ 2× request).

PriorityClass

PriorityClass objects define global priority values. Pods with higher priority evict lower-priority pods when a node is under pressure (preemption). Platform namespaces run system-cluster-critical; batch jobs use low or negative priority. Pair with PodDisruptionBudgets so preemption does not violate availability SLOs.

OpenShift Project templates

OpenShift Project is a namespace with annotations and optional template objects. Cluster admins edit the project-request template to auto-inject ResourceQuota, LimitRange, NetworkPolicy, and RBAC RoleBindings when developers run oc new-project. This is how enterprise OCP clusters standardize tenant onboarding without per-team YAML drift.

yaml
apiVersion: v1
kind: ResourceQuota
metadata:
  name: team-quota
  namespace: payments
spec:
  hard:
    requests.cpu: "20"
    requests.memory: 40Gi
    limits.cpu: "40"
    limits.memory: 80Gi
    pods: "50"
    persistentvolumeclaims: "10"
    services.loadbalancers: "2"
---
apiVersion: v1
kind: LimitRange
metadata:
  name: team-limits
  namespace: payments
spec:
  limits:
    - type: Container
      default:
        cpu: 500m
        memory: 512Mi
      defaultRequest:
        cpu: 100m
        memory: 128Mi
      min:
        cpu: 50m
        memory: 64Mi
      max:
        cpu: "4"
        memory: 8Gi
      maxLimitRequestRatio:
        cpu: "4"
        memory: "4"
    - type: Pod
      max:
        cpu: "8"
        memory: 16Gi
---
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: production-high
value: 1000000
globalDefault: false
description: Production tier workloads
Quotas and limits
$ kubectl describe resourcequota team-quota -n payments
$ kubectl get resourcequota -n payments -o wide
$ kubectl describe limitrange team-limits -n payments
$ kubectl get priorityclass
$ kubectl create quota dev-quota --hard=pods=10,requests.cpu=4,requests.memory=8Gi -n dev$ oc describe quota team-quota -n payments
$ oc get quota -n payments
$ oc describe limitrange team-limits -n payments
$ oc get priorityclass
$ oc edit projectconfig cluster  # project-request template
⚠️ Pitfall

Quota without LimitRange: ResourceQuota on requests.cpu rejects pods with no CPU request—but developers see cryptic Forbidden errors. Always pair quotas with LimitRange defaults so oc new-app and quick manifests get sensible requests/limits automatically.

🎯 Interview Tip

"Difference between ResourceQuota and LimitRange?" — Quota is namespace aggregate cap (total CPU across all pods). LimitRange is per-pod/container min/max/default. Quota answers "how much can this team use?"; LimitRange answers "how big can a single pod be?"