# OCI artifacts: Helm charts, Flux artifacts and image volumes

> Three kinds of content in one registry, each a manifest with its own media types: a Helm chart pulled by helm, a Flux artifact pulled by an OCIRepository, and a data image mounted into a Pod as an image volume.

Interactive graph: https://k8wiz.com/graphs/oci-artifacts-helm-flux-image-volumes

A registry doesn't care whether a manifest describes a container. It stores a config blob and layer blobs, and the media types say what they are. Helm, Flux and many other tools use that to ship their own formats through the registry you already run, with the same authentication, replication and digests.

Each client reads the manifest, picks the layer whose media type it understands and downloads only that blob. The third example is a plain image used as data: Kubernetes 1.36 can mount it as a read-only volume.

## Resources

### Client helm CLI

outside the cluster

Runs `helm install shop oci://ghcr.io/acme/charts/shop --version 0.9.0`. It resolves the tag `0.9.0`, reads the manifest and downloads the chart layer. No `index.yaml` is involved.

### Image manifest charts/shop:0.9.0

made by a build or publishing tool

Written by `helm push`. The config media type `application/vnd.cncf.helm.config.v1+json` is how registries and tools recognize it as a chart.

### Config Chart.yaml as JSON

made by a build or publishing tool

The chart's metadata: name, version, appVersion, description. Tools can show it without downloading the chart itself.

### Layer shop-0.9.0.tgz

made by a build or publishing tool

The packaged chart, byte for byte the `.tgz` that `helm package` made, with the media type `application/vnd.cncf.helm.chart.content.v1.tar+gzip`. A signed chart has a second layer, `application/vnd.cncf.helm.chart.provenance.v1.prov`.

### Kustomization shop

you write it, kustomize.toolkit.fluxcd.io/v1, namespace flux-system

Applies the manifests from the OCIRepository's artifact. The cluster never talks to Git. CI pushed the manifests to the registry instead.

### OCIRepository shop-manifests

you write it, source.toolkit.fluxcd.io/v1, namespace flux-system

source-controller checks the `latest` tag every 5 minutes. When its digest changes, it downloads the layer that `layerSelector` names and, with `operation: extract`, unpacks it as the new artifact.

### Image manifest manifests/shop:4f2c8e1

made by a build or publishing tool

Written by `flux push artifact` in CI, then tagged `latest` with `flux tag artifact`. The annotations record the Git source and revision, which Flux shows as the artifact's origin.

### Layer deploy/ as tar.gz

made by a build or publishing tool

The `./deploy` directory from the app repo, packed by `flux push artifact --path=./deploy`. source-controller unpacks it and serves it to the Kustomization.

### Pod inference

you write it, v1, namespace ml

Runs the inference server from one image and mounts the model from another with an `image` volume. Updating the model means pushing a new model image, not rebuilding the server.

### Container runtime containerd 2.1+

comes with an add-on

Pulls the volume image like any image, unpacks its layers into a snapshot and mounts it read-only into the Pod. Image volumes need containerd 2.1 or CRI-O 1.31 or later. CRI-O can also mount artifacts that aren't images.

### Image manifest models/sentiment:2026-10

made by a build or publishing tool

An ordinary image manifest, built `FROM scratch` with a single `COPY` of the model files. It has no entrypoint and never runs. The kubelet only needs its filesystem.

### Layer model files, 256 MB

made by a build or publishing tool

The model weights and tokenizer, compressed with zstd. Nodes that already have this digest skip the download, so rolling out a new server version doesn't move the model again.

## How the resources connect

