case · 01
A self-hosted, OCI-compliant container registry
Harbor and ECR's feature set — signing, scanning, SSO, audit — self-hosted, no cloud bill. Single-tenant by default; multi-tenant when you need it.
- 2026
- 75/75 OCI v1.1 conformance · 13 services · mTLS between every hop
- github.com/steveokay/oci-janus
Problem
If you want a container registry with real features — vulnerability scanning, image signing, RBAC, SSO, a tamper-evident audit trail — but you also want to own the infrastructure, your options are bad. distribution/distribution (the reference registry) does push/pull and almost nothing else, so you end up gluing on a handful of side services and maintaining the seams. Harbor gives you the features but is a heavy, multi-component deployment. ECR scopes to a single AWS account and bills per use. Docker Hub and Quay are SaaS — someone else’s rate limits, someone else’s outage.
I built one to find out what a self-hosted registry with the feature scope of Harbor or ECR actually costs to build and run in 2026 — and whether the same codebase could serve both a one-team internal deploy and a SaaS operator who genuinely needs tenant isolation.
Constraints
- OCI Distribution Spec v1.1, end-to-end.
docker,podman,buildkit,skopeo,crane— all of them must Just Work. No bespoke surface area; no client-side shimming. - Self-hosted first; single-tenant by default. The common case is one team running it on their own infrastructure.
DEPLOYMENT_MODE=singleships that out of the box — one tenant, no SaaS chrome. Multi-tenant (DEPLOYMENT_MODE=multi) is the same binary and schema, opted into by operators who need it. - Isolation that fails closed. When multi-tenant is on,
Hostheader → tenant, and isolation is two-layer: application code passestenant_idon every query, and PostgreSQL row-level security enforces it at the DB, so a forgotten filter fails closed instead of leaking. - Pluggable storage. Operators pick MinIO, S3, GCS, Azure Blob, or filesystem at deploy time. The registry doesn’t care.
- Assume any one service is compromised. Audit logs survive a malicious app. mTLS between every internal hop, not just at the edge. Distroless containers, no shell, nothing to drop into.
- Local dev is
docker compose up. Production is one Helm chart. Nothing else.
Architecture
Thirteen Go services as a small mesh — TLS terminating at a Traefik gateway, sync paths over gRPC + mTLS, async fan-out over a RabbitMQ topic exchange (registry.events):
request path
docker · crane
gateway
core
gRPC + mTLS mesh
auth
metadata
storage
signer
tenant
proxy
async · rabbitmq topic exchange
webhook
audit
gc
scanner
data plane · postgres (rls) · redis · minio / s3 / gcs / azure
- Edge.
gateway— Traefik v3 with TLS, host-based routing, rate-limiting. - Data plane.
core(OCI/v2/*push / pull / delete / list);storage(gRPC-streaming blob driver over the pluggable backends);proxy(pull-through cache for upstream registries). - Control plane — gRPC + mTLS.
auth(JWT RS256 with hot key-rotation + JWKS, Argon2id API keys, OAuth 2.0 + PKCE and SAML 2.0 SSO, RBAC);metadata(PostgreSQL source of truth for repos / tags / manifests);signer(Cosign + Notary v2, Vault-backed keys);tenant(tenant CRUD + custom-domain verification);management(REST BFF for the React dashboard, CLI, and Terraform). - Async — RabbitMQ topic exchange.
webhook(HMAC-signed retries on a 5s / 30s / 5m / 30m / 2h ladder, SSRF block-list);audit(append-only,FORCE ROW LEVEL SECURITY, per-tenant SHA-256 hash chain, dedicated INSERT-only Postgres role);gc(mark-sweep with per-tenant advisory locks);scanner(external-process JSON-RPC into Trivy by default, Grype / Clair adapters). - Data plane. PostgreSQL 16 with RLS, Redis 7, RabbitMQ quorum queues with DLX, MinIO / S3 / GCS / Azure / filesystem.
- Observability. OpenTelemetry → Jaeger / Tempo / Datadog; Prometheus on a dedicated port; structured
slogJSON withtrace_id/tenant_idon every line.
A read-only MCP server also ships, exposing registry state as Model Context Protocol tools for Claude Desktop / Cursor.
The two design choices I’d defend hardest:
External-process scanner plugins, not Go .so plugins. exec.CommandContext, SHA-256 binary checksum, explicit env allowlist, 10 MB stdout limit. You give up in-process plugin convenience; you keep a sane security model. The class of supply-chain risks that come with dynamic loading of compiled code at runtime just isn’t worth the ergonomics.
One codebase, two deployment modes — not two products. Single- and multi-tenant share the identical schema and wire format; DEPLOYMENT_MODE picks the posture. In single mode a SingleTenantInjector interceptor stamps every request with the bootstrap tenant id and a second CreateTenant returns FAILED_PRECONDITION. Multi-tenancy rides on PostgreSQL RLS + advisory locks rather than multi-schema or multi-database, so a bug that forgets a filter fails closed at the DB instead of leaking — and there’s no N-times migration cost or painful cross-tenant analytics.
Result
- 75 / 75 OCI Distribution Spec v1.1 conformance (5 optional features skipped and advertised as such). Architecture was designed against the spec from commit one — the project’s
CLAUDE.mddrove the implementation, not the other way around — so there was no spec-discovery phase. - 13 services, distroless and non-root. Go 1.25 builder →
gcr.io/distroless/static-debian12:nonroot. No shell. No package manager. Nothing to escalate from. - mTLS between every internal gRPC call, hot-reloading on cert-manager rotation, with a per-server peer-CN allowlist for defence-in-depth — not just TLS at the edge.
- 80% per-service test coverage floor, CI-enforced, with the OCI conformance suite running on every PR to
main. - 5 pluggable storage backends (MinIO / S3 / GCS / Azure Blob / filesystem) behind one driver interface; operators choose with
STORAGE_DRIVER=…. - Supply-chain and admission controls that actually gate traffic: Cosign + Notary v2 signing against Vault-backed keys, repo-wide
require_signaturewith a per-repo trusted-key allowlist, and CVSS-gated admission that blocks push/pull of images whose scan exceeds a configured severity. - Tamper-evident audit —
FORCE RLS+ a per-tenant SHA-256 hash chain on an INSERT-only role — streamable to SIEM over syslog RFC 5424 / CEF / HTTPS. - Machine-identity story for CI: federated workload identity lets GitHub Actions / GitLab / Buildkite runners exchange their OIDC token for a short-lived registry JWT — no stored secret.
What I’d do differently
Two real ones, both surfaced during hardening — and both are the kind of thing you find by chaos-testing before you’ve shipped, not after.
Detect connection-pool exhaustion explicitly, opt out of retry.
The gRPC retry interceptor amplified load whenever the Postgres pool saturated — Acquire blocked, surfaced as codes.Internal, the client retried, and the pool got hammered harder. The fix is one line of logic: catch saturation, return codes.ResourceExhausted, and configure the interceptor to not retry that code. The lesson isn’t that I missed it — it’s that I designed retry policy before chaos-testing it.
Don’t keep state in memory in a distributed system.
The signer service originally cached signatures in a sync.RWMutex map. A process restart erased them; VerifyManifest then 404’d until they were re-signed. The fix is obvious in hindsight: persist day-one — to Postgres, or follow Cosign’s model and push signatures as OCI artifacts. The lesson is that volatile distributed state is a footgun even in projects where every other surface is contract-first.