The previous lesson took the web-store configuration out of the image and put it into a ConfigMap, but it ended with a warning: a ConfigMap protects nothing. Anyone who can list ConfigMaps in rutas-norte-pro reads their full contents in plain text. And the debt we have been dragging since module 2 is precisely one that cannot tolerate that: the bookings-postgres password is written in the clear in a manifest versioned in Git, and that database holds the name, ID number, phone number and email of every Rutas Norte customer. This lesson settles it with the Secret object, but above all it teaches you not to trust that object more than it deserves: you will see why base64 is not encryption, why etcd encryption at rest is not enabled by default, who can read a secret without you noticing, and what the ecosystem uses when the native Secret falls short.

Important warning. This lesson explains the mechanisms Kubernetes offers for managing credentials and gives you a solid starting point, but it does not replace a professional security design. bookings-postgres stores personal data belonging to real customers (name, ID number, phone number, email), which in the European Union falls squarely within the scope of the GDPR and, in Spain, of the LOPDGDD. The specific design of credential management, encryption at rest, the rotation policy, the audit logs and the applicable security measures must be reviewed and approved by a security professional or by your organisation's compliance officer. Treat everything that follows as necessary technical knowledge, not as a compliance recommendation.

Contents

  1. The module 2 debt and why it hurts
  2. What a Secret is and how it differs from a ConfigMap
  3. Base64 is not encryption: the demonstration
  4. The Secret types
  5. Creating Secrets: imperative and declarative with stringData
  6. Consuming a Secret as a volume
  7. The bookings-postgres-credentials secret in Rutas Norte
  8. imagePullSecrets for the private registry
  9. Encryption at rest in etcd
  10. Who can read a secret
  11. Credential rotation
  12. What you must never do
  13. The real solutions in the ecosystem

  1. The module 2 debt and why it hurts

This is the manifest we have in the Rutas Norte repository today:

# k8s/base/bookings-postgres/deployment.yaml   <-- THIS IS WRONG
    spec:
      containers:
        - name: postgres
          image: postgres:16.4
          env:
            - name: POSTGRES_PASSWORD
              value: "B00k1ngs2026!"          # in the clear, in Git, for ever
            - name: POSTGRES_USER
              value: "rutasnorte"
            - name: POSTGRES_DB
              value: "bookings"

The problem is not merely cosmetic. Let us enumerate the real damage:

  1. Git does not forget. Even if you delete the line today and commit, the password is still in the history. Getting it out of there requires rewriting the repository history (git filter-repo) and force-pushing to every clone. In practice, a credential that has been in Git is considered compromised and must be rotated, not deleted.
  2. It spreads uncontrollably. Every developer who cloned the repository has the production password on their laptop. So do the CI system, the repository backup system, the internal code search engine and any static analysis tool that keeps a cache.
  3. There is no traceability. There is no way to know who has read that password, or when.
  4. Rotating it means a code deployment. Changing the password requires a commit, a review, a merge and a deployment. That pushes people towards never rotating it.
  5. Automated bots will find it. If the repository becomes public for even a single minute, there are crawlers that locate credentials in seconds.

The goal of this lesson is for that manifest to end up like this:

          env:
            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: bookings-postgres-credentials
                  key: password

The manifest can be published without leaking anything. That is the twelve-factor bar we saw in the previous lesson.

  1. What a Secret is and how it differs from a ConfigMap

A Secret is, structurally, almost identical to a ConfigMap: metadata plus data, no spec or status, namespaced, and with the same 1 MiB limit. The difference lies in the treatment Kubernetes gives it and in the expectations it creates.

Aspect ConfigMap Secret
apiVersion v1 v1
Data field data (text), binaryData data (base64), stringData (write only)
Encoding of data Plain UTF-8 text Base64 mandatory
type field Does not exist Yes, and it drives validation
When mounted as a volume Written to the node's disk Written to tmpfs (RAM)
Appears in kubectl describe The full contents Only the size in bytes of each key
Encryption at rest in etcd No Only if configured (not by default)
Can be immutable Yes Yes
Size limit 1 MiB 1 MiB
Can be an imagePullSecrets No Yes
Can be restricted with RBAC Yes Yes, and it is essential

The three differences that really matter:

First: stringData. It is a write-only field. You write plain text into it and the apiserver base64-encodes it and moves it to data before storing it. It is what makes writing Secrets by hand bearable.

Second: the tmpfs mount. When a Secret is mounted as a volume, the kubelet creates a file system in RAM, not on the node's disk. Practical consequence: if the node is powered off abruptly, the credential is not left written on any recoverable disk. It is a real protection, though a limited one.

Third: describe does not show the contents.

kubectl describe secret bookings-postgres-credentials -n rutas-norte-dev
Name:         bookings-postgres-credentials
Namespace:    rutas-norte-dev
Labels:       app=bookings-postgres
              app.kubernetes.io/part-of=rutas-norte
              environment=dev
Annotations:  <none>

Type:  Opaque

Data
====
password:  13 bytes
username:  10 bytes
database:  8 bytes

Only the sizes. This avoids the classic accident of showing the password in a shared session or leaving it in a console log. But it is a presentation detail, not a security control, as the next section demonstrates.

  1. Base64 is not encryption: the demonstration

This is the most important idea in the lesson, and you need to internalise it by doing it.

We create the Secret:

kubectl create secret generic bookings-postgres-credentials \
  --from-literal=username=rutasnorte \
  --from-literal=password='B00k1ngs2026!' \
  --from-literal=database=bookings \
  -n rutas-norte-dev
secret/bookings-postgres-credentials created

We look at it in YAML:

kubectl get secret bookings-postgres-credentials -n rutas-norte-dev -o yaml
apiVersion: v1
data:
  database: Ym9va2luZ3M=
  password: QjAwazFuZ3MyMDI2IQ==
  username: cnV0YXNub3J0ZQ==
