Why the cluster builds images
In an older OpenShift project you find no Dockerfile and no CI pipeline - yet new code reaches production. The cluster itself cloned the Git repo, built the image, pushed it and rolled the Deployment. When that chain breaks, you need to know its links.
What you need to know already: images, tags and digests (10.5); a Dockerfile and reading a build (10.8); registries (10.54); a Deployment rolling to a new image and ImagePullBackOff (15.16, 18.28); pipelines that build and promote images (25.1, 25.11); Secrets (15.33); projects and their RoleBindings (30.4); arbitrary UIDs (30.9).
The words you need first
- BuildConfig - the recipe for building an image on the cluster.
- Build - one run of that recipe.
- S2I (Source-to-Image) - a way to build without a Dockerfile: a builder image for your language takes your source code and produces a runnable image.
- ImageStream - an OpenShift object that is a named list of tags, each pointing at an image digest, with history. ImageStreamTag - one
name:tagof it. - Image trigger - "when this tag moves, update that Deployment's image".
The cluster can build your images
Vanilla Kubernetes runs images someone built elsewhere. OpenShift can also build them, inside the cluster, from a Git repository - and track the results in an object that knows the history of every tag. Three kinds carry that:
- BuildConfig (
bc) - how to build: source (Git URL, ref, context dir), strategy (Source = S2I, Docker = a Dockerfile, Custom), output (an image stream tag), triggers (config change, image change, webhooks). - Build - one run of a BuildConfig, numbered:
web-1,web-2... Each runs in a build podweb-1-buildwhose log is the build log. - ImageStream (
is) - a named set of tags, each pointing at an image by digest, with history.ImageStreamTag(istag) is onename:tagof it.
(Triggers: config change = the BuildConfig itself changed; image change = the builder image got a new version; webhooks = a URL your Git server calls on every push.)
Many enterprise teams build in their CI instead (GitLab, Jenkins, Tekton - Tekton is a pipeline tool that runs as Kubernetes objects; 25.14) and only deploy on OpenShift - you will still meet BuildConfigs in older projects, and ImageStreams are everywhere. The newer build API is Builds for OpenShift (upstream Shipwright, shipwright.io API group), an optional operator; this lesson is about the built-in build.openshift.io one.
Source-to-Image (S2I)
A builder image knows how to turn source code into a runnable image: Red Hat ships them for Node.js, Python, Java, PHP, Ruby, .NET, nginx, httpd... in the openshift project, as image streams everyone can read. oc get is lists image streams; the IMAGE REPOSITORY column is where the images live in the internal registry, TAGS the versions available:
$ oc get is -n openshift
NAME IMAGE REPOSITORY TAGS UPDATED
httpd image-registry.openshift-image-registry.svc:5000/openshift/httpd 2.4-ubi9,latest 13 days ago
java image-registry.openshift-image-registry.svc:5000/openshift/java latest,openjdk-17-ubi9,openjdk-21-ubi9 13 days ago
nginx image-registry.openshift-image-registry.svc:5000/openshift/nginx 1.24-ubi9,1.26-ubi9,latest 13 days ago
nodejs image-registry.openshift-image-registry.svc:5000/openshift/nodejs 20-ubi9,22-ubi10,22-ubi9,latest 13 days ago
python image-registry.openshift-image-registry.svc:5000/openshift/python 3.11-ubi9,3.12-ubi9,latest 13 days ago
S2I takes your source, copies it into the builder image, runs the builder's /usr/libexec/s2i/assemble script (npm install, pip install, mvn package...), commits the result as a new image whose command is /usr/libexec/s2i/run, and pushes it. No Dockerfile in your repo. The images are UBI-based (30.9) and built for arbitrary UIDs (USER 1001, app directories owned by group 0) - an S2I image never has the problem of lesson 30.9.
oc new-app from source
oc new-app creates everything an app needs from one argument. nodejs~URL means "use the nodejs builder image on the source at URL". The output first says which builder it found, then lists what it creates:
# in project shop, as the builds mission does it
oc new-app nodejs~https://github.com/sclorg/nodejs-ex.git
--> Found image 8c1a2d7 (2 weeks old) in image stream "openshift/nodejs" under tag "22-ubi9" for "nodejs"
Node.js 22
----------
Node.js 22 available as container is a base platform for building and running various Node.js 22 applications and frameworks. ...
Tags: builder, nodejs, nodejs22
* A source build using source code from https://github.com/sclorg/nodejs-ex.git will be created
* The resulting image will be pushed to image stream tag "nodejs-ex:latest"
* Use 'oc start-build' to trigger a new build
--> Creating resources ...
imagestream.image.openshift.io "nodejs-ex" created
buildconfig.build.openshift.io "nodejs-ex" created
deployment.apps "nodejs-ex" created
service "nodejs-ex" created
--> Success
Build scheduled, use 'oc logs -f buildconfig/nodejs-ex' to track its progress.
Application is not exposed. You can expose services to the outside world by executing one or more of the commands below:
'oc expose service/nodejs-ex'
Run 'oc status' to view your app.
builder~repo picks the builder explicitly; without it new-app detects the language from the repo (package.json -> nodejs, requirements.txt -> python, pom.xml -> java, a Dockerfile -> Docker strategy). Four objects, all labelled app=nodejs-ex:
- an ImageStream
nodejs-ex(empty until the first build pushes), - a BuildConfig
nodejs-ex(Source strategy fromopenshift/nodejs:22-ubi9, outputnodejs-ex:latest, triggers: GitHub + Generic webhooks, ConfigChange, ImageChange), - a Deployment with an
image.openshift.io/triggersannotation pointing atnodejs-ex:latest(new-app has created Deployments, not DeploymentConfigs, for years), - a Service on the port the builder image EXPOSEs (
8080-tcp).
oc new-build is the same without the Deployment and Service. oc new-app --image=REF (or just a name like nginx:1.27) skips the build and deploys an image, creating an ImageStream that tracks it.
The build
# the builds mission's checkout project
oc get builds
NAME TYPE FROM STATUS STARTED DURATION
nodejs-ex-1 Source Git@e3d1c2a Running 12 seconds ago
oc get pods
NAME READY STATUS RESTARTS AGE
nodejs-ex-1-build 1/1 Running 0 12s
nodejs-ex-6d8c9b7f4d-x2lqp 0/1 ImagePullBackOff 0 12s
Note the second pod: the Deployment exists before its image does. Until the build pushes, the kubelet cannot pull nodejs-ex:latest from the internal registry (manifest unknown). That is normal for the first minute of a new-app.
oc logs -f bc/NAME follows (-f, like tail -f) the log of the BuildConfig's latest build. It reads like a Dockerfile build (10.8), because S2I generates one:
oc logs -f bc/nodejs-ex
Cloning "https://github.com/sclorg/nodejs-ex.git" ...
Commit: e3d1c2a (Merge pull request #312 from sclorg/update-deps)
Author: sclorg-ci <[email protected]>
Date: Mon, 21 Sep 2026 20:00:00 +0000
...
Generating dockerfile with builder image image-registry.openshift-image-registry.svc:5000/openshift/nodejs@sha256:...
STEP 1/9: FROM image-registry.openshift-image-registry.svc:5000/openshift/nodejs@sha256:...
STEP 2/9: LABEL "io.openshift.build.image"="..." "io.openshift.build.commit.author"="sclorg-ci <[email protected]>" ...
STEP 3/9: ENV OPENSHIFT_BUILD_NAME="nodejs-ex-1" OPENSHIFT_BUILD_NAMESPACE="shop" ...
STEP 4/9: USER root
STEP 5/9: COPY upload/src /tmp/src
STEP 6/9: RUN chown -R 1001:0 /tmp/src
STEP 7/9: USER 1001
STEP 8/9: RUN /usr/libexec/s2i/assemble
---> Installing application source ...
---> Installing all dependencies
...
STEP 9/9: CMD /usr/libexec/s2i/run
COMMIT temp.builder.openshift.io/shop/nodejs-ex-1:4f3a2b1c
...
Pushing image image-registry.openshift-image-registry.svc:5000/shop/nodejs-ex:latest ...
Getting image source signatures
Copying blob sha256:...
Writing manifest to image destination
Successfully pushed image-registry.openshift-image-registry.svc:5000/shop/nodejs-ex@sha256:f3fba511...
Push successful
Read it as the S2I recipe in Dockerfile form: chown -R 1001:0 - the arbitrary-UID pattern again - then assemble, then push by digest. oc logs -f bc/NAME follows the latest build; oc logs build/nodejs-ex-1 a specific one; oc describe build shows status, duration, trigger cause and events.
Build again (new commit, or a changed builder image) with oc start-build NAME; --follow streams the log while it runs:
oc start-build nodejs-ex --follow
build.build.openshift.io/nodejs-ex-2 started
...
Push successful
oc get builds
NAME TYPE FROM STATUS STARTED DURATION
nodejs-ex-1 Source Git@e3d1c2a Complete 4 minutes ago 53s
nodejs-ex-2 Source Git@e3d1c2a Complete 47 seconds ago 41s
--wait makes start-build exit non-zero when the build fails - use it in scripts. oc cancel-build nodejs-ex-3 stops one. --from-dir=. does a binary build: uploads your local directory instead of cloning (handy before the code is pushed).
When a build fails
oc get builds
NAME TYPE FROM STATUS STARTED DURATION
catalog-1 Source Git Failed (FetchSourceFailed) 30 seconds ago 4s
oc logs build/catalog-1
Cloning "https://git.lab/shop/catalogue.git" ...
error: failed to fetch requested repository "https://git.lab/shop/catalogue.git" with provided credentials
The status carries a reason: FetchSourceFailed (URL, ref, credentials - private repos need a source secret: oc create secret generic git-auth --from-literal=username=... --from-literal=password=... and oc set build-secret --source bc/catalog git-auth), PullBuilderImageFailed, GenericBuildFailed (the assemble script failed - read the log), PushImageToRegistryFailed, InvalidOutputReference. The fix is almost always in the BuildConfig (oc edit bc) or the repo, followed by oc start-build.
ImageStreams: tags that point at digests
oc describe is NAME shows each tag with its history, newest first; oc get istag shows what each tag points at now:
oc describe is nodejs-ex
Name: nodejs-ex
Namespace: shop
...
Image Repository: image-registry.openshift-image-registry.svc:5000/shop/nodejs-ex
Image Lookup: local=false
Unique Images: 2
Tags: 1
latest
pushed image
* image-registry.openshift-image-registry.svc:5000/shop/nodejs-ex@sha256:9a0c...
2 minutes ago
image-registry.openshift-image-registry.svc:5000/shop/nodejs-ex@sha256:f3fb...
6 minutes ago
oc get istag
NAME IMAGE REFERENCE UPDATED
nodejs-ex:latest image-registry.openshift-image-registry.svc:5000/shop/nodejs-ex@sha256:9a0c... 2 minutes ago
An ImageStream does not store layers - the registry does. It stores pointers: every tag resolves to a digest, and the previous digests are kept as history (* marks the current one). Two consequences:
- A Deployment that follows a tag always gets an immutable reference (
...@sha256:9a0c...), never a floating:latestthe node might have cached. Nodes cannot drift. - Rolling back is re-pointing a tag at an old digest - no rebuild.
Tags can also point at images outside the cluster. oc import-image STREAM:TAG --from=REF --confirm copies the reference in (--confirm creates the stream if it does not exist); oc tag SOURCE DEST points a tag at a source, which can be an external image or another tag:
$ oc import-image web:1.27 --from=docker.io/nginxinc/nginx-unprivileged:1.27 --confirm
imagestream.image.openshift.io/web imported
...
$ oc tag docker.io/nginxinc/nginx-unprivileged:1.28 web:1.28
Tag web:1.28 set to docker.io/nginxinc/nginx-unprivileged:1.28.
$ oc tag docker.io/nginxinc/nginx-unprivileged:1.28 web:1.28 --scheduled
--scheduled re-imports periodically (every 15 minutes by default), so the stream notices when upstream moves the tag - and any trigger fires. An import that fails shows up on the tag:
latest
tagged from docker.io/nginxinc/nginx-unprivileged:9.9
! error: Import failed (InternalError): Internal error occurred: docker.io/nginxinc/nginx-unprivileged:9.9: reading manifest 9.9 in docker.io/nginxinc/nginx-unprivileged: manifest unknown
The internal registry
$ oc registry info
image-registry.openshift-image-registry.svc:5000
Every ImageStream is a repository image-registry.openshift-image-registry.svc:5000/<project>/<stream>. That name only resolves inside the cluster. Pulls are authorised with RBAC:
- your project's ServiceAccounts may pull its streams (the
system:image-pullersRoleBinding from lesson 30.4); - another project's pods may pull only if you grant
oc policy add-role-to-group system:image-puller system:serviceaccounts:<other> -n <yours>· without it, their pod showsErrImagePull/authentication required; - pushing needs
system:image-builder(thebuilderSA has it).
From outside (your laptop, CI) the registry is only reachable if an admin exposes its default route (oc patch configs.imageregistry.operator.openshift.io/cluster --type merge -p '{"spec":{"defaultRoute":true}}'), after which oc registry info --public prints default-route-openshift-image-registry.apps.<domain> and podman login -u $(oc whoami) -p $(oc whoami -t) <that host> works (podman: a Docker-compatible CLI; docker login works the same way) - your OpenShift token is your registry password.
What you can now do
- Build from source with
oc new-app builder~repo, follow the build, and rebuild. - Read a failed build's reason (FetchSourceFailed, GenericBuildFailed...) before its log.
- Explain ImageStreams: tags that point at digests, with history - and move a tag with
oc tag.