Static websites have always been simple to deploy - until you try to do it “the Kubernetes way.” Traditional approaches involve init containers copying files, persistent volumes, or baking content into web server images. Each has drawbacks: complexity, storage dependencies, or rebuilding entire server images for content changes.
Kubernetes 1.31 introduced a feature that changes this: Image Volumes. This allows mounting an OCI container image directly as a read-only volume. Combined with FluxCD’s image automation, you get a GitOps-native static site deployment that updates automatically when content changes.
What Are Image Volumes?
Image Volumes are a Kubernetes feature (beta since v1.31) that allows you to mount OCI container images directly as volumes in your pods. Instead of copying files into a container image or using persistent storage, you can package your static content as a container image and mount it directly into your application pod.
This is particularly useful for:
- Static websites and documentation
- Configuration data that changes with deployments
- Read-only datasets
- Any immutable content you want to version with container images
The Architecture
The setup consists of three main components:
- Content Image - A minimal scratch-based container holding only static HTML
- Web Server Pod - Caddy serving content from the mounted image volume
- FluxCD Automation - Automatic detection and deployment of new content versions
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Hugo/Static │────▶│ Content Image │────▶│ Container │
│ Site Generator │ │ (FROM scratch) │ │ Registry │
└─────────────────┘ └──────────────────┘ └────────┬────────┘
│
┌─────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Kubernetes Cluster │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Pod │ │
│ │ ┌─────────────┐ ┌─────────────────────────────────┐ │ │
│ │ │ Caddy │◀────▶│ Image Volume (mounted at /data)│ │ │
│ │ │ Container │ │ Content from scratch image │ │ │
│ │ └─────────────┘ └─────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ FluxCD Image Automation │ │
│ │ - ImageRepository: polls registry for new tags │ │
│ │ - ImagePolicy: selects latest tag │ │
│ │ - ImageUpdateAutomation: updates manifests in Git │ │
│ └───────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Enabling Image Volumes on Talos Linux
While Image Volumes became beta in Kubernetes 1.31 and should be enabled by default, I found that on Talos Linux running Kubernetes 1.33.4, the feature gate needed to be explicitly enabled. Talos Linux uses a declarative configuration model, so enabling feature gates requires patching the machine configuration.
Create a configuration patch:
machine:
kubelet:
extraArgs:
feature-gates: ImageVolume=true
cluster:
apiServer:
extraArgs:
feature-gates: ImageVolume=true
Apply it to your cluster nodes:
# For control plane nodes
talosctl patch mc --nodes <control-plane-ip> --patch @image-volume-patch.yaml
# For worker nodes
talosctl patch mc --nodes <worker-ip> --patch @image-volume-patch.yaml
After the nodes reconcile and restart the necessary components, the feature is ready to use.
The ORAS Artifact Gotcha
Initially, I tried using an OCI artifact created with ORAS (OCI Registry As Storage) to store my static files:
oras push "${REGISTRY}/${REPOSITORY}:${TAG}" \
--config config.json:application/vnd.oci.image.config.v1+json \
--artifact-type application/vnd.example.website.v1 \
"${HUGO_DIR}/public:application/vnd.example.static-content"
This seemed perfect for storing static files, but when I tried to mount it as an Image Volume, I got a cryptic error:
Error: failed to generate container spec: failed to apply OCI options:
failed to mkdir "": mkdir : no such file or directory
Image Volumes require proper OCI images with filesystem layers, not ORAS artifacts.
ORAS artifacts store files as opaque blob layers designed for generic artifact distribution (Helm charts, SBOMs, etc.), but they don’t have the filesystem layer structure that container runtimes can mount. Image Volumes specifically need OCI images built with tools like Docker, Podman, or Buildah that create proper tar.gz filesystem layers with the required rootfs metadata.
Building a Proper OCI Image for Static Content
The solution is to build a minimal container image from scratch:
FROM scratch
LABEL org.opencontainers.image.title="My Website"
LABEL org.opencontainers.image.description="Personal website built with Hugo"
COPY hugo/public /html
This produces an extremely minimal image. For a typical blog, you’re looking at a few megabytes containing nothing but your content - no OS, no shell, no attack surface.
The CI pipeline (using Woodpecker CI) builds Hugo, creates the image, and pushes with a timestamp tag:
steps:
- name: build hugo
image: hugomods/hugo:exts
commands:
- cd hugo && hugo --minify
- name: build and push container
image: quay.io/podman/stable:latest
commands:
- podman build -t registry.example.com/websites/my-site:${TAG} .
- podman push registry.example.com/websites/my-site:${TAG}
Tags follow the format YYYYMMDD-HHMMSS-<git-short-hash>, enabling both chronological sorting and traceability.
The Kubernetes Deployment with Image Volumes
Here’s where the magic happens. The deployment mounts the content image as a volume:
apiVersion: apps/v1
kind: Deployment
metadata:
name: website
spec:
template:
spec:
imagePullSecrets:
- name: registry-credentials
containers:
- name: caddy
image: caddy:alpine
ports:
- containerPort: 80
name: http
volumeMounts:
- name: caddy-config
mountPath: /etc/caddy/Caddyfile
subPath: Caddyfile
readOnly: true
- name: html-content
mountPath: /data
subPath: html
readOnly: true
volumes:
- name: caddy-config
configMap:
name: caddy-config
- name: html-content
image:
reference: registry.example.com/websites/my-site:20251209-064530-d63ad9b
pullPolicy: IfNotPresent
The volumes.image type is the Kubernetes 1.31+ feature. It pulls the specified image and mounts its filesystem as a volume. The subPath: html maps to the /html directory we created in our Containerfile.
The same imagePullSecrets that work for container images also work for image volumes, making private registry integration straightforward.
Caddy Configuration
Caddy serves the content with proper caching and security headers:
:80 {
root * /data/html
file_server
encode zstd gzip
@static {
path *.css *.js *.jpg *.jpeg *.png *.gif *.ico *.woff *.woff2 *.svg *.webp
}
header @static Cache-Control "public, max-age=31536000, immutable"
@html {
path *.html /
}
header @html Cache-Control "public, max-age=3600, must-revalidate"
header {
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
metrics /metrics
}
FluxCD Image Automation
The real power comes from automating updates. When new content is pushed, FluxCD detects it and updates the deployment automatically.
ImageRepository - Watch the Registry
apiVersion: image.toolkit.fluxcd.io/v1beta2
kind: ImageRepository
metadata:
name: my-website
namespace: flux-system
spec:
image: registry.example.com/websites/my-site
interval: 3m
secretRef:
name: registry-creds
FluxCD polls the registry every 3 minutes for new tags.
ImagePolicy - Select the Latest Tag
apiVersion: image.toolkit.fluxcd.io/v1beta2
kind: ImagePolicy
metadata:
name: my-website
namespace: flux-system
spec:
imageRepositoryRef:
name: my-website
filterTags:
pattern: '^[0-9]{8}-[0-9]{6}'
policy:
alphabetical:
order: asc
The policy filters tags matching our timestamp format and selects the latest alphabetically (which works for YYYYMMDD-HHMMSS format).
ImageUpdateAutomation - Update the Manifest
apiVersion: image.toolkit.fluxcd.io/v1beta2
kind: ImageUpdateAutomation
metadata:
name: my-website
namespace: flux-system
spec:
interval: 30m
sourceRef:
kind: GitRepository
name: my-website
git:
commit:
author:
email: fluxbot@example.com
name: fluxbot
push:
branch: main
update:
path: ./deploy/
When a new tag is detected, FluxCD updates the image reference in the deployment manifest and commits the change back to Git. The special marker comment in the deployment enables this:
reference: registry.example.com/websites/my-site:20251209-064530 # {"$imagepolicy": "flux-system:my-website"}
The Complete Flow
- Author writes content - Push markdown to the website repository
- CI builds - Hugo generates HTML, Podman builds and pushes the scratch image with timestamp tag
- FluxCD detects - ImageRepository sees new tag in registry
- Policy evaluates - ImagePolicy selects the new tag as latest
- Automation updates - ImageUpdateAutomation commits the new tag to Git
- Flux deploys - Kustomization controller applies the updated deployment
- Kubernetes pulls - The new content image is mounted, pod restarts with fresh content
Key Takeaways
Separation of concerns - Content and web server are independent. Update Caddy without touching content, update content without rebuilding the server.
OCI Images ≠ OCI Artifacts - ORAS artifacts are great for distributing generic content, but Image Volumes specifically need proper OCI images with filesystem layers. Use Docker, Podman, or Buildah to create these.
FROM scratch is your friend - For static content, a minimal image built from scratch keeps your images tiny and reduces attack surface.
Feature gates may need explicit enabling - Even for beta features, check your Kubernetes distribution’s defaults. On Talos Linux, I needed to explicitly enable the ImageVolume feature gate.
GitOps native - Every deployment is traceable to a Git commit. Rollback is git revert.
No persistent storage - The content is ephemeral and immutable. No PVCs to manage.
Requirements
- Kubernetes 1.31+ with the
ImageVolumefeature gate enabled - FluxCD with image-reflector-controller and image-automation-controller
- A container registry accessible from your cluster
- Container runtime with
ImageVolumesupport (CRI-O v1.31+, containerd v2+)
Conclusion
Kubernetes image volumes solve a real problem for static site hosting. Combined with FluxCD’s image automation, you get a deployment pipeline that’s both simple and powerful. Content changes flow automatically from Git to production with full auditability.
The pattern extends beyond websites - any scenario where you need to mount data as a volume without runtime dependencies benefits from this approach: configuration bundles, ML models, or any immutable artifacts.
