Skip to content
Container image support is in beta. Configuration and behaviour may change between releases.

Container images

The proxy implements the OCI distribution pull API, the protocol Docker, containerd, Podman, and CRI-O all use to pull images. The proxy decides each image manifest before the client receives it, and only serves the layers of images the workspace may pull, checked again on every layer request, so a denied image’s layers cannot be fetched by digest. An image’s signatures and attestations are served when the workspace may pull the image, so verifiers such as cosign and Kyverno can check it through the proxy before it runs. Every pull is recorded against the project on the API key.

Endpoint: <proxy-host>/v2/<registry>/, where <registry> is one of:

Registry Serves Notes
cgr.dev Chainguard Images Signed SBOMs, verified out of the box.
dhi.io Docker Hardened Images Signed SBOMs, verified out of the box. Requires a Docker account’s registry credentials; not served without them.
docker.io Docker Hub, including official images (docker.io/nginx is docker.io/library/nginx) Official images carry unsigned SBOMs, which are not trusted. Pulls need an allow rule.
ghcr.io GitHub Container Registry Varies by publisher, and none is trusted out of the box. Pulls need an allow rule.
quay.io Quay Varies by publisher, and none is trusted out of the box. Pulls need an allow rule.
mcr.microsoft.com Microsoft Artifact Registry Microsoft signs images but publishes no SBOM. Pulls need an allow rule.

Authentication: the API key, sent as HTTP basic auth with the key as the password. The username is not checked; _ is used here as a placeholder.

Container runtimes only talk to registries over HTTPS, so the proxy must be reachable over TLS at <proxy-host>.

An image is tracked like any other package: its name and digest appear under Packages with the ecosystem OCI, and allow and deny rules with that ecosystem apply to it. A rule’s package pattern is the image name including its registry, such as docker.io/library/* (see Image names in rules), and its version is a platform digest or * (see Platform digests). A rule with a digest needs the exact image name, and is checked against the registry when it is saved.

What an image contains comes from the SBOM its publisher signs and attaches to it. On the first pull of each image, the proxy finds that SBOM and verifies its signature before answering. ShieldedStack then reads the packages the SBOM lists and judges the image on their known vulnerabilities with the workspace’s usual thresholds. Docker Hardened Images and Chainguard Images are verified out of the box, and each workspace chooses which of them it trusts under Settings -> Security Policy. The packages inside an image appear on its Image Contents tab. Its description, authors, source repository, and size come from the image itself: the standard OCI labels its publisher sets (org.opencontainers.image.description, .authors, and .source), where present, and the layer sizes in its manifest.

An image without a signed SBOM from a trusted publisher is blocked from its first pull, in either running mode, unless an allow rule admits it. For a signed image, the running mode decides what happens while its packages are matched against known vulnerabilities:

  • Trust then Verify allows it straight away and matches its packages in the background.
  • Verify then Trust refuses the first pull with a message asking to retry in about a minute, and decides the image once matching completes.

If the registry cannot be reached to check the signature, the pull is refused with a request to retry in either running mode: a signed SBOM is required in both, and an unreachable registry is never taken as having one, or as missing one.

Every Docker Hub image has a namespace. Official images, such as nginx, postgres, and alpine, are in the library namespace, which clients leave out: docker pull nginx pulls docker.io/library/nginx, and the proxy records it under that name. Rules are saved as written and match the recorded name, so write official images with library/:

Package pattern Matches
docker.io/library/nginx The official nginx image
docker.io/library/* Every official image
docker.io/* Every Docker Hub image, official or not
docker.io/bitnami/* Every image in the bitnami namespace
docker.io/*/ngin* Images starting with ngin in any namespace, official ones included
docker.io/ngin* Namespaces starting with ngin, such as docker.io/nginxinc/nginx-unprivileged, but not the official nginx, which is docker.io/library/nginx
docker.io/nginx Refused when saved: a name without a namespace matches no image

* matches any characters, including /. To admit only official images, add an allow rule for docker.io/library/* and require an allow list match for OCI under Settings.

The other registries have no implicit namespace, so their image names are used as written.

As in every ecosystem, a deny rule matches an image name in any casing, while an allow rule matches only the name as the registry spells it. Image names are lowercase, so write patterns in lowercase.

Most images are published for several platforms, such as linux/amd64 and linux/arm64. Their tag points to an image index: a list of each platform’s image and its digest. The proxy serves the index without deciding it, and decides the platform image the runtime then pulls. Rules that pin a digest therefore name a platform digest.

When a rule with a digest is saved, ShieldedStack asks the image’s registry for that digest. The rule is saved only if the registry has it and it is a platform image; an index digest, an attestation or signature, a digest the registry does not have, or a registry that cannot be reached all refuse the save. Tags are not accepted as versions, since a tag moves: use * for a rule that should follow every update of an image, or a digest to pin one image.

To find an image’s platform digests:

  • In the rule form. Paste the reference docker pull prints, such as docker.io/library/nginx@sha256:9b0b…. The form splits it into the image name and digest, and for an index lists each platform with its digest.

  • Under Packages. Every image pulled or refused through the proxy is listed under Packages with the ecosystem OCI, by its platform digest, and an allow or deny rule added from there names it.

  • From the denial. A refused pull names the platform digest it was decided on:

    Error response from daemon: denied: This image (sha256:4f2a…) has no signed SBOM from a publisher this workspace trusts.

    The same refusal is in the workspace’s event log.

  • On Docker Hub. A tag’s page lists each OS/ARCH with its own digest. Copy the digest from the row for the platform you run, not the index digest shown for the tag as a whole.

  • Before anything has pulled it. docker buildx imagetools inspect lists the index and each platform’s digest. It reads only the index, so it works through the proxy for any image:

    Terminal window
    docker buildx imagetools inspect <proxy-host>/docker.io/library/nginx:1.27
    Name: <proxy-host>/docker.io/library/nginx:1.27
    MediaType: application/vnd.oci.image.index.v1+json
    Digest: sha256:9b0b… <- index digest: not for rules
    Manifests:
    Name: <proxy-host>/docker.io/library/nginx:1.27@sha256:4f2a…
    Platform: linux/amd64 <- platform digest above
    Name: <proxy-host>/docker.io/library/nginx:1.27@sha256:c1d7…
    Platform: linux/arm64

Pin each platform you run with its own rule. An image published for a single platform has no index, and the digest docker pull prints is its platform digest.

An allow rule decides an image before its SBOM is looked at, so it admits an image without one in either running mode. The rule’s version sets how much it admits:

Package Version Admits
mcr.microsoft.com/dotnet/aspnet Platform digest (sha256: and 64 hex characters) That one platform’s image. A new digest, such as after its tag moves, is blocked until it is allowed too.
mcr.microsoft.com/dotnet/aspnet * Every image in the repository, including ones published later.
mcr.microsoft.com/* * Every image on the registry.

ShieldedStack knows nothing of an unattested image’s contents, so an allowed image is trusted on its publisher alone. Pin platform digests where the image matters, and update the rules with each base image update.

An allow rule also admits the signatures and attestations its publisher attaches to the images it allows, so verifiers can check them through the proxy. ShieldedStack cannot tell a genuine attestation from other data labelled as one, so whoever can push to an allowed repository can attach data that will be served alongside the allowed image. Container runtimes do not run it, but it can be downloaded. Allow repositories whose push access you trust.

A deny rule refuses an image by name, or by platform digest. To block a multi-platform release, deny each of its platform digests, or deny the image by name.

The runtime is told to fetch a registry’s images from the proxy, so docker pull nginx and image references in manifests stay as they are. Configure one entry per registry you pull from.

Every runtime treats a registry mirror as optional and goes to the original registry when the mirror refuses an image, including when the proxy blocks it. The configurations below avoid that by making the proxy the registry rather than a mirror of it, and they only enforce policy if the machine cannot reach the public registries directly. See Blocking direct pulls.

Docker reads per-registry configuration from /etc/docker/certs.d/<registry>/hosts.toml when it uses the containerd image store, the default for new installations of Docker Engine 29. For Docker Hub, create /etc/docker/certs.d/docker.io/hosts.toml:

server = "https://<proxy-host>/v2/docker.io"
override_path = true
[header]
Authorization = "Basic BASE64_OF_UNDERSCORE_COLON_API_KEY"

server replaces the registry rather than adding a mirror in front of it, so nothing falls back to Docker Hub. override_path tells Docker the path already ends in the API version. Produce the header value with:

Terminal window
printf '_:%s' "$SHIELDEDSTACK_API_KEY" | base64

Repeat for each registry, changing the directory name and the last segment of server, for example /etc/docker/certs.d/ghcr.io/hosts.toml with server = "https://<proxy-host>/v2/ghcr.io". Docker reads the files on every pull, so no restart is needed.

Docker installations still on the legacy image store ignore hosts.toml. Use rewritten image names there, or switch to the containerd image store.

containerd uses the same files under its own directory, /etc/containerd/certs.d/<registry>/hosts.toml, with the same content as for Docker:

server = "https://<proxy-host>/v2/quay.io"
override_path = true
[header]
Authorization = "Basic BASE64_OF_UNDERSCORE_COLON_API_KEY"

The CRI plugin only reads the directory when the containerd configuration points at it:

[plugins.'io.containerd.cri.v1.images'.registry]
config_path = "/etc/containerd/certs.d"

Kubernetes pulls through the node’s containerd, so configuring the nodes covers every pod on them without changing a manifest. Apply the files with the node image or your configuration management, so new nodes pull through the proxy from the start.

Podman and CRI-O read registries.conf. Point each registry’s location at the proxy:

[[registry]]
prefix = "quay.io"
location = "<proxy-host>/quay.io"

Use location, not a [[registry.mirror]] entry. A mirror falls back to the original registry when the proxy blocks an image; a location replaces it.

Credentials go in the containers auth file. For Podman:

Terminal window
podman login <proxy-host> --username _ --password "$SHIELDEDSTACK_API_KEY"

CRI-O reads the file named by global_auth_file in its configuration; write the same credentials there for <proxy-host>.

Where runtime configuration is not available, name the proxy in the image reference instead, with the registry as the first path segment:

Terminal window
docker login <proxy-host> --username _ --password "$SHIELDEDSTACK_API_KEY"
docker pull <proxy-host>/docker.io/nginx:1.27
docker pull <proxy-host>/ghcr.io/org/app:2.1.0

Every client supports this, but every image reference has to change, including the ones inside Helm charts and third-party manifests.

The proxy decides pulls it receives. A machine that can still reach registry-1.docker.io, ghcr.io, or another registry directly can pull from it with an unchanged image name, so enforcement needs the registries’ API hosts blocked at the network edge for workstations, CI runners, and cluster nodes.

How image layers travel depends on the proxy’s ProxySettings__UseRedirectMode setting:

  • Redirect mode (the default): for a layer of an image the workspace has been served, the proxy hands the client the short-lived signed download URL the registry issues for it, so the layer comes straight from the registry’s content delivery network. Clients must reach those download hosts, while the registry API hosts stay blocked. A layer request for any other image is refused before the registry is asked, so a signed URL is only issued for a layer of an approved image.
  • Passthrough mode: the proxy streams each approved layer itself. Use it when clients cannot reach the content delivery network either.

To switch the proxy to passthrough mode:

docker-compose.yml
services:
proxy:
environment:
ProxySettings__UseRedirectMode: "false"

The proxy pulls from the public registries as its own service identity, and the worker reads image attestations the same way. Without credentials both pull anonymously, which works for public images, but anonymous Docker Hub pulls are rate limited per IP address and an organization pulling through one proxy reaches that limit quickly; the proxy logs a warning at startup when Docker Hub has no credentials. Docker Hardened Images (dhi.io) serves nothing anonymously, so it is not available until its credentials are set, and a pull from it answers NAME_UNKNOWN naming the settings it needs.

Give each registry a username and token through the deployment’s environment, on the proxy, the worker, and the Portal, which checks the digests in rules against the registries:

.env
OCI_DOCKERHUB_USERNAME=acme
OCI_DOCKERHUB_TOKEN=YOUR_REGISTRY_TOKEN

Docker Compose reads .env beside the Compose file, and ShieldedStack’s Compose file passes these to the proxy, the worker, and the Portal.

Registry Settings
docker.io OCI_DOCKERHUB_USERNAME, OCI_DOCKERHUB_TOKEN
dhi.io OCI_DHI_USERNAME, OCI_DHI_TOKEN (required)
ghcr.io OCI_GHCR_USERNAME, OCI_GHCR_TOKEN
quay.io OCI_QUAY_USERNAME, OCI_QUAY_TOKEN
mcr.microsoft.com OCI_MCR_USERNAME, OCI_MCR_TOKEN
cgr.dev OCI_CHAINGUARD_USERNAME, OCI_CHAINGUARD_TOKEN

To pull a registry’s images from an internal mirror instead, set OCI_<REGISTRY>_URL, for example OCI_DOCKERHUB_URL=https://dockerhub-mirror.internal. The mirror must serve the same images under the same names.

Use organization credentials that can read public images only: a Docker organization access token, a Quay robot account, or a GitHub token with read:packages. A credential that can also read private repositories makes those repositories pullable by every API key holder, whatever the registry’s own permissions say. Supply the values from your secret store, as environment variables or mounted secret files; they are never stored in the ShieldedStack database.

Docker Hub counts every manifest request as a pull and limits how many it serves: without credentials, 100 an hour per IP address, more for an authenticated free account, and no limit beyond fair use on paid plans. Docker changes these from time to time; a HEAD request for ratelimitpreview/test, which costs nothing, answers with the limit that applies to the credentials it was sent with in its ratelimit-limit header. Layer downloads are not counted. Every image pulled through one proxy shares one quota: the proxy’s IP address, or its account once credentials are set.

ShieldedStack uses that quota as follows:

  • A HEAD on a tag costs nothing. Runtimes send one before they pull, and Kubernetes nodes running pods with imagePullPolicy: Always send one each time a container starts. The proxy asks the registry with a HEAD too. A single-platform image is still decided on the digest the registry gives, so a node cannot start one the workspace has since denied. A multi-platform tag names its image index, which is decided only when a platform image is pulled, so a node that already holds that platform image starts it without asking.
  • Every other manifest request costs one pull. The proxy fetches the manifest with a GET and decides on the bytes it has read. A multi-platform pull asks for the image index and then the platform image, which costs two.
  • The worker reads each new image once, with one pull for its platform image’s manifest, to record its description, authors, and size. Looking up its signed SBOM costs a pull only for an image that has one.

The worker counts each 429 Too Many Requests a registry answers in the shieldedstack.worker.provider.throttles metric, tagged with the registry, so reaching the limit shows up before pulls start failing. Set Docker Hub credentials for anything beyond a trial, on a plan whose limit fits how often your machines pull.

hosts.toml files and registries.conf are machine configuration, not repository files. Distribute them with the node image, configuration management, or the CI runner image.

The hosts.toml header and the containers auth file hold the API key. Keep them readable by root only, and rotate them with your configuration management when the key changes.

Terminal window
docker pull cgr.dev/chainguard/static:latest

With /etc/docker/certs.d/cgr.dev/hosts.toml in place, the pull succeeds unchanged and the image appears under Packages with the ecosystem OCI, attributed to the project name on the API key. Its Image Contents tab lists the packages from Chainguard’s signed SBOM.

Pulls still come from the public registry. The configuration is a mirror rather than a replacement: a hosts.toml with an upstream server and the proxy as a [host] entry, or a Podman [[registry.mirror]]. Make the proxy the server or the location, and block direct access to the registry.

401 Unauthorized. The Authorization header or auth file holds the key without the _: prefix, or its base64 encoding has a trailing newline. Generate it with printf, not echo.

ShieldedStack is inspecting this image ... Retry in about a minute. The workspace runs Verify then Trust and the image has not been inspected yet, or the registry could not be reached to check its signed SBOM. Retry the pull. Kubernetes retries image pulls on its own, and its pod events may show only 403 Forbidden while it waits.

denied for an image that pulls elsewhere. The image has no signed SBOM from a publisher the workspace trusts. Allow it with a package rule (see Allowing unattested images), or check the trusted publishers under Settings -> Security Policy.

is an image index when saving a rule. The digest is the one docker pull prints for a multi-platform image. Add a rule for each platform digest instead. See Platform digests.

has no or could not be asked when saving a rule. The registry does not have that digest under that image name, or the Portal could not reach the registry. Check the name and digest, and that the Portal has the registry’s credentials and network access.

describes no image this workspace may pull from cosign, Kyverno, or another verifier. The proxy serves an image’s signatures and attestations only when the workspace may pull the image itself. Pull the image through the proxy to see the reason it is refused. Under Verify then Trust, an image still being inspected refuses its signatures as it refuses its pull; the verifier’s request starts the inspection, so retry it in about a minute. A signature made on an image listing more than 32 platforms is not served.

Docker Hub images always have a namespace when saving a rule. The pattern names a Docker Hub image without its namespace, such as docker.io/nginx. Official images are in the library namespace: write docker.io/library/nginx. See Image names in rules.

NAME_UNKNOWN. The first path segment is not one of the registries listed at the top of this page.

Docker Desktop refuses the proxy. Registry Access Management, if your organization enables it, applies to the proxy host as well. Add <proxy-host> to the allowed registries.

After key rotation. Regenerate the hosts.toml header or rerun podman login and docker login with the new key.