OCI Registry
    CVE Scanning

    Deploy Zot OCI Registry on a VPS

    A vendor-neutral, OCI-native container registry. Single Go binary, no database, Tantivy-backed search, CVE scanning, web UI, and pull-through Docker Hub cache.

    At a Glance

    Projectproject-zot/zot 2.x
    LicenseApache 2.0
    Recommended PlanRamNode Cloud VPS 2 vCPU / 4 GB / 80+ GB disk
    StorageOCI image layout on disk (or S3)
    Footprint< 256 MB at idle
    1

    Base Server + Storage Layout

    Update + ufw + storage dir
    apt update && apt upgrade -y
    apt install -y ca-certificates curl ufw fail2ban apache2-utils jq \
        unattended-upgrades nginx python3-certbot-nginx
    dpkg-reconfigure --priority=low unattended-upgrades
    
    ufw default deny incoming
    ufw default allow outgoing
    ufw allow OpenSSH
    ufw allow 80/tcp
    ufw allow 443/tcp
    ufw --force enable
    
    # Mount your data volume at /data BEFORE installing zot if you have one
    mkdir -p /data/zot

    Plan 10–50 GB per active project. Zot's inline dedupe reduces this significantly.

    2

    Install the Zot Binary

    System user + binary
    useradd --system --home /var/lib/zot --shell /usr/sbin/nologin zot
    mkdir -p /etc/zot /var/log/zot
    chown -R zot:zot /etc/zot /var/log/zot /data/zot
    
    ZOT_VERSION=v2.1.14
    curl -L -o /usr/local/bin/zot \
      "https://github.com/project-zot/zot/releases/download/${ZOT_VERSION}/zot-linux-amd64"
    chmod +x /usr/local/bin/zot
    
    # Optional CLI
    curl -L -o /usr/local/bin/zli \
      "https://github.com/project-zot/zot/releases/download/${ZOT_VERSION}/zli-linux-amd64"
    chmod +x /usr/local/bin/zli

    Use the full zot-linux-amd64, not -minimal — the minimal build strips UI, search, CVE scanning, and sync.

    3

    htpasswd Authentication (bcrypt required)

    Create accounts
    htpasswd -B -c /etc/zot/htpasswd admin
    htpasswd -B    /etc/zot/htpasswd ci-pusher
    chown root:zot /etc/zot/htpasswd
    chmod 640      /etc/zot/htpasswd
    4

    Zot Config (UI + CVE + pull-through cache)

    /etc/zot/config.json
    {
      "distSpecVersion": "1.1.1",
      "storage": {
        "rootDirectory": "/data/zot",
        "dedupe": true, "gc": true,
        "gcDelay": "2h", "gcInterval": "24h"
      },
      "http": {
        "address": "127.0.0.1", "port": "8080",
        "externalUrl": "https://registry.example.com",
        "realm": "zot",
        "auth": {
          "htpasswd": { "path": "/etc/zot/htpasswd" },
          "failDelay": 5
        },
        "accessControl": {
          "repositories": {
            "**": {
              "policies": [
                { "users": ["ci-pusher"], "actions": ["read","create","update"] }
              ],
              "defaultPolicy": ["read"],
              "anonymousPolicy": []
            }
          },
          "adminPolicy": {
            "users": ["admin"],
            "actions": ["read","create","update","delete"]
          }
        }
      },
      "log": {
        "level": "info",
        "output": "/var/log/zot/zot.log",
        "audit": "/var/log/zot/audit.log"
      },
      "extensions": {
        "ui":      { "enable": true },
        "search":  { "enable": true, "cve": { "updateInterval": "24h" } },
        "scrub":   { "interval": "24h" },
        "metrics": { "enable": true, "prometheus": { "path": "/metrics" } },
        "sync": {
          "enable": true,
          "registries": [
            {
              "urls": ["https://registry-1.docker.io"],
              "onDemand": true, "tlsVerify": true,
              "maxRetries": 3, "retryDelay": "5m",
              "content": [{ "prefix": "**" }]
            }
          ]
        }
      }
    }
    Validate
    sudo -u zot /usr/local/bin/zot verify /etc/zot/config.json
    5

    Hardened systemd Service

    /etc/systemd/system/zot.service
    [Unit]
    Description=Zot OCI Registry
    After=network-online.target
    Wants=network-online.target
    
    [Service]
    Type=simple
    User=zot
    Group=zot
    ExecStart=/usr/local/bin/zot serve /etc/zot/config.json
    Restart=on-failure
    RestartSec=5
    LimitNOFILE=65536
    
    NoNewPrivileges=true
    PrivateTmp=true
    ProtectSystem=strict
    ProtectHome=true
    ReadWritePaths=/data/zot /var/log/zot
    ProtectKernelTunables=true
    ProtectKernelModules=true
    ProtectControlGroups=true
    RestrictSUIDSGID=true
    RestrictRealtime=true
    LockPersonality=true
    
    [Install]
    WantedBy=multi-user.target
    Enable + watch first start
    systemctl daemon-reload
    systemctl enable --now zot
    journalctl -u zot -f   # CVE database downloads on first start (a few minutes)
    6

    Nginx Reverse Proxy + Let's Encrypt

    /etc/nginx/sites-available/zot.conf
    # Image layers can be large — disable body limits
    client_max_body_size 0;
    chunked_transfer_encoding on;
    
    server {
        listen 80; server_name registry.example.com;
        location / {
            proxy_pass http://127.0.0.1:8080;
            proxy_http_version 1.1;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
    
            proxy_read_timeout 900;
            proxy_send_timeout 900;
            proxy_request_buffering off;
        }
    }
    Enable + cert
    ln -s /etc/nginx/sites-available/zot.conf /etc/nginx/sites-enabled/
    nginx -t && systemctl reload nginx
    certbot --nginx -d registry.example.com \
        --non-interactive --agree-tos -m admin@example.com --redirect
    7

    Push, Pull, and Test the Cache

    From your workstation
    docker login registry.example.com
    docker pull alpine:3.20
    docker tag  alpine:3.20 registry.example.com/library/alpine:3.20
    docker push registry.example.com/library/alpine:3.20
    docker pull registry.example.com/library/alpine:3.20
    
    # Pull-through cache: Zot fetches from Docker Hub on first request, serves locally after
    docker pull registry.example.com/library/redis:7

    Open https://registry.example.com/ for the UI — CVE results appear once the database finishes downloading.

    8

    Backups + Upgrades

    /opt/zot-backup.sh
    #!/usr/bin/env bash
    set -euo pipefail
    STAMP=$(date +%Y%m%d-%H%M%S)
    BACKUP_DIR=/var/backups/zot
    mkdir -p "$BACKUP_DIR"
    
    systemctl stop zot
    tar -czf "$BACKUP_DIR/zot-${STAMP}.tar.gz" -C / data/zot etc/zot
    systemctl start zot
    
    find "$BACKUP_DIR" -type f -mtime +14 -delete
    Upgrade pattern (binary swap)
    ZOT_VERSION=v2.1.15
    curl -L -o /usr/local/bin/zot.new \
      "https://github.com/project-zot/zot/releases/download/${ZOT_VERSION}/zot-linux-amd64"
    chmod +x /usr/local/bin/zot.new
    sudo -u zot /usr/local/bin/zot.new verify /etc/zot/config.json
    mv /usr/local/bin/zot.new /usr/local/bin/zot
    systemctl restart zot

    For large registries use filesystem snapshots (zfs/lvm/btrfs) instead of stopping Zot. Or switch to the S3 storage driver — the bucket becomes your durable copy and the backup problem disappears.