Static web hosting on Kubernetes with OCI images as volumes

Published on 2026-05-18

Sticker by @puzzoz
Sticker by @puzzoz

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 --mount=type=cache,mode=0777,uid=1001,gid=0,target=node_modules \
    npm ci --only=production
ADD . .
RUN --mount=type=cache,mode=0777,uid=1001,gid=0,target=node_modules \
    --mount=type=cache,mode=0777,uid=1001,gid=0,target=public \
    --mount=type=cache,mode=0777,uid=1001,gid=0,target=.cache \
    npm run build

FROM registry.access.redhat.com/ubi10/nginx-126:10.1
CMD nginx -g "daemon off;"
COPY --from=build $HOME/public $HOME

With 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 --mount=type=cache,mode=0777,uid=1001,gid=0,target=node_modules \
    npm ci --only=production
ADD . .
RUN --mount=type=cache,mode=0777,uid=1001,gid=0,target=node_modules \
    --mount=type=cache,mode=0777,uid=1001,gid=0,target=.cache \
    npm run build

FROM scratch
COPY --from=build /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: html

Automating 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!

Kubernetes reference