Don’t Hardcode – Managing Secrets the Right Way
Duration: ~20 minutes
Overview
Keep passwords out of Git and YAML. Store them as Secrets, mount or inject them at runtime, and rotate without rewriting every deployment.
Replace a hardcoded credential with a Secret injected as an env var and as a file mount, then rotate it and compare how each injection updates.
Why it matters
Credentials in manifests or images land in Git history and travel with every clone. Secrets keep the values out of source, limit who can read them with RBAC, and make rotation a Secret update instead of a code change. File mounts refresh on update; env vars usually need a restart.
What does it solve
-
Git history leakage
-
Rotation friction
-
Broad manifest visibility
-
Credential reuse temptation
Your Mission
Credential hunters love passwords parked in Git and Deployment YAML—they survive forever in history. Catch the insecure pattern, put the value in a Secret, then compare two injection styles. Complete Part A, then Part B.
This module is the OpenShift baseline. Later labs cover stronger stores (Vault, External Secrets Operator) and encryption at rest.
Click each step only if you need a hint.
Create project 101-06-s-secrets
Stay in this project for Part A and Part B.
oc new-project 101-06-s-secrets || oc project 101-06-s-secrets
Now using project "101-06-s-secrets".
|
Part A: Secret as an environment variable
Show the leak, move the password into a Secret, and inject it as env so the Deployment YAML no longer carries plaintext. Env injection is simple; rotation later will need a restart.
Deploy insecure-app with DB_PASSWORD hardcoded
Show how easy loot is when passwords live in env literals.
oc create deployment insecure-app --image=registry.access.redhat.com/ubi9/ubi -- sleep infinity
oc set env deployment/insecure-app DB_PASSWORD=SuperSecret123
oc get deploy insecure-app -o yaml | grep -n 'SuperSecret'
| The grep should print the password in the Deployment YAML. |
Delete deployment insecure-app
Remove the leaky workload before you inject the Secret the right way.
oc delete deployment insecure-app
deployment.apps "insecure-app" deleted.
|
Create Secret db-credentials
The password now lives in a Secret object, not in the Deployment YAML.
oc create secret generic db-credentials --from-literal=DB_PASSWORD=SuperSecret123
secret/db-credentials created.
|
Create ConfigMap secret-reader
A tiny HTTP helper so you can read the value the process sees with curl on a Route. That path does not use oc exec (kubelet port 10250), which fails with tls: internal error when the node serving certificate is unhealthy.
The script prints the file at /opt/creds/DB_PASSWORD when that mount exists; otherwise it prints the DB_PASSWORD environment variable.
oc apply -f - <<'EOF'
apiVersion: v1
kind: ConfigMap
metadata:
name: secret-reader
data:
serve.py: |
import os
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
cred = Path("/opt/creds/DB_PASSWORD")
if cred.is_file():
body = cred.read_text().strip()
else:
body = os.environ.get("DB_PASSWORD", "missing")
data = (body + "\n").encode()
self.send_response(200)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def log_message(self, format, *args):
return
HTTPServer(("", 8080), Handler).serve_forever()
EOF
configmap/secret-reader created.
|
Deploy secure-app-env and inject Secret as env
Wait for the rollout before you curl. The Deployment should reference the Secret by name, not the password string.
oc apply -f - <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: secure-app-env
spec:
replicas: 1
selector:
matchLabels:
app: secure-app-env
template:
metadata:
labels:
app: secure-app-env
spec:
containers:
- name: app
image: registry.access.redhat.com/ubi9/python-311
command: ["python3", "-B", "/opt/reader/serve.py"]
ports:
- containerPort: 8080
readinessProbe:
httpGet:
path: /
port: 8080
volumeMounts:
- name: reader
mountPath: /opt/reader
readOnly: true
volumes:
- name: reader
configMap:
name: secret-reader
EOF
oc set env deployment/secure-app-env --from=secret/db-credentials
oc expose deployment secure-app-env --port=8080 --target-port=8080
oc expose service/secure-app-env
oc rollout status deployment/secure-app-env --timeout=90s
Rollout successful. oc get route secure-app-env should show a hostname.
|
Curl the env app and grep the Deployment for plaintext
The process should see the password. The Deployment YAML should not.
curl -sS "http://$(oc get route secure-app-env -o jsonpath='{.spec.host}')"
oc get deploy secure-app-env -o yaml | grep -n 'SuperSecret' || echo 'No plaintext password in the Deployment -- expected.'
curl prints SuperSecret123. grep should print the "No plaintext" line.
|
Part B: Secret as a file, then rotate
Mount the same Secret as a file. Then rotate the value and compare: the file path can pick up the new secret without a restart; the env path from Part A does not until you roll the Deployment.
Leave the Part A Deployment in place—you need both workloads for the rotation check. Do not delete the project until the module cleanup at the end.
Deploy secure-app-file and mount Secret db-credentials
Mount path /opt/creds. Leave secure-app-env running; you need both for the rotation check. The helper prefers the file when that mount exists.
oc apply -f - <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: secure-app-file
spec:
replicas: 1
selector:
matchLabels:
app: secure-app-file
template:
metadata:
labels:
app: secure-app-file
spec:
containers:
- name: app
image: registry.access.redhat.com/ubi9/python-311
command: ["python3", "-B", "/opt/reader/serve.py"]
ports:
- containerPort: 8080
readinessProbe:
httpGet:
path: /
port: 8080
volumeMounts:
- name: reader
mountPath: /opt/reader
readOnly: true
volumes:
- name: reader
configMap:
name: secret-reader
EOF
oc set volume deployment/secure-app-file --add --name=creds --type=secret --secret-name=db-credentials --mount-path=/opt/creds --read-only
oc expose deployment secure-app-file --port=8080 --target-port=8080
oc expose service/secure-app-file
oc rollout status deployment/secure-app-file --timeout=90s
Rollout successful. oc get route secure-app-file should show a hostname.
|
Curl the file app
curl -sS "http://$(oc get route secure-app-file -o jsonpath='{.spec.host}')"
curl prints SuperSecret123.
|
Apply a new value to Secret db-credentials
After compromise suspicion, rotation must stick. File mounts refresh in the kubelet sync window (often under a minute). Env vars keep the old value until the pod restarts.
oc create secret generic db-credentials --from-literal=DB_PASSWORD=NewValue456 -o yaml --dry-run=client | oc apply -f -
secret/db-credentials configured.
|
Wait 20s then compare file mount vs env
The file app should show NewValue456 while the env app still shows SuperSecret123.
sleep 20
echo -n 'file: '
curl -sS "http://$(oc get route secure-app-file -o jsonpath='{.spec.host}')"
echo -n 'env: '
curl -sS "http://$(oc get route secure-app-env -o jsonpath='{.spec.host}')"
| File = new value. Env = old value. |
Rollout restart secure-app-env and re-check env
Env vars pick up the new Secret only after a restart.
oc rollout restart deployment/secure-app-env
oc rollout status deployment/secure-app-env --timeout=90s
curl -sS "http://$(oc get route secure-app-env -o jsonpath='{.spec.host}')"
Env now matches NewValue456.
|
Debrief
You moved a hardcoded credential into a Kubernetes Secret, consumed it as env (Part A) and as a file mount (Part B), and compared rotation: files can refresh in place; env needs a rollout. You read the live value over a Route, the same way a client would, without oc exec.
Kubernetes Secrets are the platform baseline, not the end of the story. Values are base64-encoded, not encrypted, unless etcd encryption or an external store is on. Secrets alone do not stop exfiltration—pair them with least-privilege RBAC.
Better ways to manage secrets in later modules:
What breaks without this:
-
Credentials in Git, Dockerfiles, or Deployment YAML → trivial loot after a foothold
-
Env-only injection → rotation often needs a restart
-
Stopping at Kubernetes Secrets with no RBAC, etcd encryption, or external backend → broad read and disk exposure
no plaintext in manifests, prefer file mounts when you need in-place rotation, restrict get/list on Secrets, then move to encryption at rest and an external store as you mature.
|