kind: Secret
metadata:
  creationTimestamp: "2026-08-05T11:02:47Z"
  name: bookings-postgres-credentials
  namespace: rutas-norte-dev
  resourceVersion: "191455"
  uid: 8a3f7d21-5c6b-4e09-b1d7-4f2a9c8e3105
type: Opaque

At first glance it looks encrypted. It is not. Base64 is a reversible encoding with no key, designed in the 1980s to carry binaries over text channels. Anyone can undo it:

kubectl get secret bookings-postgres-credentials -n rutas-norte-dev \
  -o jsonpath='{.data.password}' | base64 -d; echo
B00k1ngs2026!

Three seconds and one command. No password, no key, no special permissions beyond being able to read the Secret.

We can dump the whole contents in one go:

kubectl get secret bookings-postgres-credentials -n rutas-norte-dev \
  -o go-template='{{range $k, $v := .data}}{{$k}}={{$v | base64decode}}{{"\n"}}{{end}}'
database=bookings
password=B00k1ngs2026!
username=rutasnorte

Or, from Kubernetes 1.30 onwards, with the built-in viewer:

kubectl get secret bookings-postgres-credentials -n rutas-norte-dev -o jsonpath='{.data}' \
  | jq -r 'to_entries[] | "\(.key)=\(.value|@base64d)"'

What this implies exactly:

False belief Reality
"It is encoded, so it is safe" Base64 is undone without a key, in one command
"Since it is in a Secret, I can push it to Git" No. It is equivalent to pushing it in the clear
"Only administrators can see it" It is seen by anyone with the get secrets permission in the namespace
"In etcd it is encrypted" Not by default (section 9)
"The logs do not show it" A kubectl get secret -o yaml in a pipeline does

So what is base64 for, then? For two legitimate things, and neither of them is security:

  1. Allowing binary content. A certificate in DER format or a private key with non-printable bytes does not fit in a YAML text field. Base64 makes it possible.
  2. Avoiding escaping problems. A password with quotes, line breaks or $ would break the YAML. Encoded, it does not.

The rule you must memorise: a Kubernetes Secret does not protect the data; what it does is give it a place where controls can be applied (RBAC, encryption at rest, auditing, in-memory mounting) and separate it from the application manifest. The protection comes from those controls, not from the object.

  1. The Secret types

The type field is not decorative: it determines which keys Kubernetes demands and which components know how to use the Secret.

type Required keys What it is for Creation command
Opaque None General use: passwords, API tokens, encryption keys kubectl create secret generic
kubernetes.io/dockerconfigjson .dockerconfigjson Credentials for a private image registry kubectl create secret docker-registry
kubernetes.io/tls tls.crt, tls.key Certificate and private key for TLS (Ingress) kubectl create secret tls
kubernetes.io/basic-auth username, password HTTP basic authentication generic with --type
kubernetes.io/ssh-auth ssh-privatekey SSH key (cloning private repositories) generic with --type
kubernetes.io/service-account-token token, ca.crt, namespace Permanent token for a ServiceAccount Manifest with an annotation
bootstrap.kubernetes.io/token token-id, token-secret Joining new nodes to the cluster (kubeadm) Manifest

Usage notes for Rutas Norte:

  • Opaque is the one we will use for bookings-postgres-credentials, for the payment gateway key and for the mail provider token of notifications-worker. It is the default type if you specify nothing.
  • kubernetes.io/dockerconfigjson will be the one that lets us pull images from registry.rutasnorte.example (section 8).
  • kubernetes.io/tls will be the one holding the www.rutasnorte.example certificate. You will see it in TLS and Certificates, where cert-manager will generate and renew it on its own.
  • kubernetes.io/service-account-token: be careful with this one. Before Kubernetes 1.24, every ServiceAccount had an associated Secret of this type with a token that never expired. They are no longer created automatically, and creating them by hand is a bad idea except in very specific cases: an eternal token is a token that can never be cleanly revoked. You will see the current mechanism in ServiceAccounts.

The type is validated too. This fails:

kubectl create secret generic wrong-type --type=kubernetes.io/basic-auth \
  --from-literal=user=rutasnorte -n rutas-norte-dev
The Secret "wrong-type" is invalid: data[username]: Required value

The basic-auth type demands exactly the key username, not user. That validation is useful: it stops an Ingress or a controller from receiving a Secret with the wrong shape.

  1. Creating Secrets: imperative and declarative with stringData

5.1. kubectl create secret generic

kubectl create secret generic bookings-postgres-credentials \
  --from-literal=username=rutasnorte \
  --from-literal=password='B00k1ngs2026!' \
  -n rutas-norte-dev

Watch out for the shell history. That command is written into ~/.bash_history with the password inside it. Two ways to avoid that:

# Option A: a leading space (with HISTCONTROL=ignorespace)
 kubectl create secret generic ... --from-literal=password='B00k1ngs2026!'

# Option B (better): from a file, and delete the file afterwards
printf '%s' 'B00k1ngs2026!' > /tmp/pw
kubectl create secret generic bookings-postgres-credentials \
  --from-file=password=/tmp/pw -n rutas-norte-dev
shred -u /tmp/pw

The printf '%s' instead of echo is deliberate: echo adds a trailing newline that would end up inside the password. It is a classic and baffling mistake: the password "looks right" but PostgreSQL rejects it. If you use echo, add -n.

5.2. kubectl create secret docker-registry

kubectl create secret docker-registry registry-rutasnorte \
  --docker-server=registry.rutasnorte.example \
  --docker-username=deployments \
  --docker-password='<deployment-token>' \
  [email protected] \
  -n rutas-norte-pro

5.3. kubectl create secret tls

kubectl create secret tls web-store-tls \
  --cert=certs/www.rutasnorte.example.crt \
  --key=certs/www.rutasnorte.example.key \
  -n rutas-norte-pro

5.4. Manifest with stringData

If you write the manifest by hand, always use stringData, never data:

