Secure by Design – Rebuilding Images for OpenShift
Duration: ~25 minutes
Overview
101-01 showed that a public httpd image fails under OpenShift defaults, then swapped in a pre-built UBI image. This module is the rebuild: you own the Dockerfile so the image itself is OpenShift-ready.
Five focus areas, each in its own part: UBI base images, non-root, high ports, multi-stage builds, and container build best practices. Complete Part A through Part E. 101-12 deploys its own app for TLS.
Why it matters
Shipping an image that needs root pushes teams toward anyuid and other SCC exceptions. Rebuild instead: trusted UBI base, non-root, high port, a runtime image that does not ship the toolchain, and a Dockerfile that other people can review. The same image then runs on restricted clusters without special cases.
What does it solve
-
Unknown public bases → hidden CVEs and no support story
-
Root-only services → privilege-escalation path after compromise
-
Privileged port 80 → pressure to run as root
-
Compilers and package managers in the runtime image → extra CVEs and shells
-
Ad-hoc Dockerfiles → every cluster becomes an SCC exception negotiation
Your Mission
The public httpd image just crashed because it wants root and port 80 — the same privilege attackers hope you unlock with anyuid. Rebuild on UBI so the app serves under restricted SCC: non-root, high port, multi-stage, and the rest of the OpenShift-ready build checklist. Prove the UID stays non-root, and expose it without widening the blast radius.
Click each step only if you need a hint.
Create project 101-11-r-rebuild
Stay in this project for every command in this module.
oc new-project 101-11-r-rebuild || oc project 101-11-r-rebuild
oc project should print Now using project "101-11-r-rebuild".
|
Create the build directory ~/101-11-rebuild
All Dockerfiles in this lab are written here. cd here before every cat > Dockerfile.
mkdir -p ~/101-11-rebuild
cd ~/101-11-rebuild
pwd should end with 101-11-rebuild.
|
Deploy the public httpd image
This is the image you are about to replace. Admission may accept it; restricted SCC will not let it run as it did on a laptop.
oc create deployment webapp --image=httpd
oc rollout status deployment/webapp --timeout=90s || true
| The rollout will time out. That is expected — the pod will not become Available. |
Read the httpd failure logs
Look for a bind to port 80 and "Permission denied".
oc logs -l app=webapp --tail=40 --all-containers=true || true
| Expected failure resembles the following. |
AH00558: httpd: Could not reliably determine the server's fully qualified domain name, using 10.128.2.191. Set the 'ServerName' directive globally to suppress this message
(13)Permission denied: AH00072: make_sock: could not bind to address [::]:80
(13)Permission denied: AH00072: make_sock: could not bind to address 0.0.0.0:80
no listening sockets available, shutting down
AH00015: Unable to open logs
Why this image is a defender’s problem:
-
Apache httpd on port 80 (privileged; pushes teams toward root)
-
Writes under root-owned paths (fails for random UIDs)
-
Not built for OpenShift restricted SCC
Crash-loop — do not "fix" with anyuid. Rebuild instead. Parts A through E each close one of those gaps.
|
Part A: UBI base images
A public tag is an unknown builder, an unknown patch level, and often a root-friendly default. Red Hat Universal Base Images (UBI) are redistributable RHEL userspace, scanned and supported, and published on registry.access.redhat.com / registry.redhat.io. Start every Dockerfile from a UBI image that matches what you need: general userspace, minimal runtime, or a language/runtime image that is already OpenShift-ready.
| UBI image | Use when |
|---|---|
|
Full RHEL userspace ( |
|
Smaller runtime; |
|
Smallest footprint; no package manager in the image |
|
Apache already built for non-root on 8080 |
|
Language toolsets (typical builder stage) |
Write a Dockerfile FROM ubi9/httpd-24
httpd-24 is the runtime; ubi-minimal will be the builder in Part D. Both come from a registry you already trust (see 101-07).
cd ~/101-11-rebuild
cat > Dockerfile <<'EOF'
# Runtime base: supported UBI Apache, not docker.io/httpd
FROM registry.access.redhat.com/ubi9/httpd-24:latest
EOF
cat Dockerfile
The file should show one FROM line on registry.access.redhat.com. Language images (python-311, go-toolset) are UBI too — use those when you are compiling, not a random golang:latest.
|
Part B: Non-root
OpenShift restricted SCC injects a random non-root UID and sets runAsNonRoot: true. Images that assume UID 0 fail. A USER instruction documents non-root intent (and helps on clusters that do not randomize UID). On OpenShift the numeric USER is not the runtime UID — the process still gets a project-range UID that is a member of group 0. Writable paths must be group-owned by 0 with group write (chgrp -R 0 and chmod -R g=u), not hard-coded to a single UID.
Read runAsUser from the failing webapp pod
The public httpd image expected UID 0. OpenShift did not grant it.
oc get pod -l app=webapp -o jsonpath='runAsUser={.items[0].spec.containers[0].securityContext.runAsUser}{"\n"}runAsNonRoot={.items[0].spec.securityContext.runAsNonRoot}{"\n"}'
Expect a large non-zero UID (for example 1000690000) and runAsNonRoot=true. That mismatch is the guardrail, not a cluster bug.
|
Add USER 1001 to the Dockerfile
USER 1001 is the conventional non-root user in UBI application images. Combined with root-group-writable directories, it survives OpenShift’s random UID.
cd ~/101-11-rebuild
cat > Dockerfile <<'EOF'
FROM registry.access.redhat.com/ubi9/httpd-24:latest
USER 1001
EOF
cat Dockerfile
Do not chown application directories to a single UID such as 1001 and stop there. OpenShift will still run as a different UID in group 0. Prefer chgrp -R 0 <dir> && chmod -R g=u <dir> when you create writable paths. ubi9/httpd-24 already does this for /var/www and its log/run dirs.
|
Part C: High ports
Ports 0-1023 are privileged. Only root can bind them. A non-root process that listens on 80 will fail — that is the log you already saw. Listen on 8080 (or another port >= 1024). The OpenShift Route can still present 80/443 outside the cluster; the container never needs the privileged bind.
Grep the logs for the port 80 bind failure
oc create deployment does not set containerPort. The proof is the log line bind to address 0.0.0.0:80, not a port in the pod spec.
oc logs -l app=webapp --tail=50 --all-containers=true | grep -E 'bind to address|:80|Permission denied' || true
You should see Permission denied and :80.
|
Add EXPOSE 8080 to the Dockerfile
EXPOSE 8080 documents the contract for humans, oc expose, and image metadata. ubi9/httpd-24 already listens on 8080; declaring it makes the rebuild explicit for any base you use later.
cd ~/101-11-rebuild
cat > Dockerfile <<'EOF'
FROM registry.access.redhat.com/ubi9/httpd-24:latest
USER 1001
EXPOSE 8080
EOF
cat Dockerfile
| External users still reach the app on 80/443 through a Route. High ports are the container listen address, not the public URL. |
Part D: Multi-stage builds
A single-stage image that dnf install gcc, compiles, and then ships the compiler is a larger CVE surface and a nicer shell for whoever lands in the container. Multi-stage builds use one FROM to build artifacts and a second FROM as the runtime. Only the last stage is the image you deploy; earlier stages are discarded.
Write index.html in ~/101-11-rebuild
This is the site file the builder stage will copy. In a real app this stage would compile, pip install, or npm run build.
cd ~/101-11-rebuild
cat > index.html <<'EOF'
<html><body><h1>Secure UBI httpd App</h1></body></html>
EOF
ls index.html should succeed.
|
Write a two-stage Dockerfile
The builder copies index.html and emits the file the runtime will serve. The runtime stage is UBI httpd only — no microdnf, no compiler.
cat > Dockerfile <<'EOF'
# Builder: assemble the site. This stage never ships to the cluster.
FROM registry.access.redhat.com/ubi9/ubi-minimal:latest AS builder
USER 0
WORKDIR /src
COPY index.html /src/index.html
RUN mkdir -p /out && cp /src/index.html /out/index.html
# Runtime: UBI httpd only -- non-root, high port, no build toolchain
FROM registry.access.redhat.com/ubi9/httpd-24:latest
COPY --from=builder /out/index.html /var/www/html/index.html
USER 1001
EXPOSE 8080
EOF
cat Dockerfile
Name the first stage (AS builder) so later stages can COPY --from=builder. Anything you RUN in the builder — compilers, npm, go build — stays out of the running container.
|
Part E: Container build best practices
Parts A-D are the controls that make the image OpenShift-ready. This part is the rest of the checklist, then you build once on the cluster (no laptop root shortcuts) and prove restricted SCC still holds.
| Practice | Why it matters |
|---|---|
UBI (or equivalent maintained) base |
Known provenance; see Part A |
Non-root |
Survives random UID; see Part B |
Listen on >= 1024 |
No privileged bind; see Part C |
Multi-stage |
Runtime does not ship gcc/ |
Do not COPY secrets |
|
One process per container |
Clearer signals, smaller blast radius |
Clean package caches if you |
Smaller image, fewer leftover tools |
Labels for name and exposed port |
Humans and automation can see the contract |
Prefer digest pins in production |
101-07; tags move |
Write the production Dockerfile with labels
Add labels. Do not copy credentials. Recreate index.html so the build context is complete even if you skipped Part D.
cd ~/101-11-rebuild
cat > index.html <<'EOF'
<html><body><h1>Secure UBI httpd App</h1></body></html>
EOF
cat > Dockerfile <<'EOF'
# Builder: assemble the site. This stage never ships to the cluster.
FROM registry.access.redhat.com/ubi9/ubi-minimal:latest AS builder
USER 0
WORKDIR /src
COPY index.html /src/index.html
RUN mkdir -p /out && cp /src/index.html /out/index.html
# Runtime: UBI httpd only -- non-root, high port, no build toolchain
FROM registry.access.redhat.com/ubi9/httpd-24:latest
COPY --from=builder /out/index.html /var/www/html/index.html
# OpenShift assigns a random UID in group 0. USER documents non-root
# intent; do not assume 1001 is the runtime UID.
USER 1001
EXPOSE 8080
LABEL name="webapp" \
summary="UBI httpd rebuilt for OpenShift restricted SCC" \
io.openshift.expose-services="8080:http"
# Do not COPY .env, SSH keys, or cloud credentials into any stage.
EOF
grep LABEL Dockerfile should show name="webapp".
|
Create a Docker-strategy binary BuildConfig named webapp
Skip creation if the BuildConfig already exists from a retry.
cd ~/101-11-rebuild
oc get bc webapp >/dev/null 2>&1 || oc new-build --name webapp --binary --strategy=docker
oc get bc webapp should list a Docker-strategy build.
|
Start the binary build from ~/101-11-rebuild
Stream the Dockerfile context into OpenShift. Wait until the build logs finish.
cd ~/101-11-rebuild
oc start-build webapp --from-dir=. --follow
The build should end with Push successful. If it fails with "no such file", cd ~/101-11-rebuild and retry.
|
Confirm ImageStream webapp has a tag
oc get is webapp
You should see a latest tag (or a new digest) on ImageStream webapp.
|
Point deployment/webapp at the ImageStream you built
Replace the public httpd image with the in-cluster ImageStream.
oc set image deployment/webapp *=image-registry.openshift-image-registry.svc:5000/101-11-r-rebuild/webapp:latest
oc rollout status deployment/webapp --timeout=120s
| Rollout should complete successfully this time. |
Read the new webapp logs
oc logs -l app=webapp --tail=20 --all-containers=true || true
| You should not see a bind failure on port 80. |
Prove the process UID is not 0
oc exec deploy/webapp -- id -u
| The number is non-zero (NOT 0). |
Confirm the pod SCC is restricted-v2
oc get pod -l app=webapp -o jsonpath='{.items[0].metadata.annotations.openshift\.io/scc}{"\n"}'
Output is restricted-v2. That SCC denies UID 0, privilege escalation, hostPID/IPC/Network, extra capabilities, and uses default seccomp plus SELinux MCS isolation.
|
Expose Service and Route on port 8080
The container listens on 8080. The Route is the public door — still no anyuid.
oc expose deployment webapp --port=8080 --target-port=8080
oc expose service webapp
ROUTE=$(oc get route webapp -o jsonpath='{.spec.host}')
curl -s http://$ROUTE | head -3
curl should print the Secure UBI httpd App heading. 101-12 uses its own project (101-12-tls); you can clean this project up when you are done.
|
Debrief
You rebuilt for restricted SCC across five focus areas: UBI base, non-root, high port, multi-stage runtime, and the remaining Dockerfile hygiene — then deployed without negotiating anyuid.
What breaks without this:
-
USER root/ port 80 / fixed ownership → every cluster becomes an SCC exception negotiation -
Unknown bases → hidden CVEs and drift from what you tested
-
Build tools in the runtime image → extra CVEs and a working compiler after compromise
| UBI (or maintained) bases, non-root plus group-0-writable dirs, high ports, multi-stage, no secrets in the image, RHACS root policies, and Dockerfile history in GitOps. |
fix the image once across environments; anyuid is long-term debt. Smaller runtime images usually mean fewer CVEs.
|
Cleanup
Before moving to the next module, run the lab cleanup script to reset transient resources from this module.
| 101-12 does not need this project. You can run cleanup now. |
cd ~/ocp5-rhacs-showroom
bash setup/lab-cleanup.sh --module 101-11
