Take an app to production on OpenShift: S2I build, TLS Route, storage, autoscaling and network policy
Build a small Python app from Git with Source-to-Image on OpenShift or OKD, then add an HTTPS Route, config and secrets, storage, probes, limits, autoscaling, a network policy, rollbacks and cleanup.
Not checked by a trusted editor yet Last edited Outdated?
Getting an app to run on OpenShift takes one command. Getting it to run the way it should in production takes a dozen more: TLS, configuration outside the image, storage that survives restarts, health checks, resource limits, scaling and network isolation. This tutorial adds all of them, one at a time, to a tiny Python web app, using only the oc client.
It is for developers and administrators who have done a first deployment on OpenShift or OKD and want to see what a production-style setup looks like end to end. Everything works the same on Red Hat OpenShift and on OKD, its community distribution.
What you will build
- A project called
notes-prodwith a Python app built from your Git repository by Source-to-Image (S2I). - An HTTPS Route with edge TLS termination that redirects plain HTTP.
- Settings in a ConfigMap, a token in a Secret, and a visit counter stored on a PersistentVolumeClaim.
- Readiness and liveness probes, CPU and memory requests and limits, and a HorizontalPodAutoscaler that keeps 2 to 5 replicas.
- A new build rolled out from Git, a bad change rolled back, and NetworkPolicies that only let the router and the project’s own Pods in.
Before you start
- Access to an OpenShift 4 or OKD cluster with permission to create a project. A shared development cluster or a local single-node cluster works.
- The
occlient matching your cluster version. In the web console, the question-mark menu has a Command line tools page with downloads. Check withoc version. - A public Git repository you can push to (GitHub, GitLab or similar) and
giton your computer. Python 3 locally is optional, for testing the app before pushing. - A storage class that supports
ReadWriteManyvolumes for the full autoscaling demo; Step 6 explains what to do if you only haveReadWriteOnce. - Time: about 90 minutes, including build time.
- Knowledge assumed: Kubernetes Deployments, Services and ConfigMaps, and logging in with
oc. Helpful background: Source-to-Image, OpenShift Routes and TLS termination types, and Security Context Constraints (SCC).
Step 1: Put the app in Git
The Python S2I builder image runs app.py by default if the repository has one, so a single file is enough. This app answers on port 8080, reads a greeting from an environment variable, reports whether a token is set without printing it, counts visits in a file under DATA_DIR, and offers /healthz for probes. It uses only the Python standard library, so no requirements.txt is needed. Save it as app.py in a new repository:
import os
from http.server import BaseHTTPRequestHandler, HTTPServer
DATA_DIR = os.environ.get("DATA_DIR", "/tmp")
COUNTER = os.path.join(DATA_DIR, "visits.txt")
def bump():
try:
with open(COUNTER) as f:
count = int(f.read() or 0)
except FileNotFoundError:
count = 0
count += 1
with open(COUNTER, "w") as f:
f.write(str(count))
return count
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/healthz":
body = "ok\n"
else:
greeting = os.environ.get("GREETING", "Hello")
token = "set" if os.environ.get("API_TOKEN") else "missing"
body = f"{greeting}! Visits: {bump()}. API token: {token}.\n"
data = body.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)
if __name__ == "__main__":
port = int(os.environ.get("PORT", "8080"))
print(f"Listening on port {port}", flush=True)
HTTPServer(("0.0.0.0", port), Handler).serve_forever()
Optionally test it locally, then push it:
python3 app.py
curl http://localhost:8080/
curl http://localhost:8080/healthz
git add app.py
git commit -m "Add notes app"
git push
Locally you should see Hello! Visits: 1. API token: missing. and ok. Stop the app with Ctrl+C. The example repository URL below is https://github.com/YOUR-USER/notes-app.git; replace it with yours.
Step 2: Log in and create a project
A project is an OpenShift namespace with extra metadata. Everything in this tutorial lives in one project, which makes it easy to inspect and to delete at the end.
oc login https://api.cluster.example.com:6443 -u developer
oc new-project notes-prod --display-name="Notes (production-style demo)"
oc project
oc project should print Using project "notes-prod" on the cluster you expect.
Step 3: Build and deploy from Git with S2I
oc new-app with the builder~repository syntax creates a BuildConfig, ImageStreams for the builder and the output, a Deployment and a Service. S2I clones your repository into the builder image and produces a runnable image in the cluster’s internal registry. Current oc creates a Kubernetes Deployment; older DeploymentConfigs are only created with --as-deployment-config.
oc new-app registry.access.redhat.com/ubi9/python-312~https://github.com/YOUR-USER/notes-app.git --name=notes
oc logs -f bc/notes
oc rollout status deployment/notes
oc status
The build log ends with the image being pushed. Then a Pod named notes-... becomes 1/1 ready, and oc status shows the chain from the Service to the Deployment to the build. If you look at oc logs deployment/notes, you should see Listening on port 8080.
Step 4: Publish it with an edge TLS Route
Services are internal. A Route publishes a Service on a host name through the cluster’s router. With edge termination, the router handles TLS and forwards plain HTTP to the Pod, which suits an app like this that does not speak TLS itself. --insecure-policy=Redirect sends http:// visitors to HTTPS.
oc create route edge notes --service=notes --insecure-policy=Redirect
HOST=$(oc get route notes -o jsonpath='{.spec.host}')
curl -s "https://$HOST/"
curl -sI "http://$HOST/"
The first curl prints the greeting with a visit count. The second shows a redirect status with a Location header pointing at HTTPS. Without a certificate of your own, the router uses its default certificate for the cluster’s application domain; if that is self-signed in your lab, add -k to curl.
For a real host name, create the route with your own certificate and key in PEM files instead. The certificate must be valid for the host name:
oc create route edge notes --service=notes --cert=tls.crt --key=tls.key --ca-cert=ca.crt --hostname=notes.apps.example.com --insecure-policy=Redirect
Step 5: Add configuration and a secret
Put settings in a ConfigMap and sensitive values in a Secret, then inject both into the Deployment as environment variables. Each change to the Deployment’s Pod template starts a new rollout.
oc create configmap notes-config --from-literal=GREETING="Hello from OpenShift"
oc create secret generic notes-secret --from-literal=API_TOKEN=CHANGE_ME
oc set env deployment/notes --from=configmap/notes-config
oc set env deployment/notes --from=secret/notes-secret
oc rollout status deployment/notes
curl -s "https://$HOST/"
The answer now starts with Hello from OpenShift! and ends with API token: set. Environment variables are read when a container starts, so after changing the ConfigMap or Secret later, run oc rollout restart deployment/notes.
Step 6: Add persistent storage
The visit counter is written to /tmp inside the container, so it is lost whenever a Pod is replaced. A PersistentVolumeClaim requests storage from the cluster that outlives Pods. Because the autoscaler will run several Pods on different nodes, they all need to mount the same volume, which requires the ReadWriteMany access mode. Check which storage classes your cluster offers:
oc get storageclass
Save the claim as pvc.yaml. If the default storage class does not support ReadWriteMany, add storageClassName: with one that does (often a file-based class such as CephFS or NFS; ask your cluster administrator).
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: notes-data
spec:
accessModes:
- ReadWriteMany
resources:
requests:
storage: 1Gi
oc apply -f pvc.yaml
oc get pvc notes-data
oc set volume deployment/notes --add --name=data --type=persistentVolumeClaim --claim-name=notes-data --mount-path=/data
oc set env deployment/notes DATA_DIR=/data
oc rollout status deployment/notes
The claim should become Bound; with some storage classes it stays Pending until the first Pod uses it, which is normal. Call the route a few times, then replace the Pods and call it again:
curl -s "https://$HOST/"
oc rollout restart deployment/notes
oc rollout status deployment/notes
curl -s "https://$HOST/"
The visit count continues instead of starting at 1. If only ReadWriteOnce storage is available, use that access mode and keep the app at one replica: skip the autoscaler in Step 9, because Pods on other nodes cannot mount the volume. In a real app, shared state belongs in a database, not a file.
Step 7: Add health probes
A readiness probe keeps a Pod out of the Service, and so out of the Route, until it answers. A liveness probe restarts a container that stops answering. Both call /healthz on port 8080.
oc set probe deployment/notes --readiness --get-url=http://:8080/healthz
oc set probe deployment/notes --liveness --get-url=http://:8080/healthz --initial-delay-seconds=10
oc rollout status deployment/notes
oc describe deployment notes | grep -E 'Liveness|Readiness'
The describe output shows both probes with the URL and timing. A host left empty in --get-url means the Pod’s own address.
Step 8: Set resource requests and limits
Requests tell the scheduler how much CPU and memory to reserve and are the baseline the autoscaler uses for CPU percentages. A memory limit stops a leaking process from taking memory from other workloads; the container is restarted if it goes over. Your project may also have a quota or default limits set by the administrator.
oc set resources deployment/notes --requests=cpu=50m,memory=64Mi --limits=memory=256Mi
oc rollout status deployment/notes
oc get deployment notes -o jsonpath='{.spec.template.spec.containers[0].resources}{"\n"}'
oc describe quota
The jsonpath output shows the requests and limits. oc describe quota shows project quotas, if there are any; make sure the app fits within them.
Step 9: Scale automatically with a HorizontalPodAutoscaler
A HorizontalPodAutoscaler (HPA) adjusts the number of replicas based on measured load. Here it keeps between 2 and 5 Pods and adds Pods when average CPU use goes above 75% of the requested CPU. It only works when the containers have CPU requests, which you set in Step 8.
oc autoscale deployment/notes --min=2 --max=5 --cpu-percent=75
oc get hpa notes
oc get pods
The HPA raises the Deployment to at least 2 replicas. The TARGETS column shows <unknown> for a minute or two until metrics arrive, then a percentage. To see it react, generate traffic in another terminal with a simple loop such as while true; do curl -s "https://$HOST/" > /dev/null; done and watch oc get hpa notes -w. This tiny app may need a lot of traffic to pass 75%, so do not worry if it stays at 2. Stop the loop with Ctrl+C.
Step 10: Roll out a new version and roll back a bad change
Ship a code change: edit the response text in app.py (for example change Visits to Page views), commit and push, then start a new build. When the build pushes a new image to the notes image stream, the Deployment’s image trigger, set up by oc new-app, rolls it out.
git commit -am "Rename counter"
git push
oc start-build notes --follow
oc rollout status deployment/notes
curl -s "https://$HOST/"
Now make a bad configuration change on purpose and undo it. Setting GREETING directly replaces the value that came from the ConfigMap:
oc set env deployment/notes GREETING="Oops"
oc rollout status deployment/notes
curl -s "https://$HOST/"
oc rollout history deployment/notes
oc rollout undo deployment/notes
oc rollout status deployment/notes
curl -s "https://$HOST/"
After the undo, the greeting comes from the ConfigMap again. oc rollout undo returns the Pod template to the previous revision; add --to-revision=N to pick one from the history.
Rolling back an image works differently for apps built in the cluster, because the image trigger follows the notes:latest image stream tag. To go back to an older build, point the tag at that build’s image digest, which you find with oc describe istag notes:latest or oc get istag: oc tag notes@sha256:DIGEST notes:latest.
Step 11: Restrict traffic with network policies
By default, any Pod in the cluster can connect to your Pods. Once a NetworkPolicy selects a Pod, only the connections that some policy allows are accepted. These two policies, based on the examples in the OKD documentation, allow traffic from the cluster’s router (so the Route keeps working) and from Pods in the same project, and block everything else coming in. Save them as netpol.yaml:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-from-router
spec:
podSelector: {}
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
policy-group.network.openshift.io/ingress: ""
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-same-namespace
spec:
podSelector: {}
policyTypes:
- Ingress
ingress:
- from:
- podSelector: {}
oc apply -f netpol.yaml
oc get networkpolicy
curl -s "https://$HOST/"
The Route still answers. The policy-group.network.openshift.io/ingress label that selects the router’s namespace is supported with the OVN-Kubernetes network plugin, the default in current releases. To see the block working, start a temporary Pod in another project you own and try to reach notes.notes-prod.svc:8080; the connection should time out.
Check that it works
oc get deployment,hpa,route,pvc,networkpolicy
oc get pods -o wide
curl -s "https://$HOST/"
curl -s "https://$HOST/healthz"
oc get events --sort-by=.lastTimestamp | tail -20
- The Deployment shows at least 2 ready replicas and the HPA shows a CPU percentage against a 75% target.
- The Route answers over HTTPS with your greeting,
API token: setand a counter that keeps increasing across Pod restarts. - The PVC is
Boundand both network policies are listed. - The recent events show no repeated probe failures or
BackOffwarnings.
If something goes wrong
- The build fails. Read
oc logs bc/notesoroc logs build/notes-1. Common causes are a wrong repository URL, a private repository without a source secret, or a builder image that cannot be pulled from the cluster. - The Route returns the router’s HTTP 503 error page. No ready Pod is behind the Service. Check
oc get podsand the probes inoc describe pod; a probe on the wrong path or port keeps Pods unready. - Pods crash with permission errors when writing to
/data. OpenShift runs containers with a random user ID. Most block storage handles this through the Pod’s file system group, but some file storage does not. See the entry on random UIDs and SCCs, or ask your administrator for a storage class that supports it. - The PVC stays
Pendingand Pods do not start. Runoc describe pvc notes-data. Usually no storage class supports the requested access mode, or there is no default storage class. - The HPA shows
<unknown>for a long time. Check that the Deployment has CPU requests and readoc describe hpa notesfor the reason metrics are missing. - The Route stops working after Step 11. Your cluster may use a different network plugin or router setup. Delete the policies with
oc delete -f netpol.yamlto confirm, then adapt them with your administrator.
Clean up
Warning: deleting the project removes everything in it, including the persistent volume claim and, depending on the storage class’s reclaim policy, the data on it. Check that you are pointing at the right project and cluster first:
oc whoami --show-server
oc project
oc delete project notes-prod
Project deletion takes a little while to finish. Your Git repository is not touched; delete it separately if you no longer need it.
Next steps
- Add a webhook trigger to the BuildConfig so every push to Git starts a build automatically.
- Move the counter into a PostgreSQL database and keep the app stateless, then use
ReadWriteOncestorage only for the database. - Describe the whole setup as YAML or a Helm chart, kept in Git, instead of
occommands. - Replace the router’s default certificate on the Route with one managed by cert-manager or your company’s certificate process.
- Add a default-deny egress policy and allow only the external services the app needs.
Official documentation
See also
- 0149 OpenShift vs Kubernetes vs OKD: what OpenShift adds OpenShift is Red Hat's Kubernetes platform and OKD is its community distribution. Both add projects, routes, builds, image streams, Operators and stricter… Beginner · Concept · Linux Article Concepts · Concepts 15 Oct 2026
- 0150 Logging in with oc and working with OpenShift projects Install the oc client, log in with a password, browser or token, then create, switch and inspect projects. Covers where oc stores… Beginner · How-to · Any system Article Concepts · Deploying applications 15 Oct 2026
- 0151 Tutorial: deploy a small web app on OpenShift and expose it with a Route A step-by-step first deployment: create a project, build a static website from Git with Source-to-Image, publish it on an HTTPS Route, then… Beginner · How-to · Any system Article Deploying applications 15 Oct 2026
- 0152 Build and deploy from source with oc new-app and Source-to-Image How oc new-app turns a Git repository or an image into a running app, how Source-to-Image and Docker builds are chosen, and… Intermediate · How-to · Any system Article Deploying applications 15 Oct 2026
- 0153 Expose apps with OpenShift Routes: edge, passthrough and re-encrypt TLS A Route publishes a service on a host name through the OpenShift router. Learn when to use plain HTTP, edge, passthrough or… Intermediate · How-to · Any system Article Deploying applications · Ingress & networking 15 Oct 2026
- 0154 OpenShift app configuration: environment, ConfigMaps, Secrets and persistent storage Set environment variables, load ConfigMaps and Secrets into a Deployment as variables or files, and give it persistent storage with a PersistentVolumeClaim,… Intermediate · How-to · Any system Article Config, secrets & storage · Deploying applications 15 Oct 2026
Repos in the wild
- okd-project/okd: the OKD community distribution okd-project/okd Home of OKD, the community distribution of Kubernetes that powers Red Hat OpenShift. Releases of the installer and client, issue tracking and community information live here. GitHub
- openshift/oc: the OpenShift command line client openshift/oc Source code of oc, the OpenShift CLI. It is built on top of kubectl, so it can do everything kubectl does, and adds commands for projects, builds, routes, image streams and cluster administration. GitHub
History
Every saved edit is kept. The plot on the sleeve draws one line per edit. Open any edit to see exactly what changed.
- Commitpedia +17,399 chars