Exploring OCI registries
Handling containers is probably something a modern developer can’t and probably should not live without anymore. They provide flexibility, allow easy packaging and also sandboxing of stuff you might not want to have installed on your machine.
Like so often in tech, using something successfully doesn’t imply real understanding how it works under the hood, but I lived quite happily with this black box and all greasy details shrouded in mysteries hidden behind tooling like Podman. This changed, when I started looking for an artifact store for our firmware binary artifacts. I quickly discovered there are many container registries available, but just a few stores for ordinary artifacts without spending large parts of our engineering budget on enterprise license fees. Passing this question to my bubble lead to a suggestion of a good friend to have a look at ORAS, which leverages OCI-compliant registries for exactly what I wanted to literally archive. We are already using Harbor, so moving other artifacts there as well aroused my interest.
So over the course of this article we are going to dive into the container world with a short primer of the duality of OCI, talk about basic usage and a few advanced points like SBOM and signing and conclude with my impression on the technology.
This post includes several introductional chapters as a deep dive into a specific topic. If you are just here for the examples and how to use the tooling quickly jump ahead and wait for us. |
What is OCI? &
Turns out the Open Container Initiative (OCI) isn’t a single spec by itself, but rather a governance body around several container formats and runtimes - namely:
-
Runtime Specification (runtime-spec)
-
Image Specification (image-spec)
-
Distribution Specification (distribution-spec)
The links lead to the related GitHub projects in case you want to build your own container engine, but I suggest we focus on image-spec, which lays out the structure in all gory details.
Containers inside out &
If you’ve dutifully studied the spec the overall structure of an actual container will probably not surprise you. If not believe me, they are less magically than thought, can be fetched with the help of Podman and easily be dissected on the shell:
$ podman save ghcr.io/oras-project/oras:main -o oras.tar
Copying blob 08000c18d16d done |
...
Writing manifest to image destination
$ tar xvf oras.tar --one-top-level
08000c18d16dadf9553d747a58cf44023423a9ab010aab96cf263d2216b8b350.tar
...
manifest.json
repositories
$ tree oras
oras
├── 08000c18d16dadf9553d747a58cf44023423a9ab010aab96cf263d2216b8b350.tar
...
├── 29ec8736648c6f233d234d989b3daed3178a3ec488db0a41085d192d63321c72
├── json
├── layer.tar -> ../08000c18d16dadf9553d747a58cf44023423a9ab010aab96cf263d2216b8b350.tar
└── VERSION
...
├── manifest.json
└── repositories
6 directories, 23 files
Containers mapped out &
1 | Blobs is the main directory with all adressable filesystem layers and their related metadata defined in the appropriate JSON files config and manifest. The name of the layers are actually digests as well, but to make it easier to follow let us keep the fancy numbers. |
2 | Config contains entries like meta information about author as well as other runtime information like environment variables, entrypoints, volume mounts etc. as well as infos about specific hardware architecture and OS. |
3 | rootfs contains an ordered list of the digests that compose the actual image. |
4 | The manifest just links to the actual configugration by digest and to the layers. |
5 | And finally the index includes all available manifests and also image annotations. |
Mysteries solved, but there is still one essential piece missing - namely media types.
What are media types? &
This surprises probably no one, but media types are also covered by a spec [2] - the media-spec
There you can see the exhaustive list of the known types and an implementor’s todo list for compliance to the specs. Conversely, this also means as long as we pick something different we are free to fill layers with anything to our liking without triggering a certain behaviour accidentally.
Use-Cases &
The next few examples require an OCI-compatible registry and also access to the binaries of oras and cosign and some more. Since installation is usually a hassle, all examples rely on Podman and the well-supported Zot Registry.
Firing up Zot &
Setting up our registry is just a piece of cake and shouldn’t raise any eyebrows yet. We pretty much set just the bare essentials - deliberately without any hardening for actual logins.
$ podman run --rm -it --name zot-registry -p 5000:5000 --network=host \
-v ./infrastructure/zot-registry/config.json:/etc/zot/config.json \ (1)
ghcr.io/project-zot/zot-linux-amd64:v2.1.2
1 | Apart from host stuff we also want to enable the fancy web UI and the CVE
scanner - have a glimpse how this can be done on GitHub: https://github.com/unexist/showcase-oci-registries/blob/master/infrastructure/zot-registry/config.json |
Once started and after Trivy's update of the vulnerabilities is done we are dutifully greeted with an empty list:

