ConfigMaps & Secrets in k0s - Configuration Management
Status: Active
Last Updated: 2026-08-14
Category: Containers - Core k0s
Prerequisites: k0s-deployments
Time: 2 hours
Tags: kubernetes, k0s, configmaps, secrets, environment-variables, volumes
Summary
Hard-coding config into container images breaks everything you learned about rolling updates โ every config change would mean rebuilding images. ConfigMaps decouple configuration from images, Secrets do the same for sensitive data with base64 encoding and (optionally) encryption at rest. Learn all four ways to inject them into pods: environment variables, command-line args, volume-mounted files, and envFrom.
๐ฏ What You'll Learn
By the end of this article, you'll be able to:
- โ Create ConfigMaps from literals, files, and directories
- โ Create Secrets and understand base64 encoding vs encryption
- โ Inject config as environment variables and command arguments
- โ Mount config as files inside pods
- โ Know which injection method to use for each situation
- โ Understand k0s secrets encryption at rest
- โ Troubleshoot missing/invalid config references
๐งฉ The Problem: Config Baked Into Images
Recall the Deployment pattern from k0s-deployments. Imagine this:
# โ Anti-pattern baked into an image
ENV DATABASE_URL=postgres://admin:hunter2@prod-db:5432/app
Consequences of baking config into images:
| Problem | Impact |
|---|---|
| Dev/staging/prod need different values | Three nearly-identical images to build & track |
| Rotate a DB password | Rebuild + redeploy image fleet |
| Secret leaks via image registry | Anyone who can pull the image has your credentials |
| Twelve-factor violation | Config should live in the environment |
Kubernetes' answer: two object types that hold config outside pod specs and containers:
- ConfigMap โ non-sensitive key/value pairs or whole config files
- Secret โ same shape, but intended for sensitive data, base64-encoded, and eligible for encryption at rest
Both follow the same lifecycle: create โ reference from a pod โ Kubernetes injects at runtime.
๐ฆ Creating ConfigMaps
Method 1: Literal Key/Value Pairs (Quick)
sudo k0s kubectl create configmap app-config \
--from-literal=LOG_LEVEL=info \
--from-literal=APP_ENV=production \
--from-literal=MAX_CONNECTIONS=100
Inspect it:
sudo k0s kubectl get configmap app-config -o yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
data:
LOG_LEVEL: info
APP_ENV: production
MAX_CONNECTIONS: "100" # note: everything is a string
binaryData: {}
Method 2: From a File
# A real nginx config on disk
cat > /tmp/nginx-site.conf <<'EOF'
server {
listen 80;
server_name example.local;
location / {
return 200 'config came from a ConfigMap!';
add_header Content-Type text/plain;
}
}
EOF
sudo k0s kubectl create configmap nginx-site \
--from-file=/tmp/nginx-site.conf
The file name becomes the key, file contents become the value. This is how you ship entire config files โ no image rebuild needed.
Method 3: From a Whole Directory
mkdir -p /tmp/confd
cp /tmp/nginx-site.conf /tmp/confd/
echo "gzip on;" > /tmp/confd/gzip.conf
sudo k0s kubectl create configmap app-files --from-file=/tmp/confd/
# keys: nginx-site.conf, gzip.conf
Method 4: Declarative YAML (GitOps Is Law)
Imperative creation works, but production config belongs in git:
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
data:
LOG_LEVEL: info
APP_ENV: production
nginx-site.conf: | # multi-line file embedded inline
server {
listen 80;
location / { return 200 'from git!'; }
}
EOF-marker-not-needed
Wait โ careful with that trailing line; here's the correct full form:
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
data:
LOG_LEVEL: info
APP_ENV: production
nginx-site.conf: |
server {
listen 80;
location / {
return 200 'from git!';
add_header Content-Type text/plain;
}
}
Apply it:
sudo k0s kubectl apply -f app-config.yaml
The
|is YAML's literal block scalar โ preserves newlines exactly. Essential for embedding config files.
Size Limit
ConfigMaps cap at 1MiB. They're for configuration, not data storage โ large assets belong in object storage (cloud/minio-setup) or persistent volumes (k0s-storage).
๐ Creating Secrets
Same patterns, different kind:
# Literal
sudo k0s kubectl create secret generic db-credentials \
--from-literal=username=admin \
--from-literal=password=hunter2
# From a file (e.g., TLS key)
sudo k0s kubectl create secret generic tls-key \
--from-file=tls.key=/path/to/server.key
Look at what's actually stored:
sudo k0s kubectl get secret db-credentials -o yaml
apiVersion: v1
kind: Secret
metadata:
name: db-credentials
type: Opaque
data:
username: YWRtaW4=
password: aHVudGVyMg==
Base64 โ Encryption โ Say It Out Loud
echo 'YWRtaW4=' | base64 -d # โ admin
echo 'aHVudGVyMg==' | base64 -d # โ hunter2
Base64 is transport encoding, not protection. Anyone with API read access to the namespace can decode secrets instantly.
Where real protection comes from:
| Layer | Mechanism | Status by default in k0s |
|---|---|---|
| Transport | TLS between client โ API server | โ Always on |
| At rest | Encryption provider encrypting secret values in etcd | โ ๏ธ Configure explicitly (below) |
| Access control | RBAC โ who may get secrets |
โ Available, must be configured |
Enabling Encryption at Rest in k0s
k0s makes this a one-line config change. Generate a key and add an encryption section to k0s.yaml under spec.storage.etcd.extraArgs (or use the dedicated encryption support):
head -c 32 /dev/urandom | base64
# e.g. output: q8fK2xcVvbeSDdCBfyOP7ZzXWQn1sRp5M3tGhJkLmNo=
sudo k0s kubectl get secrets --all-namespaces # baseline before enabling
In /etc/k0s/k0s.yaml, add under spec:
spec:
storage:
etcd:
extraArgs:
encryption-provider-config: /var/lib/k0s/encryption-config.yaml
And place /var/lib/k0s/encryption-config.yaml:
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets
providers:
- aescbc:
keys:
- name: key1
secret: q8fK2xcVvbeSDdCBfyOP7ZzXWQn1sRp5M3tGhJkLmNo=
- identity: {} # fallback so existing plaintext secrets still read
Restart k0s (sudo systemctl restart k0scontroller) and rewrite existing secrets so they're stored encrypted:
sudo k0s kubectl get secrets --all-namespaces -o json | \
sudo k0s kubectl replace -f -
Verify encryption took effect by checking etcd directly โ values will be ciphertext prefixed with k8s:enc:aescbc:.
Full RBAC lockdown of secret access is covered in security/rbac-basics.
๐ Injection Method 1: Environment Variables
Single Values with valueFrom
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
spec:
replicas: 2
selector:
matchLabels: { app: api }
template:
metadata:
labels: { app: api }
spec:
containers:
- name: api
image: myorg/api:1.4
env:
# From a ConfigMap
- name: LOG_LEVEL
valueFrom:
configMapKeyRef:
name: app-config # ConfigMap name
key: LOG_LEVEL # key within it
# From a Secret
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: db-credentials
key: password
# Plain literal is still allowed alongside
- name: PORT
value: "3000"
What Happens at pod start:
1. Kubelet resolves configMapKeyRef โ fetches value "info"
2. Kubelet resolves secretKeyRef โ decodes base64 โ "hunter2"
3. Both injected into container environment before PID 1 starts
4. App reads them like any env var: process.env.LOG_LEVEL
โ ๏ธ Critical limitation: env vars are set at container start and do not update when the ConfigMap changes. Updating the ConfigMap does nothing until pods restart. For live-updating config, you need volume mounts (Method 3).
Test the round trip yourself:
sudo k0s kubectl apply -f api-deployment.yaml
sudo k0s kubectl exec deploy/api -- sh -c 'echo $DB_PASSWORD'
# โ hunter2 (decoded transparently)
Bulk Import with envFrom
Load every key in a ConfigMap/Secret as env vars at once:
envFrom:
- configMapRef:
name: app-config # LOG_LEVEL, APP_ENV, MAX_CONNECTIONS
envFrom:
- secretRef:
name: db-credentials # username, password
Trade-offs:
env: with valueFrom |
envFrom bulk |
|
|---|---|---|
| Explicitness | Every var visible in manifest | Keys invisible โ audit requires reading the CM |
| Key collisions | Impossible | Silent overwrite risk across multiple refs |
| Renaming a CM key | Compile-visible in manifest | Breaks app silently |
| Verdict | Production default | Quick bootstrapping only |
๐ Injection Method 2: Command-Line Arguments
Some apps take config via CLI flags rather than env vars. Kubernetes composes args from config cleanly:
containers:
- name: app
image: myorg/api:1.4
args:
- --log-level=$(LOG_LEVEL) # $(VAR) expands from env section
- --max-connections=$(MAX_CONNECTIONS)
env:
- name: LOG_LEVEL
valueFrom: { configMapKeyRef: { name: app-config, key: LOG_LEVEL } }
- name: MAX_CONNECTIONS
valueFrom: { configMapKeyRef: { name: app-config, key: MAX_CONNECTIONS } }
The $(VAR) syntax (not ${VAR}) pulls from the same pod's env: block โ compose-style indirection.
๐๏ธ Injection Method 3: Volume-Mounted Files
For config files (nginx.conf, tls certs, application.yml), mount the ConfigMap/Secret as a tmpfs directory:
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 2
selector:
matchLabels: { app: web }
template:
metadata:
labels: { app: web }
spec:
containers:
- name: nginx
image: nginx:1.27-alpine
volumeMounts:
- name: site-config # โ volume defined below
mountPath: /etc/nginx/conf.d # where files appear
readOnly: true
- name: db-auth
mountPath: /run/secrets/db # secret as files
readOnly: true
volumes:
- name: site-config
configMap:
name: app-config # each key becomes a file
- name: db-auth
secret:
secretName: db-credentials # username, password become files
Resulting filesystem inside the container:
/etc/nginx/conf.d/
โโโ LOG_LEVEL โ weird if mounted; pick mounts carefully!
โโโ APP_ENV
โโโ MAX_CONNECTIONS
โโโ nginx-site.conf โ nginx picks this up naturally
/run/secrets/db/
โโโ username โ cat /run/secrets/db/username โ admin
โโโ password
(Yes โ mounting the whole mixed-purpose ConfigMap drops the literal keys in as files too. Real-world practice: separate ConfigMaps per consumption style.)
Reading secrets as files avoids env-var leakage risks: env vars leak via crash dumps, child processes, and /proc/<pid>/environ; files have stricter read paths. Many runtimes prefer file-based secrets for this reason.
Live Updates โ The Volume Superpower
Unlike env vars, volume-mounted config updates automatically (kubelet syncs every ~minute):
# Edit the config in git, then:
sudo k0s kubectl apply -f app-config.yaml
# ~60 seconds later, inside the running pod:
sudo k0s kubectl exec deploy/web -- cat /etc/nginx/conf.d/nginx-site.conf
# โ updated content!
Caveats:
- Apps often cache config at startup โ they need file-watching (or SIGHUP) to notice.
- Sub-path mounts (
subPath: nginx-site.conf) break auto-update โ avoid unless forced. - Symlink dance: kubelet swaps via
..datasymlink โ watchers must follow symlinks (most do).
Reload nginx without restart:
sudo k0s kubectl exec deploy/web -- nginx -s reload
That's a complete config-change workflow with zero downtime: edit git โ apply โ wait for sync โ reload.
Projecting Specific Keys Only
Don't want the junk keys? Select precisely:
volumes:
- name: site-config
configMap:
name: app-config
items:
- key: nginx-site.conf
path: default.conf # rename on the way in!
Now only default.conf appears in the mount.
๐งญ Choosing the Right Method
| Situation | Use |
|---|---|
App reads standard env vars (DATABASE_URL, REDIS_HOST) |
env: + valueFrom |
| 12-factor app, many vars, low ceremony needs | envFrom |
| App reads config files (nginx, haproxy, yaml) | Volume mount from ConfigMap |
| Credentials consumed by modern runtimes | File-based Secret mount |
| Credentials for legacy env-var-only app | secretKeyRef env var |
| TLS certificates | Secret with type: kubernetes.io/tls + volume mount |
| Config that must change without pod restart | Volume mount (auto-sync) |
Docker-compose .env equivalent |
ConfigMap + envFrom |
Special secret types worth knowing:
# TLS secret - used later by [k0s-ingress](k0s-ingress)
sudo k0s kubectl create secret tls site-tls \
--cert=tls.crt --key=tls.key
# Docker registry credentials - used by [container-registry-integration](container-registry-integration)
sudo k0s kubectl create secret docker-registry regcred \
--docker-server=registry.example.com \
--docker-user=deploy-bot \
--docker-password=$TOKEN
๐ ๏ธ Hands-On Lab: Zero-Rebuild Config Change
Prove the whole story end-to-end โ serve custom content driven entirely by a ConfigMap:
# 1. Initial config
cat <<'EOF' | sudo k0s kubectl apply -f -
apiVersion: v1
kind: ConfigMap
metadata:
name: lab-web-content
data:
index.html: |
<h1>version one</h1>
EOF
# 2. Pod serving that content
cat <<'EOF' | sudo k0s kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: lab-web
labels: { app: lab-web }
spec:
containers:
- name: nginx
image: nginx:1.27-alpine
ports: [{ containerPort: 80 }]
volumeMounts:
- name: content
mountPath: /usr/share/nginx/html
volumes:
- name: content
configMap:
name: lab-web-content
EOF
sleep 15
sudo k0s kubectl exec lab-web -- curl -s localhost
# โ <h1>version one</h1>
# 3. Change ONLY the configmap
cat <<'EOF' | sudo k0s kubectl apply -f -
apiVersion: v1
kind: ConfigMap
metadata:
name: lab-web-content
data:
index.html: |
<h1>version two - zero rebuild!</h1>
EOF
# 4. Wait out the kubelet sync (~60s), verify WITHOUT restarting anything
sleep 70
sudo k0s kubectl exec lab-web -- curl -s localhost
# โ <h1>version two - zero rebuild!</h1>
sudo k0s kubectl get pod lab-web # RESTARTS unchanged - never bounced
# Cleanup
sudo k0s kubectl delete pod lab-web cm lab-web-content
The restart counter staying at zero while content changed is the entire value proposition of ConfigMaps.
๐ฉบ Troubleshooting & Common Issues
Pod stuck in CreateContainerConfigError
sudo k0s kubectl describe pod <name> | grep -A 5 Events
Typical messages:
| Message | Cause | Fix |
|---|---|---|
configmap "app-config" not found |
Referenced CM doesn't exist in this namespace | Create it or fix the name/namespace |
key "LOG_LEVEL" not found in ConfigMap |
Key renamed/missing | Align keys between CM and pod spec |
secret "db-credentials" not found |
Same, for secrets | Check kubectl get secret -n <ns> |
Note: missing ConfigMaps/Secrets don't block scheduling โ pods start and immediately error. That's why the failure shows up in events, not scheduler decisions.
Env var empty in the app but ConfigMap looks right
Check the classic trio:
- Namespace mismatch โ CM created in
default, workload in another namespace. Objects are namespaced! - Typo'd key โ
kubectl describe podshows resolution errors per variable. - Case sensitivity โ
log_levelโLOG_LEVEL.
Changed the ConfigMap but nothing happened
- Env vars never hot-update โ restart required:
sudo k0s kubectl rollout restart deployment/api. - Volume mounts sync within ~60s unless using
subPath(which freezes content forever). - App itself caches config โ check whether it watches files or needs a reload signal.
EnvVar expansion didn't work
$(VAR) only references variables defined earlier in the same env: list. Order matters; cross-container or cross-source references silently stay literal.
Secret too large / cert rejected
Secrets share the 1MiB limit. A bloated cert chain usually means stale intermediates โ trim the chain file.
I deleted a Secret and now pods crash-loop
Pods referencing absent secrets fail container creation; with restartPolicy Always they loop. Recreate the secret first โ recovery is immediate on next restart attempt.
โ Checkpoint
Before moving on, confirm you can:
- Explain base64 vs encryption, and where k0s gets real protection
- Choose between
env:,envFrom, and volume mounts for a given app - Perform a live config change with zero pod restarts
- Diagnose
CreateContainerConfigErrorfrom events alone - List which method supports hot-reload and which doesn't
๐ Related
Prerequisites:
- k0s-deployments - Deployments, rollouts, probes
Next in course:
- k0s-storage - PersistentVolumes for state beyond config
- k0s-ingress - Using TLS secrets to terminate HTTPS
Related topics:
- security/vault-introduction - When built-in Secrets aren't enough
- security/secrets-in-iac - Secrets management in Terraform/Ansible pipelines
- sysadmin/secrets - dotenvx and host-level secrets hygiene
๐ Change Log
2026-08-14
- Created lesson covering README spec: ConfigMaps, Secrets, environment variables, volume mounts
- Included k0s encryption-at-rest configuration
- Added injection-method decision table and troubleshooting matrix
Next Article: k0s-storage - Data that survives pod death!