apiVersion: v1
kind: Secret
metadata:
  name: bookings-postgres-credentials
  namespace: rutas-norte-dev
  labels:
    app: bookings-postgres
    app.kubernetes.io/name: bookings-postgres
    app.kubernetes.io/component: database
    app.kubernetes.io/part-of: rutas-norte
    environment: dev
type: Opaque
stringData:                         # plain text; the apiserver encodes it when storing
  username: rutasnorte
  password: "B00k1ngs2026!"
  database: bookings

Advantages of stringData over data:

  • You write it and read it without encoding or decoding anything.
  • It removes the mistake of encoding with echo without -n, which puts a \n into the value.
  • A diff in a pull request is readable... which, mind you, is also the reason this file cannot go into Git as it stands.

If a key appears in both stringData and data, stringData wins.

And here comes the obvious question: if this file cannot go into Git, where does it live? Three legitimate answers, in order of maturity:

  1. Only in the cluster. The file is applied once from a secure workstation and destroyed. Simple, but you lose traceability and you have to document separately which keys exist.
  2. In Git, encrypted. Sealed Secrets or SOPS let you version a file that only the cluster can decrypt (section 13).
  3. Outside Kubernetes. An external secrets manager (Vault, AWS Secrets Manager) is the source of truth and an operator synchronises it (section 13).

In the meantime, the bare minimum in the Rutas Norte repository:

# .gitignore
k8s/**/secret-*.yaml
k8s/**/*-secret.yaml
*.key
*.pem

Plus a secret-postgres.yaml.example with dummy values, versioned, so the team knows which keys are needed.

  1. Consuming a Secret as a volume

The mechanism is identical to that of the ConfigMaps in the previous lesson, swapping configMap for secret and name for secretName:

    spec:
      containers:
        - name: api
          image: registry.rutasnorte.example/bookings-api:2.5.0
          volumeMounts:
            - name: db-credentials
              mountPath: /etc/secrets/postgres      # our own path, not a system directory
              readOnly: true
      volumes:
        - name: db-credentials
          secret:
            secretName: bookings-postgres-credentials
            defaultMode: 0400                      # only the owner reads
            items:                                 # optional: only these keys
              - key: password
                path: password

Inside the container:

kubectl exec -n rutas-norte-dev deploy/bookings-api -- ls -la /etc/secrets/postgres
kubectl exec -n rutas-norte-dev deploy/bookings-api -- mount | grep secrets
total 0
drwxrwxrwt 3 root root  100 Aug  5 11:31 .
drwxr-xr-x 3 root root 4096 Aug  5 11:31 ..
lrwxrwxrwx 1 root root   15 Aug  5 11:31 password -> ..data/password

tmpfs on /etc/secrets/postgres type tmpfs (ro,relatime,size=...)

Two things to highlight:

  • type tmpfs: the volume is in RAM. It never touches the node's disk. When the pod dies, it disappears.
  • The same chain of links to ..data as with ConfigMaps, with the same consequence: if the Secret changes, the mounted file is updated (with the same kubelet delay, and with the same subPath exception, which does not propagate).

That refresh capability is the main reason to prefer a volume over an environment variable when credentials are involved. A well-written application can re-read the file and adopt a rotated password without restarting. We will come back to this in section 11.

Consumption as an environment variable (secretKeyRef, envFrom with secretRef) is just as common and often more practical, above all with third-party images such as postgres:16, which expect POSTGRES_PASSWORD in the environment. You will see it in full detail in Environment Variables. Here is the comparison up front, because it shapes the design:

Volume Environment variable
Refreshes on rotation Yes No: requires recreating the pod
Visible in kubectl describe pod No The reference is, the value is not
Visible in /proc/<pid>/environ No Yes, to any process in the container
Can leak in an error dump Unlikely Yes: many frameworks dump the environment
Compatible with third-party images Sometimes (*_FILE) Almost always

Many serious images accept the _FILE variant: POSTGRES_PASSWORD_FILE=/etc/secrets/postgres/password. When it exists, prefer it: it combines the compatibility of the variable with the safety of the file.

  1. The bookings-postgres-credentials secret in Rutas Norte

Let us settle the debt properly. The full design:

flowchart LR
    S["Secret<br/>bookings-postgres-credentials<br/>type: Opaque"]
    S -->|"env: POSTGRES_PASSWORD"| PG["bookings-postgres<br/>(creates the user on init)"]
    S -->|"volume /etc/secrets/postgres"| API["bookings-api<br/>(connects to the DB)"]
    S -->|"volume /etc/secrets/postgres"| W["notifications-worker<br/>(reads pending bookings)"]
    S2["Secret<br/>smtp-notifications"] -->|"env: SMTP_PASSWORD"| W
    RBAC["RBAC (08-01)<br/>only these SAs read the Secret"] -.controls.-> S

The Secret, in k8s/environments/dev/secret-postgres.yaml (kept out of Git):

apiVersion: v1
kind: Secret
metadata:
  name: bookings-postgres-credentials
  namespace: rutas-norte-dev
  labels:
    app: bookings-postgres
    app.kubernetes.io/name: bookings-postgres
    app.kubernetes.io/part-of: rutas-norte
    environment: dev
type: Opaque
stringData:
  username: rutasnorte
  password: "d3v-Ch4ng3M3-2026"
  database: bookings
  # Full URL: convenient for the app, but it duplicates the password.
  # If the app can compose it, better not to keep it here.
  url: "postgresql://rutasnorte:d3v-Ch4ng3M3-2026@bookings-postgres:5432/bookings"

The bookings-postgres Deployment, now with no plain-text passwords:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: bookings-postgres
  namespace: rutas-norte-dev
  labels:
    app: bookings-postgres
    app.kubernetes.io/part-of: rutas-norte
    environment: dev
spec:
  replicas: 1
  selector:
    matchLabels:
      app: bookings-postgres
      environment: dev
  strategy:
    type: Recreate                     # a DB cannot have two pods on the same disk
  template:
    metadata:
      labels:
        app: bookings-postgres
        app.kubernetes.io/name: bookings-postgres
        app.kubernetes.io/component: database
        app.kubernetes.io/part-of: rutas-norte
        environment: dev
    spec:
      containers:
        - name: postgres
          image: postgres:16.4
          ports:
            - name: postgres
              containerPort: 5432
          env:
            - name: POSTGRES_USER
              valueFrom:
                secretKeyRef:
                  name: bookings-postgres-credentials
                  key: username
            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: bookings-postgres-credentials
                  key: password
            - name: POSTGRES_DB
              valueFrom:
                secretKeyRef:
                  name: bookings-postgres-credentials
                  key: database
            - name: PGDATA
              value: /var/lib/postgresql/data/pgdata

And bookings-api, which consumes it as a file:

        - name: api
          image: registry.rutasnorte.example/bookings-api:2.5.0
          env:
            - name: DB_HOST
              value: bookings-postgres          # the Service from module 2
            - name: DB_PORT
              value: "5432"
            - name: DB_PASSWORD_FILE
              value: /etc/secrets/postgres/password
          volumeMounts:
            - name: db-credentials
              mountPath: /etc/secrets/postgres
              readOnly: true
      volumes:
        - name: db-credentials
          secret:
            secretName: bookings-postgres-credentials
            defaultMode: 0400
            items:
              - key: password
                path: password
              - key: username
                path: username

Note the design detail: bookings-api receives only username and password thanks to items. It does not receive the url key, which contains the duplicated password, nor any other key that might be added to the Secret in the future. It is the principle of least privilege applied to the contents of an object.

Verification that the debt has been settled:

grep -r "B00k1ngs2026" k8s/ ; echo "exit: $?"
kubectl exec -n rutas-norte-dev deploy/bookings-api -- cat /etc/secrets/postgres/password; echo
exit: 1

d3v-Ch4ng3M3-2026

grep finds nothing in the repository and the application has its credential. That is the goal.

  1. imagePullSecrets for the private registry

The Rutas Norte images live in registry.rutasnorte.example, which is private. Without credentials, the pod fails like this:

kubectl describe pod bookings-api-7d9f8c6b4-x2mkl -n rutas-norte-pro | tail -6
Events:
  Type     Reason     Age                From     Message
  ----     ------     ----               ----     -------
  Normal   Pulling    45s                kubelet  Pulling image "registry.rutasnorte.example/bookings-api:2.5.0"
  Warning  Failed     43s                kubelet  Failed to pull image: rpc error: code = Unknown
             desc = failed to resolve reference: unexpected status from HEAD request: 401 Unauthorized
  Warning  Failed     43s                kubelet  Error: ErrImagePull
  Normal   BackOff    18s (x3 over 42s)  kubelet  Back-off pulling image
  Warning  Failed     18s                kubelet  Error: ImagePullBackOff

ImagePullBackOff with a 401 always means the same thing: registry credentials are missing.

You create a Secret of the right type:

kubectl create secret docker-registry registry-rutasnorte \
  --docker-server=registry.rutasnorte.example \
  --docker-username=deployments \
  --docker-password='<read-only-deployment-token>' \
  -n rutas-norte-pro
secret/registry-rutasnorte created

What it stores inside is a .dockerconfigjson file:

kubectl get secret registry-rutasnorte -n rutas-norte-pro \
  -o jsonpath='{.data.\.dockerconfigjson}' | base64 -d | jq .
{
  "auths": {
    "registry.rutasnorte.example": {
      "username": "deployments",
      "password": "<read-only-deployment-token>",
      "auth": "ZGVwbG95bWVudHM6PHRva2VuLi4uPg=="
    }
  }
}

Another demonstration that base64 protects nothing: the auth field is simply user:password encoded.

And it is referenced in the pod:

    spec:
      imagePullSecrets:
        - name: registry-rutasnorte      # at POD level, not container level
      containers:
        - name: api
          image: registry.rutasnorte.example/bookings-api:2.5.0

Three important details:

  1. imagePullSecrets goes in the pod's spec, not inside containers. It applies to every image in the pod, including init containers.
  2. You can list several, one per registry. The kubelet picks the one matching the image's server.
  3. You have to repeat it in every pod in the namespace, which is tedious and easy to forget. The elegant solution is to declare it on the ServiceAccount: every pod using it will inherit it without writing it out. You will see it in ServiceAccounts.

Good security practice: the registry token must be image read-only (pull), never one with push permission. If the cluster is compromised, a pull token limits the damage to being able to download images; a push one would allow replacing them with malicious versions. The Image Security lesson goes deeper into this.

  1. Encryption at rest in etcd

We reach the point most people are unaware of.

By default, Secrets are stored in etcd unencrypted. Base64-encoded, yes; encrypted, no. Whoever has access to the disk of a control-plane node, to an etcd backup or to a file system dump has every credential in the cluster.

Demonstration (on a cluster where you have control-plane access):

sudo ETCDCTL_API=3 etcdctl \
  --cacert=/etc/kubernetes/pki/etcd/ca.crt \
  --cert=/etc/kubernetes/pki/etcd/server.crt \
  --key=/etc/kubernetes/pki/etcd/server.key \
  get /registry/secrets/rutas-norte-dev/bookings-postgres-credentials | hexdump -C | head -8
00000000  2f 72 65 67 69 73 74 72  79 2f 73 65 63 72 65 74  |/registry/secret|
00000010  73 2f 72 75 74 61 73 2d  6e 6f 72 74 65 2d 64 65  |s/rutas-norte-de|
00000020  76 2f 62 6f 6f 6b 69 6e  67 73 2d 70 6f 73 74 67  |v/bookings-postg|
...
000000a0  64 33 76 2d 43 68 34 6e  67 33 4d 33 2d 32 30 32  |d3v-Ch4ng3M3-202|
000000b0  36 12 0a 72 75 74 61 73  6e 6f 72 74 65           |6..rutasnorte|

