How to Build a Private OCI Image Registry with zot in 30 Minutes
A step-by-step guide to deploying and configuring zot, a lightweight Go-based OCI image registry from scratch. Covers installation, authentication, image push/pull, image synchronization, and garbage collection to solve internal image management challenges efficiently.

Building a Private OCI Image Registry from Scratch: A Lightweight Practical Guide with zot
Introduction: Are You Still Struggling with Image Registries?
Last month, while migrating our services, we ran into a typical problem: pulling from Docker Hub's public registry was too slow, and Harbor was simply too heavy. Just wrestling with its dependencies and configurations took me half a day, yet our actual requirements were quite simple: we just needed a private registry on our internal network that could push/pull, handle basic authentication, and support syncing.
That's when I discovered zot on GitHub. It's an OCI-native container image registry written entirely in Go. Its positioning is very clear: a lightweight, scalable, and fully OCI-compliant alternative to heavy traditional solutions like Harbor or Docker Registry. With over 2,700 stars, it has a healthy and active community.
By reading this guide, you will complete the following hands-on tasks:
- Deploy and run zot on a Linux machine
- Configure basic authentication and file storage
- Successfully push and pull private container images
- Understand how to configure image synchronization and garbage collection
Let's get straight to the point.
Prerequisites
Before we begin, ensure your environment meets the following requirements:
- OS: Linux (Ubuntu 22.04 is used as an example here, but other distributions follow similar steps)
- Docker: Installed and running (required for push/pull testing later)
- Network: Access to GitHub to download binaries
- Knowledge: Familiarity with basic Docker commands and container image concepts
You don't need Go language experience. zot provides precompiled binaries that work out of the box.
Quick Start: Deploy Your First zot Instance
Step 1: Download and Install zot
Thanks to Go's static compilation, zot is just a single executable file. Simply download the binary for your architecture:
bash
## Download the Linux amd64 version (adjust according to your architecture)
wget https://github.com/project-zot/zot/releases/latest/download/zot-linux-amd64
## Grant execution permission and move to system path
chmod +x zot-linux-amd64
sudo mv zot-linux-amd64 /usr/local/bin/zot
## Verify installation
zot --version
Why this approach? zot is a purely statically compiled Go program with no external dependencies. Downloading the binary directly is the fastest method, saving significant time compared to compiling from source. If you're on macOS or an ARM machine, just replace the filename accordingly (e.g., zot-darwin-amd64 or zot-linux-arm64).
Step 2: Create the Configuration File
zot reads the zot.json configuration file from the current directory by default. Let's create a minimal viable configuration:
bash
mkdir -p /opt/zot/config /opt/zot/data
Then create /opt/zot/config/zot.json with the following content:
json
{
"storage": {
"rootDirectory": "/opt/zot/data"
},
"http": {
"address": "0.0.0.0",
"port": "5000"
},
"log": {
"level": "info"
}
}
Key points:
rootDirectory: The actual path where images are stored. Ensure you have sufficient disk space.http.addressset to0.0.0.0allows external access. Change it to127.0.0.1for local debugging only.- Port
5000is the default for container image registries, aligning with Docker Registry for easier future replacement.
Step 3: Start zot
bash
## Start in the foreground (useful for debugging and viewing real-time logs)
zot serve /opt/zot/config/zot.json
## For production, run in the background or manage with systemd
nohup zot serve /opt/zot/config/zot.json > /opt/zot/zot.log 2>&1 &
After starting, visiting http://<YOUR_IP>:5000/v2/ should return a response like {}, indicating the OCI API endpoint is ready. Congratulations, zot is up and running!
Hands-on: Push and Pull Private Images
Now that we have a running registry, the next step is to test if it can properly handle pushing and pulling images.
Step 1: Configure Docker to Trust Insecure Registries
Since we haven't configured HTTPS yet (mandatory for production), Docker will block the connection by default. We need to add it to the trust list:
bash
## Edit Docker configuration (create if it doesn't exist)
sudo vim /etc/docker/daemon.json
Add the following:
json
{
"insecure-registries": ["<YOUR_IP>:5000"]
}
Then restart Docker:
bash
sudo systemctl restart docker
Why is this necessary? Docker enforces HTTPS for image registries by default as a security best practice. For local testing, we can temporarily disable this, but in production, you must configure TLS certificates. zot supports specifying cert and key paths in the http.tls field.
Step 2: Pull a Public Image and Push to zot
bash
## Pull a public image
docker pull nginx:alpine
## Tag it to point to our zot registry
docker tag nginx:alpine <YOUR_IP>:5000/my-nginx:1.0
## Push to zot
docker push <YOUR_IP>:5000/my-nginx:1.0
If successful, you'll see output similar to:
The push refers to repository [<YOUR_IP>:5000/my-nginx]
xxxxx: Pushed
1.0: digest: sha256:xxxxx size: xxx
Step 3: Verify Pull
bash
## Remove local image first to ensure it's pulled from the registry
docker rmi <YOUR_IP>:5000/my-nginx:1.0
## Pull it back
docker pull <YOUR_IP>:5000/my-nginx:1.0
## Verify
docker images | grep my-nginx
If it pulls back successfully, the read/write functionality of your registry is fully operational. You now have a working private image registry.
Advanced Configuration: Authentication, Sync, and Garbage Collection
Configure Basic Authentication (htpasswd)
In real-world projects, private registries always require access control. zot supports htpasswd-based authentication:
bash
## Install htpasswd utility
sudo apt-get install apache2-utils -y
## Create auth file and add a user
cd /opt/zot/config
htpasswd -Bbc htpasswd admin password123
Then update zot.json by adding auth under http:
json
{
"http": {
"address": "0.0.0.0",
"port": "5000",
"auth": {
"htpasswd": {
"path": "/opt/zot/config/htpasswd"
}
}
}
}
After restarting zot, push/pull operations will require --username and --password flags, or you can run docker login <YOUR_IP>:5000 in Docker first.
Image Synchronization (Sync)
zot can sync images from public registries to your local instance, which is ideal for internal networks. Add the sync field to your configuration:
json
{
"sync": {
"credentialsFile": "/opt/zot/config/credentials.json",
"registries": [
{
"urls": ["https://index.docker.io/v1/"],
"onDemand": true,
"tlsVerify": true,
"maxRetries": 3,
"content": [
{
"destination": "local-nginx",
"tags": {
"regex": "alpine"
}
}
]
}
]
}
}
Now, when you run docker pull <zot_address>/local-nginx:alpine, zot will pull it from Docker Hub on demand and cache it locally. Note that sync must be enabled at compile time (GOFLAGS="-tags sync") or you should use the precompiled version with the sync tag.
Garbage Collection (GC)
Over time, image registries consume a lot of disk space. zot supports garbage collection to clean up unreferenced layers:
bash
## Run GC (recommended during off-peak hours)
zot gc /opt/zot/config/zot.json
You can also configure a cron job to automate this.
Common Issues & Troubleshooting
- Push fails with
x509: certificate signed by unknown authority: Docker doesn't trust your registry certificate. For local testing, add it toinsecure-registriesas shown above. In production, configure valid TLS certificates. - 403 Authentication failure: Check if the htpasswd file path is correct and ensure the zot process has read permissions. Incorrect passwords also trigger 403.
- Disk full: Run
zot gcfirst to clean up unused image layers, then consider scaling storage. Set up monitoring alerts to trigger cleanup when disk usage hits 80%. - Sync feature unavailable: Ensure your downloaded binary includes the sync tag, or use the
zot-linux-amd64-syncbinary variant.
Summary
Today, we walked through the complete process of installing, configuring, setting up authentication, and pushing/pulling images with zot. Compared to Harbor's complex architecture with over a dozen components, zot handles core requirements with a single binary, making it highly suitable for small to medium teams needing a private image registry.
Next steps you can explore:
- Configure a reverse proxy (Nginx/Traefik) for HTTPS and multi-registry domain routing
- Dive into zot's extension features: image signature verification (cosign), OCI artifact support
- Manage production deployments using systemd or Docker Compose
Repository: https://github.com/project-zot/zot
Official Documentation: https://zotregistry.dev
Feel free to leave a comment if you have any questions!