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
| Project | project-zot/zot 2.x |
| License | Apache 2.0 |
| Recommended Plan | RamNode Cloud VPS 2 vCPU / 4 GB / 80+ GB disk |
| Storage | OCI 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/zotPlan 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/zliUse 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/htpasswd4
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.json5
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.targetEnable + 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 --redirect7
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:7Open 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 -deleteUpgrade 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 zotFor 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.
