Deploying Zot Registry on Docker: Lightweight OCI-Native Registry for On-Premise and Air-Gapped Setups

DevOps tutorial - IT technology blog
DevOps tutorial - IT technology blog

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/distribution binary 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.

Share: