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.
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.
ConfigMap (API)
│
├── env / envFrom ──► set once at pod start ──► restart required on change
│
└── volume mount ──► kubelet sync (~60s) ──► files update in-place
(subPath = NO auto-sync)
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
$ 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
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.
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.
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.
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.
$ 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
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.
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.
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
- Install the Sealed Secrets controller in the cluster; it generates an RSA key pair.
- Fetch the cluster public key: kubeseal --fetch-cert.
- Encrypt a Secret manifest offline: kubeseal --format yaml < secret.yaml > sealedsecret.yaml.
- Commit the SealedSecret CR to git; Argo CD / Flux applies it.
- 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.
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
$ 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
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.
"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 | ExternalSecret → SecretStore / 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.
- AppRole — role_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
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
$ 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 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.
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.cpu | Sum of CPU requests/limits across all pods |
| requests.memory / limits.memory | Sum of memory requests/limits |
| pods | Total pod count |
| persistentvolumeclaims | PVC count |
| services.loadbalancers | Cloud LB cost control |
| count/deployments.apps | Object 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.
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
$ 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
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.
"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?"