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-postgresstores 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
- The module 2 debt and why it hurts
- What a Secret is and how it differs from a ConfigMap
- Base64 is not encryption: the demonstration
- The Secret types
- Creating Secrets: imperative and declarative with
stringData - Consuming a Secret as a volume
- The
bookings-postgres-credentialssecret in Rutas Norte imagePullSecretsfor the private registry- Encryption at rest in etcd
- Who can read a secret
- Credential rotation
- What you must never do
- The real solutions in the ecosystem
- 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:
- 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. - 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.
- There is no traceability. There is no way to know who has read that password, or when.
- 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.
- 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: passwordThe manifest can be published without leaking anything. That is the twelve-factor bar we saw in the previous lesson.
- 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.
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 bytesOnly 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.
- 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-devWe look at it in 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: OpaqueAt 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; echoThree 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}}'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:
- 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.
- 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.
- 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:
Opaqueis the one we will use forbookings-postgres-credentials, for the payment gateway key and for the mail provider token ofnotifications-worker. It is the default type if you specify nothing.kubernetes.io/dockerconfigjsonwill be the one that lets us pull images fromregistry.rutasnorte.example(section 8).kubernetes.io/tlswill be the one holding thewww.rutasnorte.examplecertificate. 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-devThe 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.
- Creating Secrets: imperative and declarative with
stringData
stringData5.1. kubectl create secret generic
kubectl create secret generic bookings-postgres-credentials \
--from-literal=username=rutasnorte \
--from-literal=password='B00k1ngs2026!' \
-n rutas-norte-devWatch 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/pwThe 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-pro5.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-pro5.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: bookingsAdvantages of stringData over data:
- You write it and read it without encoding or decoding anything.
- It removes the mistake of encoding with
echowithout-n, which puts a\ninto the value. - A
diffin 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:
- 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.
- In Git, encrypted. Sealed Secrets or SOPS let you version a file that only the cluster can decrypt (section 13).
- 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:
Plus a secret-postgres.yaml.example with dummy values, versioned, so the team knows which keys are needed.
- 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: passwordInside 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 secretstotal 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
..dataas with ConfigMaps, with the same consequence: if the Secret changes, the mounted file is updated (with the same kubelet delay, and with the samesubPathexception, 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.
- The
bookings-postgres-credentials secret in Rutas Norte
bookings-postgres-credentials secret in Rutas NorteLet 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/pgdataAnd 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: usernameNote 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; echogrep finds nothing in the repository and the application has its credential. That is the goal.
imagePullSecrets for the private registry
imagePullSecrets for the private registryThe Rutas Norte images live in registry.rutasnorte.example, which is private. Without credentials, the pod fails like this:
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: ImagePullBackOffImagePullBackOff 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-proWhat 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.0Three important details:
imagePullSecretsgoes in the pod'sspec, not insidecontainers. It applies to every image in the pod, including init containers.- You can list several, one per registry. The kubelet picks the one matching the image's server.
- 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.
- 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 -800000000 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 dataAnd 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:
- 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
identitymust go last during the migration: it lets you read the Secrets that already existed unencrypted. - 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:
- The key in that file is the crown jewel. If you use
aescbcorsecretbox, 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 whykmsv2 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. - 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.
- 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
- 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-prosecret/bookings-postgres-credentials configured
deployment.apps/bookings-api restarted
deployment.apps/notifications-worker restarted
deployment "bookings-api" successfully rolled outA 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 manualrollout 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.
- 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!"]Anyone with read permission on pods — a permission granted to a lot of people, monitoring systems included — has just read the mail password.
- 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: passwordThe 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:
- Today (where we are): native Secrets created by hand, outside Git, with
.gitignoreand an example file. Acceptable indev. - Next step: encryption at rest with a KMS in the cluster and strict RBAC on secrets in
rutas-norte-pro. - 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:
- 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.
- Install a credential detector in your
pre-commit.gitleaksordetect-secretscost ten minutes of configuration and prevent 90% of accidents. - Keep an inventory of secrets. What exists, who consumes it, when it was last rotated, who owns it.
- Document the keys, never the values. A
secret-postgres.yaml.examplewithpassword: CHANGEMEunder version control is useful and safe. - 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
- Create a
payment-gatewaySecret inrutas-norte-devwith the keysapi_key(valuesk_test_4eC39HqLyjWDarjtT1zdp7dc) andenvironment(valuesandbox). - Show the object in YAML and check that the value cannot be read.
- Decode
api_keywith a single chained command. - Check what
kubectl describeshows for that Secret and explain why that is not a security measure. - Answer: if a colleague has the
listverb on secrets in that namespace but notget, can they read the key? Prove it withkubectl auth can-i.
Exercise 2: Settle the bookings-postgres debt
- Write the manifest for the
bookings-postgres-credentialsSecret forrutas-norte-devusingstringData, withusername,passwordanddatabase, and with the Rutas Norte labelling scheme. - Modify the
bookings-postgresDeployment so that it takes the three variables from the Secret. - Modify
bookings-apiso that it mounts onlyusernameandpasswordas files in/etc/secrets/postgreswith read-only permissions. - Verify that the volume is on tmpfs.
- Prove with
grepthat no plain-text password is left ink8s/, 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.
- Write out the full sequence of steps, indicating which of them carries a risk of an outage and how you avoid it.
- Run the Secret update with a single command that works whether or not the Secret already exists.
- Make the pods adopt the new credential without losing service at any point.
- Verify that the 4 new pods are up and that none of them still holds the previous credential.
- Identify which consumers of the Secret are not restarted by
kubectl rollout restartand 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 yamlsecret/payment-gateway created
apiVersion: v1
data:
api_key: c2tfdGVzdF80ZUMzOUhxTHlqV0Rhcmp0VDF6ZHA3ZGM=
environment: c2FuZGJveA==
kind: Secret
metadata:
name: payment-gateway
namespace: rutas-norte-dev
type: OpaqueThe decoding:
kubectl get secret payment-gateway -n rutas-norte-dev \
-o jsonpath='{.data.api_key}' | base64 -d; echoName: payment-gateway
Namespace: rutas-norte-dev
Type: Opaque
Data
====
api_key: 32 bytes
environment: 7 bytesdescribe 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_keyYes, 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: bookingsThe 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: passwordVerifying 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'Two files, not three: database has not been projected because it is not in items. And the volume is tmpfs, that is, RAM.
No results. The .gitignore:
# Secrets: never in the repository
k8s/**/secret-*.yaml
k8s/**/*-secret.yaml
!k8s/**/*.yaml.example
*.key
*.pem
*.p12The !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:
- 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. - Update the Secret. No risk: the live pods already have the credential loaded in memory.
rollout restart. No risk ifmaxUnavailableis set properly: module 2 guarantees progressive replacement.- 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 -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-prodeployment.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 21sAll four pods are new (same pod-template-hash, AGE under a minute and RESTARTS 0). Verifying the effective credential:
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. Checkkubectl get jobsbefore 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 restartworks, 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
- What Is Kubernetes?
- Kubernetes Architecture
- Key Concepts and Terminology
- Setting Up a Kubernetes Cluster
- The Kubernetes CLI: kubectl
- Objects, YAML Manifests and the Declarative Model
- The Course Project: the Rutas Norte Platform
Module 2: Core Kubernetes Components
- Pods
- ReplicaSets
- Deployments
- Updates, Rollbacks and Deployment Strategies
- Services
- Namespaces
- Labels, Selectors and Annotations
Module 3: Configuration and Secret Management
- ConfigMaps
- Secrets
- Environment Variables
- Resource Quotas and Limits
- LimitRanges and Quality of Service (QoS) Classes
- ServiceAccounts and API Access from Pods
Module 4: Networking in Kubernetes
- Cluster Networking
- Service Types
- Internal DNS and Service Discovery
- Ingress Controllers
- TLS and Certificate Management with cert-manager
- Network Policies
Module 5: Storage in Kubernetes
- Volumes
- Persistent Volumes
- Persistent Volume Claims
- Storage Classes
- Dynamic Provisioning, Expansion and Snapshots
- Backup and Restore of Persistent Data
Module 6: Advanced Kubernetes Concepts
- StatefulSets
- DaemonSets
- Jobs and CronJobs
- Init Containers, Sidecars and Multi-Container Patterns
- Scheduling: Affinity, Taints and Tolerations
- Custom Resource Definitions (CRDs)
- Operators and the Controller Pattern
Module 7: Monitoring and Logging
- Health Checks and Probes
- Metrics Server and kubectl top
- Monitoring with Prometheus
- Visualization and Alerting with Grafana and Alertmanager
- Centralized Logging with Elasticsearch, Fluentd and Kibana (EFK)
- Application Debugging and Cluster Events
Module 8: Kubernetes Security
- Role-Based Access Control (RBAC)
- Security Contexts and Container Hardening
- Pod Security Policies and Pod Security Standards
- Network Security
- Image Security
- Auditing, Scanning and Vulnerability Management
Module 9: Scaling and Performance
- Horizontal Pod Autoscaling
- Vertical Pod Autoscaling
- Cluster Autoscaling
- Event-Driven and Custom-Metric Scaling with KEDA
- High Availability: PodDisruptionBudgets and Topology
- Performance Tuning
Module 10: Kubernetes Ecosystem and Tooling
- Minikube and Local Environments with kind
- Kubeadm
- Helm
- Kustomize
- GitOps with Argo CD and Flux
- Managed Kubernetes: EKS, AKS and GKE
Module 11: Case Studies and Real-World Applications
- Deploying a Web Application
- Running Stateful Applications
- CI/CD with Kubernetes
- Deployment Strategies: Blue-Green and Canary
- Multi-Cluster Management
- Production Operations: Incidents, Runbooks and Costs
