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:

  • 101-08 — encrypt Secrets at rest in etcd so a stolen volume is not plaintext

  • 201-05 — HashiCorp Vault and External Secrets Operator, so the source of truth is off the cluster and pods do not carry static copies in Git

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.
giphy

Cleanup

Before moving to the next module, run the lab cleanup script to reset transient resources from this module.

cd ~/ocp5-rhacs-showroom
bash setup/lab-cleanup.sh --module 101-06