- Client helm CLI → Image manifest charts/shop:0.9.0: GET manifest (traffic)
- Image manifest charts/shop:0.9.0 → Config Chart.yaml as JSON: config (references)
- Image manifest charts/shop:0.9.0 → Layer shop-0.9.0.tgz: layers[0] (references)
- Client helm CLI → Layer shop-0.9.0.tgz: GET blob by chart media type (traffic)
- Kustomization shop → OCIRepository shop-manifests: sourceRef (references)
- OCIRepository shop-manifests → Image manifest manifests/shop:4f2c8e1: GET manifest (traffic)
- Image manifest manifests/shop:4f2c8e1 → Layer deploy/ as tar.gz: layers[0] (references)
- OCIRepository shop-manifests → Layer deploy/ as tar.gz: layerSelector, extract (traffic)
- Pod inference → Image manifest models/sentiment:2026-10: volumes[].image.reference (references)
- Container runtime containerd 2.1+ → Image manifest models/sentiment:2026-10: pulls (traffic)
- Image manifest models/sentiment:2026-10 → Layer model files, 256 MB: layers[0] (references)
- Container runtime containerd 2.1+ → Layer model files, 256 MB: unpacks (traffic)
- Container runtime containerd 2.1+ → Pod inference: mounts read-only (traffic)

## How it works

1. **A Helm chart is a manifest with Helm media types.** `helm push shop-0.9.0.tgz oci://ghcr.io/acme/charts` uploads `Chart.yaml` as the config blob and the `.tgz` as the only layer, and tags the manifest `0.9.0`. The config media type marks it as a chart.
2. **helm fetches only the chart layer.** `helm install` resolves `0.9.0`, reads the manifest, finds the layer with the media type `application/vnd.cncf.helm.chart.content.v1.tar+gzip` and downloads that blob. A provenance layer, if there is one, is fetched only for `--verify`.
3. **CI pushes manifests as a Flux artifact.** `flux push artifact` packs `./deploy` into one layer with Flux's media type and records the Git revision in annotations. `flux tag artifact` then moves `latest` to it. Git credentials stay in CI.
4. **The OCIRepository selects one layer.** source-controller sees the digest behind `latest` change, reads the manifest and takes the layer matching `layerSelector.mediaType`. `operation: extract` unpacks it. `copy` would keep the tarball, which is what a HelmRelease needs when it installs a chart through `chartRef`.
5. **The Kustomization applies the artifact.** The Kustomization reads the unpacked files from source-controller and applies them like files from Git. `flux get sources oci` shows the tag and digest in use.
6. **A data image becomes a volume.** The Pod's `image` volume names the model image. The runtime pulls it with the same code as container images, unpacks the layer and mounts the result read-only at `/models/sentiment`. The model ships and caches separately from the server image.

## Common mistakes

- Some registries reject manifests with media types they don't know. All the major ones accept artifacts now, but older self-hosted registries and some proxies may need configuration.
- Helm stores version `1.2.3+build.4` under the tag `1.2.3_build.4`, because tags can't contain `+`. Tools that compute the tag themselves have to do the same.
- An OCIRepository without `layerSelector` takes the first layer. That works for Flux artifacts, but for a chart with a provenance layer, select the chart's media type explicitly.
- Image volumes are read-only, and files keep the owner and mode they have in the image. Set ownership and permissions when you build the image, so the container's user can read them.
- A moving tag like `latest` in an image volume with `pullPolicy: IfNotPresent` keeps the old content on nodes that already pulled it. Use dated or versioned tags, or a digest.

## The manifests you write

```yaml
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: shop
  namespace: flux-system
spec:
  interval: 10m
  sourceRef:
    kind: OCIRepository
    name: shop-manifests
  path: ./
  prune: true
---
apiVersion: source.toolkit.fluxcd.io/v1
kind: OCIRepository
metadata:
  name: shop-manifests
  namespace: flux-system
spec:
  interval: 5m
  url: oci://ghcr.io/acme/manifests/shop
  ref:
    tag: latest
  layerSelector:
    mediaType: application/vnd.cncf.flux.content.v1.tar+gzip
    operation: extract
---
apiVersion: v1
kind: Pod
metadata:
  name: inference
  namespace: ml
spec:
  containers:
    - name: server
      image: ghcr.io/acme/inference-server:3.2.0
      volumeMounts:
        - name: model
          mountPath: /models/sentiment
          readOnly: true
  volumes:
    - name: model
      image:
        reference: ghcr.io/acme/models/sentiment:2026-10
        pullPolicy: IfNotPresent
```