Time to push our first artifact!
Pushing a binary artifact &
Ultimately I want to push embedded software artifacts to the registry, but since this is public and my own project heos-dial isn’t ready yet we are pushing a binary of the Golang version of my faithful todo service:
$ podman run --rm -v .:/workspace -it --network=host \ (1)
ghcr.io/oras-project/oras:main \
push localhost:5000/todo-service:latest \
--artifact-type showcase/todo-service \ (2)
--plain-http \ (3)
todo-service/todo-service.bin:application/octet-stream
✓ Uploaded todo-service/todo-service.bin 26.1/26.1 MB 100.00% 32ms
└─ sha256:cc8ab19ee7e1f1f7d43b023317c560943dd2c15448ae77a83641e272bc7a5dbc
✓ Uploaded application/vnd.oci.empty.v1+json (4)
└─ sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a
✓ Uploaded application/vnd.oci.image.manifest.v1+json
└─ sha256:fb1f02fff7f1406ae3aa2d9ebf3f931910b69e99c95e78e211037f11ec8f1eb6
Pushed [registry] localhost:5000/todo-service:latest
ArtifactType: showcase/todo-service
Digest: sha256:fb1f02fff7f1406ae3aa2d9ebf3f931910b69e99c95e78e211037f11ec8f1eb6
1 | The ORAS container allows us to call it this way and directly pass our arguments. |
2 | Here we set our custom artifact type, to be able to distinguish it. |
3 | No need to make our live miserable with SSL/TLS! |
4 | This isn’t a real container, so we must provide a https://oras.land/docs/how_to_guides/manifest_config/[dummy config}. |
Pull it back &
One-way-success, time to get it back:
Naively with Podman &
Pulling images from container registries is one of the core tasks of Podman:
$ podman pull localhost:5000/todo-service:latest
Trying to pull localhost:5000/todo-service:latest...
Error: parsing image configuration: unsupported image-specific operation on artifact with type "showcase/todo-service" (1)
1 | Unsurprisingly Podman doesn’t understand our custom artifact type and hence refuses to do our bidding. |
If Podman cannot connect to your local registry and bails out with
|
Confidently with ORAS &
Let us try again - this time with ORAS.
$ podman run --rm -v .:/workspace -it --network=host \
ghcr.io/oras-project/oras:main \
pull localhost:5000/todo-service:latest --plain-http
✓ Pulled todo-service/todo-service.bin 26.1/26.1 MB 100.00% 38ms
└─ sha256:cc8ab19ee7e1f1f7d43b023317c560943dd2c15448ae77a83641e272bc7a5dbc
✓ Pulled application/vnd.oci.image.manifest.v1+json 586/586 B 100.00% 66µs
└─ sha256:fb1f02fff7f1406ae3aa2d9ebf3f931910b69e99c95e78e211037f11ec8f1eb6
Pulled [registry] localhost:5000/todo-service:latest
Digest: sha256:fb1f02fff7f1406ae3aa2d9ebf3f931910b69e99c95e78e211037f11ec8f1eb6
$ tree todo-service
todo-service
└── todo-service.bin
1 directory, 1 file
Print information about the image &
There are several commands available to gather information about images on the registry.
Fetch the manifest &
$ podman run --rm -v .:/workspace -it --network=host \
ghcr.io/oras-project/oras:main \
manifest fetch --pretty --plain-http \
localhost:5000/todo-service:latest
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"artifactType": "showcase/todo-service",
"config": {
"mediaType": "application/vnd.oci.empty.v1+json", (1)
"digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
"size": 2,
"data": "e30="
},
"layers": [
{
"mediaType": "application/octet-stream",
"digest": "sha256:cc8ab19ee7e1f1f7d43b023317c560943dd2c15448ae77a83641e272bc7a5dbc",
"size": 27352532,
"annotations": { (2)
"org.opencontainers.image.title": "todo-service/todo-service.bin"
}
}
],
"annotations": {
"org.opencontainers.image.created": "2025-06-04T11:57:57Z"
}
}
1 | This is our empty dummy config - check the size and data fields. |
2 | Annotations are supported as well and can be added with oras push --annotation. |
Discover the tree &
$ podman run --rm -v .:/workspace -it --network=host \
ghcr.io/oras-project/oras:main \
discover --format tree --plain-http \
localhost:5000/todo-service:latest
localhost:5000/todo-service@sha256:fb1f02fff7f1406ae3aa2d9ebf3f931910b69e99c95e78e211037f11ec8f1eb6
What is an SBOM? &
A software bill of materials or SBOM is a kind of inventory list of an artifact, which details included software components and assists in securing the software supply chain. This gets more and more attention as it should especially since the log4j vulnerability back then in 2020 and 2021.
There are different formats for SBOM files like SPDX or CycloneDX and also a broad range of tools that support one or more of them as input and output is available.
I am kind of fond[3] of Anchore with their tools syft and grype and therefore the next examples are going to make use of both of them.
Syfting through &
Since my todo service is based on Golang syft can easily scan the source code and assemble our SBOM
$ podman run --rm -v .:/workspace -it --network=host \
-v ./todo-service:/in \
docker.io/anchore/syft:latest \
scan dir:/in -o cyclonedx-json=/workspace/sbom.json (1)
✔ Indexed file system /in
✔ Cataloged contents 86121fea66864109267c361a1fec880ab49dc5f619205b1f364ecb7ba31eb066
├── ✔ Packages [70 packages]
├── ✔ Executables [1 executables]
├── ✔ File digests [1 files]
└── ✔ File metadata [1 locations]
[0000] WARN no explicit name and version provided for directory source, deriving artifact ID from the given path (which is not ideal)
A newer version of syft is available for download: 1.26.1 (installed version is 1.26.0) (2)
$ cat sbom.json | jq '.components | length' (3)
71
1 | My pick is entirely based on the cool name though. |
2 | Interesting since I am using the latest tag. |
3 | Quite a lot of components.. |
Scanning for vulnerabilities &
Like Trivy, grype can easily scan from inside a container and provide machine-readable statistics by default:
$ podman run --rm -v .:/workspace -it --network=host \
docker.io/anchore/grype:latest \
sbom:/workspace/sbom.json
✔ Vulnerability DB [updated]
✔ Scanned for vulnerabilities [9 vulnerability matches]
├── by severity: 1 critical, 2 high, 6 medium, 0 low, 0 negligible
└── by status: 9 fixed, 0 not-fixed, 0 ignored
NAME INSTALLED FIXED-IN TYPE VULNERABILITY SEVERITY EPSS% RISK
golang.org/x/crypto v0.15.0 0.17.0 go-module GHSA-45x7-px36-x8w8 Medium 98.45 36.5
golang.org/x/net v0.18.0 0.23.0 go-module GHSA-4v7x-pqxf-cx7m Medium 98.35 33.4
golang.org/x/crypto v0.15.0 0.31.0 go-module GHSA-v778-237x-gjrc Critical 96.91 32.6
google.golang.org/protobuf v1.31.0 1.33.0 go-module GHSA-8r3f-844c-mc37 Medium 46.14 0.1
github.com/jackc/pgx/v5 v5.4.3 5.5.4 go-module GHSA-mrww-27vc-gghv High 38.06 0.1
golang.org/x/crypto v0.15.0 0.35.0 go-module GHSA-hcg3-q754-cr77 High 15.90 < 0.1
golang.org/x/net v0.18.0 0.38.0 go-module GHSA-vvgc-356p-c3xw Medium 5.05 < 0.1
golang.org/x/net v0.18.0 0.36.0 go-module GHSA-qxp5-gwg8-xv66 Medium 1.24 < 0.1
github.com/jackc/pgx/v5 v5.4.3 5.5.2 go-module GHSA-fqpg-rq76-99pq Medium N/A N/A
Attaching our SBOM &
If we are content with the scanning result[4] let us quickly add this to our image:
$ podman run --rm -v .:/workspace -it --network=host \
ghcr.io/oras-project/oras:main \
attach localhost:5000/todo-service:latest --plain-http \
--artifact-type showcase/sbom \ (1)
sbom.json:application/vnd.cyclonedx+json
✓ Uploaded sbom.json 50.1/50.1 KB 100.00% 2ms
└─ sha256:0690e255a326ee93c96bf1471586bb3bc720a1f660eb1c2ac64bbf95a1bd9693
✓ Exists application/vnd.oci.empty.v1+json 2/2 B 100.00% 0s
└─ sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a
✓ Uploaded application/vnd.oci.image.manifest.v1+json 724/724 B 100.00% 3ms
└─ sha256:5c6bb144aaed7d3e4eb58ac6bcdbf2a68d0409d5328f81c9d413e9301e2517a9
Attached to [registry] localhost:5000/todo-service@sha256:fb1f02fff7f1406ae3aa2d9ebf3f931910b69e99c95e78e211037f11ec8f1eb6
Digest: sha256:5c6bb144aaed7d3e4eb58ac6bcdbf2a68d0409d5328f81c9d413e9301e2517a9
1 | This gave me a bit of a headache, because Zot supports SBOM scanning and also propagates the results on the web UI - see the sidepanel for more information. |
Discover our changes &
And if we run discover again we can see there is a new layer:
$ podman run --rm -v .:/workspace -it --network=host \
ghcr.io/oras-project/oras:main \
discover --format tree --plain-http \
localhost:5000/todo-service:latest
localhost:5000/todo-service@sha256:fb1f02fff7f1406ae3aa2d9ebf3f931910b69e99c95e78e211037f11ec8f1eb6
└── showcase/sbom
└── sha256:5c6bb144aaed7d3e4eb58ac6bcdbf2a68d0409d5328f81c9d413e9301e2517a9
└── [annotations]
└── org.opencontainers.image.created: "2025-06-04T12:40:38Z"
Speaking about security: Just adding images without means of verification if this is the real deal apart from the checksum doesn’t make too much sense too me.
I think the why should be clear, let us talk about how.
Image signing &
Needless to say topics like encryption, signatures etc. are usually pretty complicated, so I can gladly there exists lots of tooling to ease this for us dramatically. I did the homework for us in preparation for this post and checked our options. While doing that I found lots of references to notary and {skopeo[skopeo], but the full package and overall documentation of cosign just convinced me and it can basically sign anything in a registry.
In this last chapter we are going to sign our image and specific layers via in-toto attestations with the help of cosign.
Signing the image &
Cosign comes with lots of useful commands to create and manage identities, signatures and whatnot, but in the most convenient way it just allows us to select from a list of supported identity provider in our browser per runtime:
$ podman run --rm -v .:/workspace --network=host \
ghcr.io/sigstore/cosign/cosign:v2.4.1 \
sign --yes \
localhost:5000/todo-service:latest
Generating ephemeral keys...
Retrieving signed certificate...
Non-interactive mode detected, using device flow.
Enter the verification code xxxx in your browser at: https://oauth2.sigstore.dev/auth/device?user_code=xxxx (1)
Code will be valid for 300 seconds
Token received!
Successfully verified SCT...
...
By typing 'y', you attest that (1) you are not submitting the personal data of any other person; and (2) you understand and agree to the statement and the Agreement terms at the URLs listed above. (2)
tlog entry created with index: 230160511
Pushing signature to: localhost:5000/todo-service
1 | Quickly follow the link and pick one of your liking - we continue with Github here. |
2 | Glad we added --yes - interactivity in container is usually a pain. |
And when we check the web UI we can see there is a bit of progress:

Relying on Zot is nice and good, but there are other ways to do that.
Verification of the image &
It all boils down to another simple call of cosign:
$ podman run --rm -v .:/workspace --network=host \
ghcr.io/sigstore/cosign/cosign:v2.4.1 \
verify \
--certificate-oidc-issuer=https://github.com/login/oauth \ (1)
--certificate-identity=christoph@unexist.dev \
localhost:5000/todo-service:latest | jq ".[] | .critical" (2)
Verification for localhost:5000/todo-service:latest --
The following checks were performed on each of these signatures: (3)
- The cosign claims were validated
- Existence of the claims in the transparency log was verified offline
- The code-signing certificate was verified using trusted certificate authority certificates
{
"identity": {
"docker-reference": "localhost:5000/todo-service"
},
"image": {
"docker-manifest-digest": "sha256:fb1f02fff7f1406ae3aa2d9ebf3f931910b69e99c95e78e211037f11ec8f1eb6"
},
"type": "cosign container image signature"
}
1 | There are several options for verification available - we just rely on issuer and mail. |
2 | Apparently this critical is nothing of concern and a format specificed by RedHat. |
3 | This is a short summary of the checks that have been performed during the verification. |
Just as a negative test this is how it looks like when the verification actually fails:
$ podman run --rm -v .:/workspace --network=host \
ghcr.io/sigstore/cosign/cosign:v2.4.1 \
verify \
--certificate-oidc-issuer=https://github.com/login/oauth \
--certificate-identity=anon@anon.rs \
localhost:5000/todo-service:latest
Error: no matching signatures: none of the expected identities matched what was in the certificate, got subjects [christoph@unexist.dev] with issuer https://github.com/login/oauth
main.go:69: error during command execution: no matching signatures: none of the expected identities matched what was in the certificate, got subjects [christoph@unexist.dev] with issuer https://github.com/login/oauth
First step done - step two is to sign our SBOM as well.
Create an in-toto attestation &
If you have made it this far in this post I probably shouldn’t bore you with another spec about in-toto or the framework around it and just provide the examples:
$ DIGEST=`podman run --rm -v .:/workspace -it --network=host \
ghcr.io/oras-project/oras:main \
discover --format json --plain-http \
localhost:5000/todo-service:latest | jq -r ".referrers[].reference"` (1)
$ podman run --rm -v .:/workspace --network=host \
ghcr.io/sigstore/cosign/cosign:v2.4.1 \
attest --yes \ (2)
--type cyclonedx \ (3)
--predicate /workspace/sbom.json \
$DIGEST
Generating ephemeral keys...
Retrieving signed certificate...
Non-interactive mode detected, using device flow.
Enter the verification code xxxx in your browser at: https://oauth2.sigstore.dev/auth/device?user_code=xxxx
Code will be valid for 300 seconds
Token received!
Successfully verified SCT...
Using payload from: /workspace/sbom.json
...
By typing 'y', you attest that (1) you are not submitting the personal data of any other person; and (2) you understand and agree to the statement and the Agreement terms at the URLs listed above.
using ephemeral certificate:
-----BEGIN CERTIFICATE-----
LOREMIPSUMDOLORSITAMETCONSECTETURADIPISCINGELIT
MORBIIDSODALESESTVIVAMUSVOLUTPATSODALESTINCIDUNT
...
-----END CERTIFICATE-----
tlog entry created with index: 232176597
1 | We need the digest to identify our artifact for the next steps - so please keep it at hand. |
2 | Don’t forget to deal with the interactive prompt here. |
3 | Some information about type and name of what cosign is supposed to attest. |
cosign still supports the older command attach sbom to attach artifacts, but the it is deprecated and it is generally advised to use proper attestations. There is a heaty debate about its status and maturity though. |
Download attestation &
As mentiond before this is complex, so let us have a closer look at what we can actually get back.
$ podman run --rm -v .:/workspace --network=host \
ghcr.io/sigstore/cosign/cosign:v2.4.1 \
download attestation \
$DIGEST | jq "del(.payload)" (1)
{
"payloadType": "application/vnd.in-toto+json", (2)
"signatures": [
{
"keyid": "",
"sig": "MEYCIQDE4/CeQstLjHLE+ZQ+BCH+aaw2wSWSr9i26d7iuazXrwIhAPtly5XBD6C14s/78vTjuHdLOjj2a9TeSgs0yD6YRrZd"
}
]
}
1 | We omit the payload data here - feel free to dump your own base64 blob |
2 | This is the actual type of the payload that has been transmitted. |
If you want to see the actual content of the payload here is a small exercise for you:
$ podman run --rm -v .:/workspace --network=host \
ghcr.io/sigstore/cosign/cosign:v2.4.1 \
download attestation \
$DIGEST | jq -r .payload | base64 -d | jq .predicate
Verification of the attestation &
And lastly in the same manner as before the attestation can also be verified by the means of cosign:
$ podman run --rm -v .:/workspace --network=host \
ghcr.io/sigstore/cosign/cosign:v2.4.1 \
verify-attestation \
--type cyclonedx \
--certificate-oidc-issuer=https://github.com/login/oauth \
--certificate-identity=christoph@unexist.dev \
$DIGEST | jq ".[] | .critical"
podman run --rm -v .:/workspace --network=host \
ghcr.io/sigstore/cosign/cosign:v2.4.1 \
verify-attestation \
--type cyclonedx \ (1)
--certificate-oidc-issuer=https://github.com/login/oauth \
--certificate-identity=christoph@unexist.dev \
$DIGEST > /dev/null (2)
Verification for localhost:5000/todo-service@sha256:5c6bb144aaed7d3e4eb58ac6bcdbf2a68d0409d5328f81c9d413e9301e2517a9 --
The following checks were performed on each of these signatures:
- The cosign claims were validated
- Existence of the claims in the transparency log was verified offline
- The code-signing certificate was verified using trusted certificate authority certificates
Certificate subject: christoph@unexist.dev
Certificate issuer URL: https://github.com/login/oauth
1 | Here we pass some expectations to the checks. |
2 | We don’t want to see the exact same content from the previous step again. |
Passing bogus information or trying to verify the wrong digest leads to an error:
podman run --rm -v .:/workspace --network=host \
ghcr.io/sigstore/cosign/cosign:v2.4.1 \
verify-attestation \
--type cyclonedx \
--certificate-oidc-issuer=https://github.com/login/oauth \
--certificate-identity=anon@anon.rs \
$DIGEST > /dev/null
Error: no matching attestations: none of the expected identities matched what was in the certificate, got subjects [christoph@unexist.dev] with issuer https://github.com/login/oauth
main.go:74: error during command execution: no matching attestations: none of the expected identities matched what was in the certificate, got subjects [christoph@unexist.dev] with issuer https://github.com/login/oauth
Phew that was quite lengthy to reach this point, time for a small recap.
Conclusion &
During the course of this post we have seen how OCI-registries can be leveraged to store almost any kind of artifact. The layered structure and format allows to add additional metadata and ancillary artifacts like Helm-charts can be put there to rest as well.
Bill of materials allow quick scan of layers for known vulnerabilities and combined with proper signing can the security of the supply chain be further strengthened. Alas this is also no silver bullet and takes lots of work to get it right in automatic workflows.
I personally think this is a great addition, solves my initial hunt for artifact storage and also eases the handling of all the dependencies of different kind of artifacts in a more secure way. Next stop for me is to compile all this into a shiny new Architecture Decision Record. and discuss is with my team.
All examples can be found here hidden in the taskfiles: