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:


๐Ÿงฉ 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:

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:

  1. Apps often cache config at startup โ€” they need file-watching (or SIGHUP) to notice.
  2. Sub-path mounts (subPath: nginx-site.conf) break auto-update โ€” avoid unless forced.
  3. Symlink dance: kubelet swaps via ..data symlink โ€” 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:

  1. Namespace mismatch โ€” CM created in default, workload in another namespace. Objects are namespaced!
  2. Typo'd key โ€” kubectl describe pod shows resolution errors per variable.
  3. Case sensitivity โ€” log_level โ‰  LOG_LEVEL.

Changed the ConfigMap but nothing happened

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:


๐Ÿ”— Related

Prerequisites:

Next in course:

Related topics:


๐Ÿ“ Change Log

2026-08-14


Next Article: k0s-storage - Data that survives pod death!

Choose Theme

Your selection is saved locally.

Neural Cacophony
Aperture v2
Flux v1
Mosaic Chaos
Nexus v1
Nexus Zest
Prism v2
Synapse