Comparing Container Registry Approaches
Choosing an image registry for bare-metal servers or isolated air-gapped networks usually comes down to three patterns:
- Enterprise Multi-Service Suites (e.g., Harbor): Harbor delivers fine-grained RBAC, project quotas, robot accounts, and replication policies. The catch? It spins up nearly ten separate containers (PostgreSQL, Redis, Core, Jobservice, Trivy, Nginx, Registry). On an edge gateway with 4 GB of RAM or a shared staging VM, Harbor eats up roughly 2 GB just sitting idle.
- Legacy Distribution Registry (Docker Registry v2): The classic
distribution/distributionbinary is tiny and uses under 40 MB of RAM. However, it lacks native OCI artifact discovery, search indexing, built-in access control lists, and vulnerability scanning hooks. - Modern Single-Binary OCI Registries (Zot): Zot is a CNCF incubating project written in Go. It ships as a single static binary or container without any external database dependency. Despite its small footprint, it natively indexes OCI Image Indexes, Helm charts, Cosign signatures, and SBOM attachments.
| Feature | Harbor | Docker Registry v2 | Zot Registry |
|---|---|---|---|
| Architecture | Microservices (8–10 containers) | Single process | Single binary / container |
| Idle Memory Footprint | ~1.5 GB – 3.0 GB | ~20 MB – 40 MB | ~30 MB – 60 MB |
| OCI 1.1 Support | Supported via Core layer | Basic / Legacy schema | Native OCI 1.1 Specification |
| Backing Dependencies | PostgreSQL, Redis | None | None (Embedded storage engine) |
Evaluating Zot: Strengths and Trade-offs
Before rolling Zot out across your environments, weigh where it shines against where you might hit boundaries.
Where Zot Excels
- Near-Instant Boot & Low Overhead: It boots in under 200 milliseconds and idles around 35 MB of RAM. You can easily embed it directly into CI build agents, test runners, or edge devices.
- Native OCI Artifact Handling: Push Helm 3 charts, WebAssembly binaries, Cosign signatures, and CycloneDX SBOMs directly with standard tooling. No extra plugins required.
- Effortless Air-Gapped Maintenance: Backups are trivial. Archive the data directory and the single JSON configuration file, and you are done.
- Modular Extensions: Turn on htpasswd, LDAP, OAuth2/OIDC, deduplication, and Trivy-backed image scanning by toggling boolean flags in one file.
Trade-offs to Keep in Mind
- Streamlined Web Console: Zot includes a clean UI for exploring repositories and tags, but it lacks Harbor’s granular project-level team dashboards.
- Organizational Governance: Large enterprises requiring multi-departmental quota management and complex approval workflows may still favor Harbor.
Target Architecture
In production, run Zot inside Docker Compose behind an Nginx or Traefik reverse proxy handling TLS termination. Container runtimes like containerd and CRI-O reject insecure HTTP registries by default. Putting a proxy up front handles HTTPS smoothly without configuring custom TLS certificates inside Zot itself.
Here is the directory structure on the host:
/opt/zot/
├── config.json
├── htpasswd
├── docker-compose.yml
└── data/
Step-by-Step Deployment
Set up a secure, functional Zot registry on any Linux server running Docker Engine and Docker Compose v2.
Step 1: Set Up Directory Structure
Create the base project directory and data mount point:
sudo mkdir -p /opt/zot/data
cd /opt/zot
Step 2: Generate Authentication Credentials
Generate a bcrypt-hashed password file using a quick disposable container:
# Create htpasswd entry for user 'developer'
docker run --rm httpd:alpine htpasswd -B -b -n developer SecurePassword123 > htpasswd
# Restrict permissions to owner only
chmod 600 htpasswd
Step 3: Configure Zot (config.json)
Create config.json to configure local storage paths, deduplication, garbage collection, and authentication:
cat <<'EOF' > config.json
{
"distSpecVersion": "1.1.0",
"storage": {
"rootDirectory": "/var/lib/registry",
"gc": true,
"gcDelay": "1h",
"dedupe": true
},
"http": {
"address": "0.0.0.0",
"port": "5000",
"auth": {
"htpasswd": {
"path": "/etc/zot/htpasswd"
}
}
},
"extensions": {
"ui": {
"enable": true
},
"search": {
"enable": true
}
}
}
EOF
Step 4: Define the Docker Compose Service
Write docker-compose.yml referencing the official full-featured Zot image:
cat <<'EOF' > docker-compose.yml
services:
zot:
image: ghcr.io/project-zot/zot-linux-amd64:v2.1.1
container_name: zot-registry
restart: unless-stopped
ports:
- "5000:5000"
volumes:
- ./config.json:/etc/zot/config.json:ro
- ./htpasswd:/etc/zot/htpasswd:ro
- ./data:/var/lib/registry
user: "1000:1000"
EOF
Grant ownership of the data volume to UID/GID 1000:1000:
sudo chown -R 1000:1000 /opt/zot/data
Step 5: Launch and Test Health
Start the container in detached mode:
docker compose up -d
# Review startup logs
docker compose logs -f zot
Check that the registry catalog responds with an HTTP 200:
curl -u developer:SecurePassword123 http://localhost:5000/v2/_catalog
You should receive an empty repository array: {"repositories":[]}.
Step 6: Push and Pull Docker Images
Log in with the Docker CLI and push a sample Alpine image:
# 1. Authenticate
echo "SecurePassword123" | docker login localhost:5000 -u developer --password-stdin
# 2. Pull a base image
docker pull alpine:3.20
# 3. Retag for your local registry
docker tag alpine:3.20 localhost:5000/base/alpine:3.20
# 4. Push the image
docker push localhost:5000/base/alpine:3.20
# 5. Clean local cache and pull back from Zot
docker rmi localhost:5000/base/alpine:3.20
docker pull localhost:5000/base/alpine:3.20
Step 7: Push Helm Charts via OCI
Helm 3 treats OCI registries as native chart repositories:
# 1. Create a sample chart
helm create webapp
# 2. Package into a .tgz bundle
helm package webapp
# 3. Log in to Zot with Helm
echo "SecurePassword123" | helm registry login localhost:5000 -u developer --password-stdin
# 4. Push chart archive directly to Zot
helm push webapp-0.1.0.tgz oci://localhost:5000/charts
# 5. Test pulling and extracting
helm pull oci://localhost:5000/charts/webapp --version 0.1.0 --untar
Step 8: Verify Artifacts
Query the catalog endpoint again:
curl -u developer:SecurePassword123 http://localhost:5000/v2/_catalog
# Expected output: {"repositories":["base/alpine","charts/webapp"]}
Open http://<server-ip>:5000 in your browser to explore image layers, vulnerability reports, and OCI descriptors in the web UI.