The password is read straight out of the dump. You do not even need to decode base64: etcd stores the serialised object with the values already in binary.

The fix is an EncryptionConfiguration on the apiserver:

# /etc/kubernetes/enc/encryption-config.yaml (only on the control-plane nodes)
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
  - resources:
      - secrets                        # which objects get encrypted
      - configmaps                     # optional, it has a cost
    providers:
      - aescbc:                        # the FIRST one is used for WRITING
          keys:
            - name: key-2026-08
              secret: <32 random bytes in base64>
      - identity: {}                   # unencrypted: needed to READ the old data

And it is enabled by starting the apiserver with --encryption-provider-config=/etc/kubernetes/enc/encryption-config.yaml.

The available providers:

Provider Cipher Speed Strength Comment
identity None — None The default behaviour
secretbox XSalsa20 + Poly1305 Very fast Strong Good option when there is no KMS
aescbc AES-CBC 32 bytes Fast Acceptable Theoretically vulnerable to a padding oracle
aesgcm AES-GCM Very fast Strong Requires frequent key rotation
kms v2 Delegated to an external KMS Fast The best The key is never on the node's disk

Four critical rules:

  1. Order matters. The first provider in the list is the one that encrypts on write. All of them in the list are tried on read. That is why identity must go last during the migration: it lets you read the Secrets that already existed unencrypted.
  2. Enabling it does not encrypt what already exists. Only what is written from that moment on gets encrypted. You have to force a rewrite of everything:
kubectl get secrets --all-namespaces -o json | kubectl replace -f -
  1. The key in that file is the crown jewel. If you use aescbc or secretbox, the key sits in plain text on the control-plane node's disk. Whoever has that file and a copy of etcd has everything. That is why kms v2 is the only genuinely solid option in production: the data encryption key is wrapped with a key that lives in an HSM or in the cloud KMS and is never written to the node.
  2. On managed Kubernetes (EKS, AKS, GKE) you enable this with a tick box, delegating to the provider's KMS. It is one of the strong reasons to use a managed cluster, as you will see in 10-06.

For Rutas Norte, which stores customer ID numbers and contact details, encryption at rest with a KMS is not optional: it is one of those technical measures the GDPR expects to see documented. And once again: the specific decision must be validated by the compliance officer.

  1. Who can read a secret

A Secret has no protection of its own. Everything depends on RBAC, studied in depth in Role-Based Access Control. Here it is enough to understand why the two are inseparable.

Who can read bookings-postgres-credentials today:

Who How How it is restricted
Anyone with get secrets in the namespace kubectl get secret -o yaml RBAC: do not grant that verb
Anyone with list secrets in the namespace kubectl get secrets -o yaml (it lists the contents!) RBAC: list is as dangerous as get
Anyone who can create a pod in the namespace Mounts the Secret in a pod of their own and reads it RBAC on create pods
Anyone with exec on a pod that mounts it kubectl exec ... -- cat /etc/secrets/... RBAC on pods/exec
The kubelet of the nodes running those pods It needs the Secret in order to mount it NodeRestriction limits it to its own pods
Anyone with access to etcd or its backups Direct dump Encryption at rest + node security
A cluster administrator Everything Auditing

The two entries that surprise people and must be underlined:

list leaks the contents. Many people grant list secrets thinking it only allows seeing the names. It does not: kubectl get secrets -o yaml with list permission returns the complete objects, data included. In RBAC, list on secrets is equivalent to get.

Being able to create pods is equivalent to being able to read every secret in the namespace. Anyone who can deploy a pod can mount any Secret in the namespace and dump it. There is no way to prevent this with RBAC on secrets: you have to restrict pod creation. It is the main reason why the namespace is the real unit of credential isolation and why Rutas Norte separates dev, pre and pro: nobody with development access should be able to create pods in rutas-norte-pro.

A good auditing exercise, anticipating module 8:

kubectl auth can-i get secrets -n rutas-norte-pro
kubectl auth can-i list secrets -n rutas-norte-pro --as=system:serviceaccount:rutas-norte-pro:default
yes
no

  1. Credential rotation

Rotating a credential means changing it periodically and, without exception, whenever you suspect it has leaked. And here a problem appears that Kubernetes does not solve on its own.

The problem: a pod that started yesterday holds the old password. If you change it in the Secret:

  • If it consumes it as a volume: the file is updated within a couple of minutes. But the application will only notice if it re-reads the file. Most read at start-up and keep the connection open.
  • If it consumes it as an environment variable: it is never updated. A process's environment variables are fixed when it is executed and there is no way to change them from outside.

The correct rotation sequence, with two valid credentials at once, which is what avoids a service outage:

sequenceDiagram
    participant OP as Operator
    participant PG as bookings-postgres
    participant K8S as Secret
    participant API as bookings-api pods

    OP->>PG: 1. Create the NEW password (the old one is still valid)
    OP->>K8S: 2. Update the Secret with the new one
    Note over API: 3. The old pods still hold the old one: THEY KEEP WORKING
    OP->>API: 4. kubectl rollout restart (progressive, no outage)
    Note over API: 5. The new pods start with the new one
    OP->>PG: 6. Revoke the OLD password
    OP->>PG: 7. Check in the logs that nobody is using it

In commands:

# 1. In PostgreSQL, create the new credential without removing the old one
kubectl exec -n rutas-norte-pro deploy/bookings-postgres -- \
  psql -U postgres -c "ALTER USER rutasnorte PASSWORD 'new-2026-08';"

# 2. Update the Secret
kubectl create secret generic bookings-postgres-credentials \
  --from-literal=username=rutasnorte \
  --from-literal=password='new-2026-08' \
  --from-literal=database=bookings \
  -n rutas-norte-pro --dry-run=client -o yaml | kubectl apply -f -

# 3. Progressively restart the consumers
kubectl rollout restart deploy/bookings-api -n rutas-norte-pro
kubectl rollout restart deploy/notifications-worker -n rutas-norte-pro
kubectl rollout status deploy/bookings-api -n rutas-norte-pro
secret/bookings-postgres-credentials configured
deployment.apps/bookings-api restarted
deployment.apps/notifications-worker restarted
deployment "bookings-api" successfully rolled out

A warning about ALTER USER: in PostgreSQL a user has a single password, so step 1 replaces it and the old pods will break as soon as they open a new connection. In a system with serious rotation you use two users (rutasnorte_a and rutasnorte_b) and alternate between them, which is what Vault's dynamic secret engines do. Design it before you need it.

Points worth fixing in your mind:

  • Automate change detection. The technique of hashing the Secret into an annotation on the template, which you will see in 03-03, makes updating the Secret trigger the rollout by itself, with no manual rollout restart.
  • Always rotate after an exposure. If the password has been in Git, rotating it is mandatory even if you rewrote the history.
  • Watch out for stragglers. A CronJob or a pod that is not part of a Deployment does not get restarted by rollout restart. Draw up a list of consumers before rotating.

  1. What you must never do

Practice Why it is serious
Pushing the Secret to Git, even base64-encoded Base64 is not encryption. It is the same as pushing it in the clear, and it stays in the history for ever
Putting a credential in an annotation Annotations get copied to derived objects, show up in describe and in controller logs
Passing it in args or command It appears in kubectl describe pod, in ps aux inside the container and in the kubelet logs
Baking it into the image It stays in a registry layer; docker history shows it even if you delete it in a later layer
Writing it into a ConfigMap No protected describe, no tmpfs, no encryption at rest, and ConfigMap RBAC is usually lax
Logging it Logs are centralised, copied and kept for months (07-05)
Reusing the same credential across the three environments An incident in dev compromises pro
Using the default Secret or sharing one between components It makes revoking access for a single component impossible
Granting list secrets "because it only lists names" list returns the complete contents
Creating permanent ServiceAccount tokens A token that never expires cannot be cleanly revoked
Relying on .gitignore alone A git add -f or a differently named file bypasses it. Add a pre-commit hook that detects credentials

On the args point, a demonstration of how exposed it stays:

# NEVER DO THIS
        - name: worker
          image: registry.rutasnorte.example/notifications-worker:1.8.0
          args: ["--smtp-password=S3nd1ng-2026!"]
kubectl describe pod notifications-worker-6f7d9-abcde -n rutas-norte-pro | grep -A2 Args
    Args:
      --smtp-password=S3nd1ng-2026!

Anyone with read permission on pods — a permission granted to a lot of people, monitoring systems included — has just read the mail password.

  1. The real solutions in the ecosystem

The native Secret solves where the credential lives inside the cluster. It does not solve how it gets there securely and auditably, nor how it is versioned, nor how it rotates itself. That is what these four families exist for. We are not going to run a tutorial on any of them — each one is worth a course of its own — but you must know when to look in each direction.

Solution Core idea When to choose it
Sealed Secrets (Bitnami) A controller in the cluster holds a private key; you encrypt with the public one and push the encrypted file to Git. Only that cluster can decrypt it Small teams, pure GitOps, no external infrastructure
SOPS (+ age/KMS) Encrypts only the values in the YAML, leaving the keys readable. Integrates with Flux, Helm and Kustomize When you want readable diffs and already use GitOps
External Secrets Operator An ExternalSecret CRD declares where to fetch the credential from; the operator reads it from Vault/AWS/Azure/GCP and keeps a native Secret synchronised The de facto standard in companies that already run a secrets manager
HashiCorp Vault A complete secrets manager: short-lived dynamic credentials, PKI engine, detailed auditing, its own policies Large organisations, strong audit requirements
Cloud KMS (AWS Secrets Manager, Azure Key Vault, GCP Secret Manager) The cloud manager is the source of truth; access is via federated identity, with no static credentials You are already on that cloud

An example of what an ExternalSecret looks like, just so you recognise the pattern when you come across it:

apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: bookings-postgres-credentials
  namespace: rutas-norte-pro
spec:
  refreshInterval: 1h                     # resyncs hourly: automatic rotation
  secretStoreRef:
    name: vault-rutasnorte
    kind: SecretStore
  target:
    name: bookings-postgres-credentials   # the NATIVE Secret that will be created
    creationPolicy: Owner
  data:
    - secretKey: password                 # key in the Kubernetes Secret
      remoteRef:
        key: rutasnorte/pro/postgres      # path in Vault
        property: password

The elegant part of the pattern: this file can go into Git. It does not contain the credential, it only says where to find it. And refreshInterval: 1h means that if someone rotates the password in Vault, the cluster's Secret updates itself within the hour.

Where each thing fits in the maturity of a platform like Rutas Norte:

  1. Today (where we are): native Secrets created by hand, outside Git, with .gitignore and an example file. Acceptable in dev.
  2. Next step: encryption at rest with a KMS in the cluster and strict RBAC on secrets in rutas-norte-pro.
  3. Maturity: External Secrets Operator pointing at the corporate secrets manager, with automatic rotation and an audit trail of every read.

Common Mistakes and Tips

Mistake Symptom Fix
Believing base64 protects The Secret gets pushed to Git It is encoding, not encryption. Never into Git unencrypted
echo without -n when encoding The app rejects the "correct" password Use printf '%s' or echo -n, or better still stringData
Wrong Secret type data[username]: Required value Review the type table in section 4
Secret in another namespace Pod in ContainerCreating, FailedMount Secrets do not cross namespaces
Expecting a refresh with environment variables The rotation never reaches the pods Variables are immutable: rollout restart
subPath with a Secret The rotation never propagates Same as with ConfigMaps: avoid subPath
defaultMode: 0400 with a non-root user Permission denied Use 0440 with fsGroup, or 0444
Forgetting imagePullSecrets ImagePullBackOff with a 401 Add it to the pod or, better, to the ServiceAccount
imagePullSecrets inside containers YAML validation error It goes in the pod's spec
Granting list secrets Silent leak of every credential list returns contents. Treat it like get
Not enabling encryption at rest Credentials readable in the etcd backups EncryptionConfiguration with kms v2
Rotating without a second credential Service outage during the rotation The new credential must be valid before revoking the old one
Password in args or in a log Visible in describe pod and in the logging system Volume or environment variable, never an argument

