“All problems in computer science can be solved by another level of indirection”
David Wheeler
What’s it about?
Almost every team today runs a container registry: Artifactory, AWS, Azure, GitHub, GitLab, Harbor, or a self-hosted one. However, many use them only for Docker images.
An OCI registry, however, can do more. It stores any type of file, versions them using tags, makes them immutable via digests, and handles authentication and permissions. These are exactly the features you’d want for many other artifacts as well:
- Configuration Packages
- Data Exports
- Reports
- All Types of ZIP Archives
- Family Trees
When I was once again faced with the question of where to store my artifacts and how to distribute them, the usual options came to mind: a network drive, an S3 bucket, or my own download endpoint. In the end, I didn’t go with any of them. I used the existing registry and ORAS Java.
What is ORAS?
ORAS stands for OCI Registry As Storage and is a CNCF project. It uses the OCI specification to store any artifacts in a registry. Helm charts, SBOMs, and signatures are already distributed this way today.
Most people are familiar with the command-line tool oras. Less well known is the Java SDK, which allows you to access a registry directly from Java, without shell commands or a Docker daemon.
implementation 'land.oras:oras-java-sdk:0.8.5'
Setup: a builder, done
It all starts with a registry client. The SDK provides a builder for this purpose. You can quickly integrate your own CA certificates and Basic Auth:
Registry registry = Registry.builder()
.withRegistry("registry.██████.ai")
.withCaFile(Path.of("/etc/ssl/my-ca.crt"))
.withAuthProvider(new UsernamePasswordProvider("archivar", "██████"))
.build();
I like that there’s no magic involved. That fits well with Spring Boot: You register the client as a @Bean, read the values from @ConfigurationProperties, and you’re all set.
Pushing a Family Tree Report
Uploading is essentially a single method call. The artifact is assigned its own artifact type and, optionally, the standard annotations from the OCI specification:
ContainerRef ref = ContainerRef.parse("registry.██████.ai/genealogy/report:1.4.0");
Manifest manifest = registry.pushArtifact(
ref,
ArtifactType.from("application/vnd.██████.report.v1+zip"),
Annotations.ofManifest(Map.of("org.opencontainers.image.version", "1.4.0")),
null,
OCI.PushOptions.defaults(),
LocalPath.of(Path.of("build/report.zip"), "application/zip"));
The result is a manifest with a digest. Anyone who needs the artifact can retrieve it using oras pull, another ORAS client, or Java again. The format is open and standardized.
The real trick: Using tags as a form of indirection
Now the quote from the beginning comes into play. Tags in a registry are simply pointers to a manifest. A manifest can have any number of tags.
This results in a simple promotion workflow:
- Each artifact is pushed under a fixed version number, for example, 1.4.0.
- In addition, a dynamic tag such as “draft” points to the latest version.
- After a successful review, another tag—such as “final”—points to the same manifest.
Nothing needs to be re-uploaded for the “promotion.” A new tag on the existing manifest is sufficient:
Manifest manifest = registry.getManifest(ContainerRef.parse(repo + ":draft")); registry.pushManifest(ContainerRef.parse(repo + ":final"), manifest);
That’s two lines of code, and the digest remains the same. This ensures that the exact artifact that was previously verified—byte for byte—ends up in production.
Error Handling
The SDK throws errors from the registry as OrasExceptions. The HTTP status code is included. A missing tag can thus be easily translated into a business-specific exception:
try {
manifest = registry.getManifest(ref);
} catch (OrasException e) {
if (Integer.valueOf(404).equals(e.getStatusCode())) {
throw new ArtifactNotFoundException(ref);
}
throw e;
}
Pitfalls
A few things caught my attention while working with the SDK:
- BouncyCastle: The SDK uses BouncyCastle internally, including for digest calculation. If other BouncyCastle modules are present in the project—such as bcpkix in tests—the versions must match exactly. Otherwise, the provider initialization will fail, and the error message isn’t very helpful.
- New SDK: The version number still starts with 0.x. The API may change between minor versions. It’s worth checking the release notes before updating.
- Local testing: A registry runs quickly in a container, such as registry:2 or Zot. With TestContainers, you can write integration tests that interact directly with a real registry. This is much more convenient than using mocks.
My Conclusion
ORAS Java solved an infrastructure problem for me that I didn’t want to deal with at all. Instead of setting up yet another storage system, I’m using a registry that’s already up and running, backed up, and monitored. On top of that, I get the following for free:
- versioning,
- immutable digests,
- access control,
- and a clean promotion workflow via tags.
The API is lean and easy to read. It can be integrated with a Spring Boot application in no time.
My tip: The next time you’re thinking about where to store family trees, exports, or other artifacts, first check to see if you already have a registry. Chances are, you do.
For more information, visit https://oras.land and the GitHub repository oras-project/oras-java.