Container Registry
The Tabbify registry is where your app images and WASM components live. It speaks the standard OCI Distribution API on /v2, but it has no public IP — it joins the private mesh and answers only on its mesh address. You push from one peer and pull from another through encrypted WireGuard tunnels, with per-tenant isolation enforced at the door.
How it is built
The registry is a thin Rust wrapper around Zot. The wrapper runs as PID 1, supervises a Zot child on loopback 127.0.0.1:5001 (anonymous, never exposed), joins the mesh tagged registry with a sticky identity, and reverse-proxies /v2 on its assigned ULA at port 5000. Zot does the storage; the wrapper does auth, namespace enforcement, and mesh integration. Large layers stream through both directions — nothing is buffered in memory.
Addressing
The registry binds [my_ula]:5000. The ULA is derived from the mesh coordinator and persisted under REGISTRY_DATA_DIR, so it stays stable across restarts:
[fd5a:1f02:xxxx:xxxx::1]:5000
Because the registry serves plain HTTP over the encrypted mesh, any Docker daemon pulling from it must list that address in insecure-registries (/etc/docker/daemon.json).
Authentication
When REGISTRY_AUTH_URL is set, every /v2 request is gated. The wrapper validates your tabbify JWT against auth and mints a short-lived (300s) HS256 registry token signed by a per-process secret. The hot path verifies that token locally — no per-request call to auth.
This is the standard OCI bearer flow. Log in with your tabbify JWT as the password (the username is ignored):
docker login [fd5a:1f02:xxxx:xxxx::1]:5000 -u x -p <tabbify-jwt>
Under the hood: a first /v2 hit returns 401 with a challenge pointing at /auth/token; the client exchanges your JWT there, gets back {token, access_token, expires_in: 300}, and retries with Authorization: Bearer <registry_token>. The wrapper calls auth's POST /v1/validate and reads the network claim (falling back to subject) as your tenant.
Per-tenant namespaces
Phase 1 enforces a hard rule: every repository path must start with <tenant>/. A token for tenant acme can touch /v2/acme/... and nothing else.
namespace::check("acme", "/v2/acme/app/manifests/v1") -> Allow
namespace::check("acme", "/v2/globex/app/manifests/v1") -> Deny (403)
namespace::check("acme", "/v2/") -> Allow (version check)
namespace::check("acme", "/v2/_catalog") -> Deny (Phase 1)
Pushing images and WASM
Docker images push and pull exactly as you expect:
docker tag busybox [fd5a:1f02:xxxx:xxxx::1]:5000/myteam/demo:v1
docker push [fd5a:1f02:xxxx:xxxx::1]:5000/myteam/demo:v1
WASM components are stored as OCI artifacts under the same /v2 API with media type application/vnd.tabbify.wasm.component.v1. Use oras, not docker:
oras push --plain-http \
--artifact-type application/vnd.tabbify.wasm.component.v1 \
[fd5a:1f02:xxxx:xxxx::1]:5000/myteam/hello-wasm:v1 \
hello.wasm:application/wasm
Storage
REGISTRY_STORAGE picks the backend: local (filesystem under REGISTRY_DATA_DIR/zot-cache, the always-green dev default) or s3 (set REGISTRY_S3_BUCKET and REGISTRY_S3_REGION). On AWS, credentials come from the EC2 instance role — no static keys in env. Both modes keep a local boltdb dedupe cache.
Running it
docker run -d \
--device /dev/net/tun --cap-add NET_ADMIN \
-e REGISTRY_STORAGE=local \
-e REGISTRY_AUTH_URL=http://auth-service:8080 \
-v tbf-registry:/var/lib/tabbify-registry \
ghcr.io/tabbify-io/tabbify-registry
Mesh membership needs the TUN device and NET_ADMIN. Mount a volume at REGISTRY_DATA_DIR or you lose the sticky ULA on restart.
Where things stand: Zot supervision, mesh join, the streaming /v2 proxy, JWT auth, per-tenant namespaces, and both storage modes are live on AWS and verified end-to-end over the mesh. Leave REGISTRY_AUTH_URL unset for anonymous dev mode — no token, no namespace enforcement, local only. The catalog endpoint and ACLs beyond per-tenant are Phase 2; signed images and garbage collection are Phase 3.
See also The Node API, The deploy pipeline, and the CLI reference.