Introduced in Kubernetes 1.31, promoted to beta in 1.33 and entered in stable with Kubernetes 1.36, we can use OCI images as volumes for containers. They are now a possible storage choice among Persistent Volume Claims, empty directory, pod metadata, Config Maps and Secrets. This option can take advantage of the existing tooling and CI/CD already present to deliver an archive containing any kind of data.
OCI images, the evolution of Docker images, have become a new way to deliver artifacts, to the point that they are also used by some Linux distributions to build and ship system updates.
By leveraging OCI images with this new feature we can improve our static website development and deployment by decoupling webserver image and static website build, simplifying application versioning and making quicker web server patching without rebuilding everything.
Creating website images, the old way
Until now, the standard way to host a website on Kubernetes was, after eventually building the site assets, to extend a web server image (in this case Nginx) with the files of the static website copied in the www root folder. An example Containerfile for building my website with this approach could be the following:
FROM registry.access.redhat.com/ubi10/nodejs-24-minimal:10.1 as build
WORKDIR $HOME
ADD package*.json ./
RUN \
npm ci --only=production
ADD . .
RUN \
npm run build
FROM registry.access.redhat.com/ubi10/nginx-126:10.1
CMD nginx -g "daemon off;"
COPY $HOME/public $HOMEWith the new approach, we can replace the Nginx base image with scratch that is an empty base image, as opposed to a distro or runtime base image developer use in a typical scenario.
FROM registry.access.redhat.com/ubi10/nodejs-24-minimal:10.1 as build
WORKDIR $HOME
ADD package*.json ./
RUN \
npm ci --only=production
ADD . .
RUN \
npm run build
FROM scratch
COPY /opt/app-root/src/public /Let's see with podman images what are the differences between the two images:
REPOSITORY TAG IMAGE ID CREATED SIZE
ghcr.io/kowalski7cc/website scratch f1e26dd79993 13 seconds ago 140 MB
ghcr.io/kowalski7cc/website nginx a108d5cebad5 4 minutes ago 428 MB
We can see the image kowalski7cc/website:scratch is taking significantly less space than the kowalski7cc/website:nginx image without changing anything about the website!
At this point we can push our smaller image and prepare a Kubernetes deployment to it.
The basic syntax to use a OCI image as a volume is the following:
...
spec:
containers:
- volumeMounts:
- name: volume
mountPath: /volume
...
volumes:
- name: volume
image:
reference: $VOLUME_IMAGE
pullPolicy: IfNotPresent
...So we can create a deployment like this example for an Nginx container, where we mount the ghcr.io/kowalski7cc/website:scratch image under the /opt/app-root/src, which is the default path used by Red Hat's Nginx image named registry.access.redhat.com/ubi10/nginx-126:10.1. With this image, it is required to override the default image command with ["nginx", "-g", "daemon off;"], otherwise Nginx would not start and instead we would find OCP s2i stub script.
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: website
name: website
namespace: website
spec:
selector:
matchLabels:
app: website
template:
metadata:
labels:
app: website
namespace: website
spec:
containers:
- image: registry.access.redhat.com/ubi10/nginx-126:10.1
imagePullPolicy: IfNotPresent
name: website
command:
- nginx
- -g
- daemon off;
ports:
- containerPort: 8080
name: http
protocol: TCP
volumeMounts:
- mountPath: /opt/app-root/src
name: html
volumes:
- image:
pullPolicy: Always
reference: ghcr.io/kowalski7cc/website:scratch
name: htmlAutomating static site build with Actions
Once we have deployed our first image, we can automatize the build with any kind of pipeline. If you are using a forge compatible with GitHub Actions, such as Gitea or Forgejo, you can use the Action. In this example I'm using Gatsbyjs framework for my website, but you can use your preferred one. After static site build, I use Red Hat buildah step to copy in a scratch image the static assets and publish them on my registry.
name: Build and push
on:
push:
branches: ["master"]
env:
REGISTRY: my-private-registry
IMAGE_NAME: ${{ github.repository }}
NODE: 24
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Use Node.js ${{ env.NODE }}
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE }}
cache: npm
- name: Get npm cache directory
id: npm-cache-dir
shell: bash
run: echo "dir=$(npm config get cache)" >> ${GITHUB_OUTPUT}
- uses: actions/cache@v5
with:
path: ${{ steps.npm-cache-dir.outputs.dir }}
key: ${{ runner.os }}-node${{ env.NODE }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node${{ env.NODE }}-
- name: Install dependencies
run: npm ci
- name: Caching Gatsby
id: gatsby-cache-build
uses: actions/cache@v5
with:
path: |
public
.cache
key: ${{ runner.os }}-gatsby-build-${{ github.run_id }}
restore-keys: |
${{ runner.os }}-gatsby-build-
- name: Build
run: npm run build
- name: Upload public assets for job ${{ github.run_id }}
uses: actions/upload-artifact@v7
with:
if-no-files-found: error
name: public
path: public/
push-raw:
needs: build
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- name: Download public assets for job ${{ github.run_id }}
uses: actions/download-artifact@v8
with:
name: public
path: public
- name: Build Image
id: build-image
uses: redhat-actions/buildah-build@v2
with:
base-image: scratch
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: "latest ${{ github.run_id }}"
context: public
content: public
oci: true
- name: Push To Registry
id: push-to-quay
uses: redhat-actions/push-to-registry@v2
with:
image: ${{ steps.build-image.outputs.image }}
tags: ${{ steps.build-image.outputs.tags }}
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Print image url
run: echo "Image pushed to ${{ steps.push-to-quay.outputs.registry-paths }}"Web hosting is not the limit
With this approach, we can apply the same paradigm with other types of application. For example we can have a OpenJDK Runtime for running our Java application, and have a pipeline build only the JAR artifact inside a OCI image and just push that on a registry. Or a more complex application runtime such as JBoss, where we can have our WARs inside a image and configurations stored inside Config Maps and just mount them in the container using a flexible deployment!
Every application which needs a runtime image, already provided by a vendor, can potentially use this approach. We could even build standard applications and just mount them on a distro base image, almost like how systemd's sysextensions work!