How I Built an Alternative to Docker Registries Using Just an S3 Bucket


Introduction

Ordinary working day. I pushed a deployment to EKS, went for coffee, came back, and the pod was still sitting in ContainerCreating. Image around 2 GB. ECR was taking its sweet time.

Fine, happens. Except it happened again the next deploy. And the one after that.

So I started digging, and somewhere along the way I found an article where someone had tested keeping Docker images directly on S3. The pull numbers were kind of ridiculous, up to 100 Gbps on EC2, because that is just what S3 gives you inside AWS. Storage was roughly a quarter of the ECR price too.

My first thought was that surely a tool for this already exists. It did not. Or at least I could not find one 😁

So I built it.

Why registries were annoying me

ECR, Docker Hub, GHCR are fine. I am not here to tell you they are bad. But if you sit inside AWS and pull big images all day, the small annoyances stack up:

ECR / Docker Hubs3lo
Pull speed~1-5 Gbpsup to 100 Gbps on EC2
Storage cost$0.10/GB/month$0.023/GB/month (S3 Standard)
Cloud supportvendor-specificS3, GCS, Azure Blob, MinIO, R2, Ceph
Registry to manageyesit is a bucket

The speed thing is not magic. EC2 and EKS nodes talk to S3 over AWS internal network, and a registry endpoint sitting behind a load balancer simply cannot compete with that.

The part I underrated at first was the simplicity. Nothing to provision. No registry to keep alive. A bucket and an IAM policy, done.

So what is s3lo

A CLI written in Go. It pushes and pulls Docker images to anything S3-compatible: AWS S3, Google Cloud Storage, Azure Blob, MinIO, Cloudflare R2, Ceph, or a folder on your laptop.

Under the hood it uses the OCI Image Layout, which is the boring standard choice and exactly why it works well. One layer, one S3 object. Layers move in parallel, and identical layers are stored once because they are addressed by their SHA256. Nothing proprietary, any OCI-aware tool can read what is in the bucket.

Later I added signing (cosign and AWS KMS), because “prove the image running in prod is the one your CI built” is a question that comes up in every audit I have been through.

Getting started

# Homebrew (macOS/Linux)
brew install OuFinx/tap/s3lo

# Or quick install
curl -sSL https://raw.githubusercontent.com/OuFinx/s3lo/main/install.sh | sh

Push and pull look how you would expect:

# Push a local image to S3
s3lo push myapp:v1.0 s3://my-bucket/myapp:v1.0

# Pull it back
s3lo pull s3://my-bucket/myapp:v1.0

# Grab one platform out of a multi-arch image
s3lo pull s3://my-bucket/myapp:v1.0 --platform linux/amd64

# See what is in the bucket
s3lo list s3://my-bucket/

Auth goes through the normal AWS credential chain: env vars, ~/.aws/credentials, instance profiles, SSO. If aws s3 ls works for you, so does this.

Different cloud? Same commands, different prefix:

# Google Cloud Storage
s3lo push myapp:v1.0 gs://my-gcs-bucket/myapp:v1.0

# Azure Blob
AZURE_STORAGE_ACCOUNT=mystorageaccount s3lo push myapp:v1.0 az://my-container/myapp:v1.0

# MinIO or anything S3-compatible
s3lo push myapp:v1.0 s3://my-bucket/myapp:v1.0 --endpoint http://localhost:9000

Pulling images from other registries

This is the command I use most, honestly. copy moves an image from Docker Hub, ECR, GHCR or another bucket straight into S3 without the local Docker daemon touching anything.

# From Docker Hub
s3lo copy nginx:latest s3://my-bucket/nginx:latest

# From ECR, using your existing AWS credentials
s3lo copy 123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:v1.0 s3://my-bucket/myapp:v1.0

# Between two buckets, staging to prod
s3lo copy s3://staging-bucket/myapp:v1.0 s3://prod-bucket/myapp:v1.0

Multi-arch images copy every platform by default. If you want just one:

s3lo copy docker.io/library/alpine:latest s3://my-bucket/alpine:latest --platform linux/arm64

Before this existed I was pulling locally and re-pushing, which meant a daemon, disk space and about three minutes of my life per image. Not great in CI.

What is actually in there

A few months in I genuinely did not know what was eating space, or whether deduplication was doing anything at all. Two commands answer that.

inspect opens up a single tag:

s3lo inspect s3://my-bucket/myapp:v1.0
Reference: s3://my-bucket/myapp:v1.0
Type:      single-arch image
Layers:    6
Total:     312.40 MB

  [1] sha256:9b18e9b68314... (78.21 MB)
  [2] sha256:2f4a5c8d1e77... (54.03 MB)
  ...

Signatures:
  release-signer  (key: awskms:///alias/release-signer, signed: 2026-04-15T10:22:31Z)

s3lo bucket stats zooms out, and this is where dedup stops being theoretical:

s3lo bucket stats s3://my-bucket/
Bucket: s3://my-bucket/

Images:       12
Tags:         47
Storage:      4.1 GB across 89 unique blobs
Dedup savings: 8.3 GB (67.0% — without dedup: 12.4 GB)

Estimated monthly cost:
  S3 (current):              $0.09/month
  S3 (no dedup):             $0.29/month
  ECR equivalent:            $0.41/month
  Savings vs ECR:            $0.32/month (78% cheaper)

That 67% is not clever engineering on my part. It is just that all your images sit on the same Ubuntu or Alpine or Node base, and s3lo keeps one copy of each layer instead of forty.

Keeping the bucket from rotting

Left alone, a bucket collects old tags until the end of time. So there are lifecycle rules, per image or for the whole bucket:

# Keep the last 10 tags per image, drop anything older than 90 days
s3lo config set s3://my-bucket/ lifecycle.keep_last=10 lifecycle.max_age=90d

# Dry run, nothing is deleted
s3lo bucket clean s3://my-bucket/

# For real this time
s3lo bucket clean s3://my-bucket/ --confirm

clean prunes tags according to those rules and then collects the blobs nobody references anymore. Skip it for long enough and the dedup savings above quietly stop being true.

Browsing without typing

Running list and then inspect in a loop got old, so I built a TUI:

s3lo tui s3://my-bucket/

You get every image with sizes and tag counts, drill into one to see its tags, timestamps and live stats on the right. From there you can delete a tag, inspect it, scan it for vulnerabilities, or hit g for a layer sharing matrix that shows which layers are shared across which tags.

It is not going to win a design award, but poking around a bucket is much less painful now.

Signing

Probably the feature I am happiest with. If you have compliance people, or you just want to know an image was not swapped somewhere along the way:

# Sign with AWS KMS, which also leaves a CloudTrail trail
s3lo security sign s3://my-bucket/myapp:v1.0 --key awskms:///alias/release-signer

# Verify before you deploy
s3lo security verify s3://my-bucket/myapp:v1.0 --key awskms:///alias/release-signer

Exit codes are 0 for valid, 1 for invalid or missing, 2 for infrastructure trouble, so wiring it into a CI gate is one line.

A local cosign key works too:

COSIGN_PASSWORD=secret s3lo security sign s3://my-bucket/myapp:v1.0 --key cosign.key
s3lo security verify s3://my-bucket/myapp:v1.0 --key cosign.pub

The same security group also has scan (Trivy under the hood) and sbom, if you need those in the pipeline.

From CI

Most pushes happen from CI anyway, not from anyone’s laptop. Minimal GitHub Actions step:

- name: Push image to S3
  run: |
    curl -Lo s3lo.tar.gz https://github.com/OuFinx/s3lo/releases/latest/download/s3lo_linux_amd64.tar.gz
    tar xzf s3lo.tar.gz && chmod +x s3lo
    ./s3lo push myapp:${{ github.sha }} s3://my-bucket/myapp:${{ github.sha }}

For AWS auth I use OIDC, so there are no long-lived keys sitting in secrets. A role that trusts GitHub’s OIDC provider plus the bucket policy and you are done.

Mirroring on release fits here too:

- run: ./s3lo copy nginx:${{ env.NGINX_VERSION }} s3://my-bucket/nginx:${{ env.NGINX_VERSION }}

Kubernetes

Pulling images by hand is nice, but the whole point was getting them into a cluster without rebuilding how containerd works.

That is s3lo-operator. A DaemonSet that runs a small OCI proxy on every node. Containerd is pointed at it through its own hosts.toml, so s3.local requests go to the local proxy, which turns them into S3 GetObject calls.

Pod: image: s3.local/my-bucket/myapp:v1.0
  → containerd → hosts.toml → localhost proxy
    → s3lo-proxy → S3 GetObject
  → container starts

No containerd restart, no patching anything. Helm install:

helm install s3lo-operator deploy/helm/s3lo-operator \
  --namespace s3lo \
  --create-namespace \
  --set image.tag=1.4.1

Then just reference the image:

containers:
  - name: app
    image: s3.local/my-bucket/myapp:v1.0

IAM is an EKS Pod Identity role attached to the s3lo-proxy service account. Nothing exciting there, which is the point.

When you do not want the operator

Sometimes you need an OCI endpoint for ten minutes. Local dev, one node, testing before you commit to installing anything.

s3lo serve is an HTTP server that speaks the OCI Distribution Spec, so Docker and containerd can pull from it directly:

# Start it
s3lo serve s3://my-bucket/ --port 5000

# Pull with plain Docker
docker pull localhost:5000/myapp:v1.0

For S3 backends the blobs come back as a presigned redirect, so the client downloads straight from S3 and none of the data goes through the server.

No cloud account at all

There is also a local:// backend, same OCI layout, no credentials needed:

# Point s3lo at a directory
s3lo bucket init --local ./local-s3

# Everything else is identical
s3lo push myapp:v1.0 local://./local-s3/myapp:v1.0
s3lo pull local://./local-s3/myapp:v1.0
s3lo tui local://./local-s3/

This is how I test almost everything while developing. It also covers air-gapped setups and the case where you want to try s3lo before creating a bucket.

Conclusion

It started as irritation at a slow pull and turned into a tool I now use every day. One bucket, no registry to babysit, and pulls that finish before the coffee does.

Both projects are open source: s3lo and s3lo-operator.

If you try it and something breaks, tell me below 🚀


Changes History

9af7f21docs: rewrite s3lo article in author voice and fix outdated commands (#13)(2 days ago)
90fce65feat(seo): add robots.txt, og:image, JSON-LD and single h1 per post (#12)(2 days ago)
694df12feat: add s3lo article - alternative to Docker registries using S3 (#11)(3 months ago)

Comments