Final tips:

  1. Treat every Secret as if it were going to leak. Design on the assumption that someone will read it: different credentials per environment and per component, with least privilege in the database.
  2. Install a credential detector in your pre-commit. gitleaks or detect-secrets cost ten minutes of configuration and prevent 90% of accidents.
  3. Keep an inventory of secrets. What exists, who consumes it, when it was last rotated, who owns it.
  4. Document the keys, never the values. A secret-postgres.yaml.example with password: CHANGEME under version control is useful and safe.
  5. And, as said at the start: have the design reviewed by a security professional or the compliance officer. With customers' personal data involved, this is not negotiable.

Exercises

Exercise 1: Prove that base64 is not encryption

  1. Create a payment-gateway Secret in rutas-norte-dev with the keys api_key (value sk_test_4eC39HqLyjWDarjtT1zdp7dc) and environment (value sandbox).
  2. Show the object in YAML and check that the value cannot be read.
  3. Decode api_key with a single chained command.
  4. Check what kubectl describe shows for that Secret and explain why that is not a security measure.
  5. Answer: if a colleague has the list verb on secrets in that namespace but not get, can they read the key? Prove it with kubectl auth can-i.

Exercise 2: Settle the bookings-postgres debt

  1. Write the manifest for the bookings-postgres-credentials Secret for rutas-norte-dev using stringData, with username, password and database, and with the Rutas Norte labelling scheme.
  2. Modify the bookings-postgres Deployment so that it takes the three variables from the Secret.
  3. Modify bookings-api so that it mounts only username and password as files in /etc/secrets/postgres with read-only permissions.
  4. Verify that the volume is on tmpfs.
  5. Prove with grep that no plain-text password is left in k8s/, and add the necessary rules to the .gitignore.

Exercise 3: Rotation with no service outage

bookings-api runs with 4 replicas in rutas-norte-pro and the database password has to be rotated because a former team member knew it.

  1. Write out the full sequence of steps, indicating which of them carries a risk of an outage and how you avoid it.
  2. Run the Secret update with a single command that works whether or not the Secret already exists.
  3. Make the pods adopt the new credential without losing service at any point.
  4. Verify that the 4 new pods are up and that none of them still holds the previous credential.
  5. Identify which consumers of the Secret are not restarted by kubectl rollout restart and what you would do about them.

Solutions

Solution 1

kubectl create secret generic payment-gateway \
  --from-literal=api_key=sk_test_4eC39HqLyjWDarjtT1zdp7dc \
  --from-literal=environment=sandbox \
  -n rutas-norte-dev

kubectl get secret payment-gateway -n rutas-norte-dev -o yaml
secret/payment-gateway created

apiVersion: v1
data:
  api_key: c2tfdGVzdF80ZUMzOUhxTHlqV0Rhcmp0VDF6ZHA3ZGM=
  environment: c2FuZGJveA==
kind: Secret
metadata:
  name: payment-gateway
  namespace: rutas-norte-dev
type: Opaque

The decoding:

kubectl get secret payment-gateway -n rutas-norte-dev \
  -o jsonpath='{.data.api_key}' | base64 -d; echo
sk_test_4eC39HqLyjWDarjtT1zdp7dc
kubectl describe secret payment-gateway -n rutas-norte-dev
Name:         payment-gateway
Namespace:    rutas-norte-dev
Type:         Opaque

Data
====
api_key:      32 bytes
environment:  7 bytes

describe only shows the size. It is not a security measure, but a matter of visual hygiene: it stops the credential being shown by accident on a shared screen or in the output of a script. Whoever has permission to run describe has the get verb, and with get -o yaml they obtain the value. The real protection comes from RBAC and encryption at rest, not from the output format.

About list:

kubectl auth can-i list secrets -n rutas-norte-dev [email protected]
kubectl get secrets -n rutas-norte-dev -o yaml [email protected] | grep api_key
yes
  api_key: c2tfdGVzdF80ZUMzOUhxTHlqV0Rhcmp0VDF6ZHA3ZGM=

Yes, they can read it. list returns the complete objects, not just their names. It is a very widespread permission-granting mistake.

Solution 2

# k8s/environments/dev/secret-postgres.yaml  (NOT versioned in Git)
apiVersion: v1
kind: Secret
metadata:
  name: bookings-postgres-credentials
  namespace: rutas-norte-dev
  labels:
    app: bookings-postgres
    app.kubernetes.io/name: bookings-postgres
    app.kubernetes.io/component: database
    app.kubernetes.io/part-of: rutas-norte
    environment: dev
type: Opaque
stringData:
  username: rutasnorte
  password: "d3v-Ch4ng3M3-2026"
  database: bookings

The bookings-postgres Deployment is the one from section 7. The bookings-api fragment:

          volumeMounts:
            - name: db-credentials
              mountPath: /etc/secrets/postgres
              readOnly: true
      volumes:
        - name: db-credentials
          secret:
            secretName: bookings-postgres-credentials
            defaultMode: 0400
            items:
              - key: username
                path: username
              - key: password
                path: password

Verifying the tmpfs and that there are only two files:

kubectl exec -n rutas-norte-dev deploy/bookings-api -- sh -c \
  'ls /etc/secrets/postgres && mount | grep secrets'
password
username
tmpfs on /etc/secrets/postgres type tmpfs (ro,relatime)

Two files, not three: database has not been projected because it is not in items. And the volume is tmpfs, that is, RAM.

grep -rniE "password.*[:=].*[a-z0-9]{8}" k8s/ --include=*.yaml | grep -v secret-

No results. The .gitignore:

# Secrets: never in the repository
k8s/**/secret-*.yaml
k8s/**/*-secret.yaml
!k8s/**/*.yaml.example
*.key
*.pem
*.p12

The !k8s/**/*.yaml.example line lets you version the templates with dummy values, which are what document the keys needed.

Solution 3

The sequence, with the risk flagged:

  1. Create the new password in PostgreSQL. High risk if the engine only accepts one password per user: the live pods will break on their next connection. You avoid it by creating a new user (rutasnorte_b) instead of changing the current one's password, leaving both valid.
  2. Update the Secret. No risk: the live pods already have the credential loaded in memory.
  3. rollout restart. No risk if maxUnavailable is set properly: module 2 guarantees progressive replacement.
  4. Revoke the old credential. Risk if any consumer has not been restarted; that is why it comes after verifying.
kubectl create secret generic bookings-postgres-credentials \
  --from-literal=username=rutasnorte_b \
  --from-literal=password='R0t4t3d-2026-08!' \
  --from-literal=database=bookings \
  -n rutas-norte-pro --dry-run=client -o yaml | kubectl apply -f -
secret/bookings-postgres-credentials configured

The --dry-run=client -o yaml | kubectl apply -f - trick is the idiomatic one: it generates the object and applies it, working both for creating and for updating, which kubectl create on its own does not do (it would fail with AlreadyExists).

kubectl rollout restart deploy/bookings-api -n rutas-norte-pro
kubectl rollout status deploy/bookings-api -n rutas-norte-pro
kubectl get pods -l app=bookings-api -n rutas-norte-pro
deployment.apps/bookings-api restarted
Waiting for deployment "bookings-api" rollout to finish: 2 of 4 updated replicas are available...
deployment "bookings-api" successfully rolled out

NAME                            READY   STATUS    RESTARTS   AGE
bookings-api-7f4b8c9d6-2xkqp    1/1     Running   0          48s
bookings-api-7f4b8c9d6-8mvtr    1/1     Running   0          62s
bookings-api-7f4b8c9d6-j9wzl    1/1     Running   0          35s
bookings-api-7f4b8c9d6-qh4nc    1/1     Running   0          21s

All four pods are new (same pod-template-hash, AGE under a minute and RESTARTS 0). Verifying the effective credential:

kubectl exec -n rutas-norte-pro deploy/bookings-api -- cat /etc/secrets/postgres/username; echo
rutasnorte_b

Consumers that are not restarted by a rollout restart of the Deployments:

  • CronJobs such as occupancy-reports (module 6): their pods are created on each run, so they will pick up the new credential by themselves; but if there is a run in progress during the rotation, it will fail. Check kubectl get jobs before revoking.
  • Loose pods created by hand for debugging. You find them with kubectl get pods -n rutas-norte-pro -o json | jq '.items[] | select(.metadata.ownerReferences == null) | .metadata.name'.
  • StatefulSets (module 6): rollout restart works, but it is ordinal and slower; you have to wait for it to finish.
  • Clients outside the cluster: reporting tools, backups, dashboards. Kubernetes cannot see them and they are the ones that cause the incident when you revoke. That is why the consumer inventory from section 11 is not bureaucracy.

Conclusion

You have settled the most serious debt from module 2. The bookings-postgres password is no longer in any Git manifest: it lives in a Secret, bookings-api receives it as a file mounted in memory with only the keys it needs, and bookings-postgres takes it from secretKeyRef in order to initialise. A grep over k8s/ returns nothing.

But the important part of this lesson is not the object, it is the judgement. You know that a Secret differs from a ConfigMap in stringData, in the tmpfs mount, in describe hiding the contents and in the existence of a type field that validates the shape. And you know, because you have proved it with a single command, that base64 is not encryption: the real protection comes from RBAC, encryption at rest and namespace isolation, not from the object itself. You know the seven Secret types and what each one is for, the three ways of creating them, and why stringData is always preferable to data when writing by hand.

You know that encryption at rest in etcd is not enabled, you have seen the password readable in an etcd dump, you know the five EncryptionConfiguration providers and why kms v2 is the only genuinely solid one, and that enabling it does not retroactively encrypt what already exists. You are clear about who can read a secret — with the two surprises: list leaks contents and being able to create pods is equivalent to being able to read everything — how a credential is rotated without cutting off the service, and why environment variables never refresh. You have the list of what must never be done and a map of the real ecosystem — Sealed Secrets, SOPS, External Secrets Operator, Vault, the cloud KMS offerings — for when the native Secret falls short. And do not forget the warning from the start: with the personal data Rutas Norte holds, the final design must be validated by a security professional or the compliance officer.

The two lessons have left a common loose end. We have seen the objects that store configuration and secrets, and one of the ways of getting them into the container: the mounted file. The other one is missing, the one that shows up in 80% of real-world manifests and that both lessons have mentioned without developing. The next one, Environment Variables, covers it in full: env, envFrom with prefix, valueFrom with configMapKeyRef and secretKeyRef, the optional flag, the Downward API so that bookings-api records in its traces which pod and which node served each request, $(VAR) expansion and its rules, the collisions when the same key arrives by several routes, and the technique of the configuration hash in an annotation that finally solves the problem of environment variables never refreshing.

Kubernetes Course

Module 1: Introduction to Kubernetes

Module 2: Core Kubernetes Components

Module 3: Configuration and Secret Management

Module 4: Networking in Kubernetes

Module 5: Storage in Kubernetes

Module 6: Advanced Kubernetes Concepts

Module 7: Monitoring and Logging

Module 8: Kubernetes Security

Module 9: Scaling and Performance

Module 10: Kubernetes Ecosystem and Tooling

Module 11: Case Studies and Real-World Applications

Module 12: Preparing for Kubernetes Certification

© Copyright 2026. All rights reserved