The open source Docker registry (the CNCF Distribution project) can keep image layers on local disk or in any S3-compatible object store. Using object storage such as MinIO separates the stateless registry process from the data, so you can resize, replace or scale registry instances without moving images around. In this tutorial you will run MinIO and the registry with Docker Compose on Ubuntu 24.04, give the registry its own restricted MinIO credentials, publish it over HTTPS with Nginx and basic authentication, and schedule garbage collection.
Prerequisites
To follow this guide you need:
- A server running Ubuntu 24.04 LTS with at least 2 GB of RAM and enough disk for your images, for example a CubePath VPS.
- A non-root user with
sudoprivileges. - Docker Engine and the Docker Compose plugin installed from Docker's official repository.
- A domain such as
registry.your_domainwith a DNSArecord pointing to the server. - UFW enabled with OpenSSH allowed.
Notein late 2025 MinIO moved its community edition to source-only distribution and stopped publishing new official images and binaries. The images used here still work, but for long-term production use consider an S3 service you already operate or a maintained S3-compatible server. The registry configuration in Step 4 is the same for any S3-compatible endpoint: only the endpoint URL and keys change.
Step 1 - Creating the project directory and credentials
Create a directory for the deployment:
sudo mkdir -p /opt/registry/auth
cd /opt/registry
Store two secrets in an .env file that Docker Compose reads automatically: the MinIO root password and the secret key the registry will use to access its bucket. Hex strings are safe to embed in URLs and YAML:
sudo tee /opt/registry/.env > /dev/null <<EOF
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=$(openssl rand -hex 24)
REGISTRY_S3_SECRET=$(openssl rand -hex 24)
EOF
sudo chmod 600 /opt/registry/.env
Display the file, because you will need the REGISTRY_S3_SECRET value in Step 4:
sudo cat /opt/registry/.env
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=9d2c4b7e1f0a8c6e3b5d7f9a1c3e5b7d9f1a3c5e7b9d1f3a
REGISTRY_S3_SECRET=4e6a8c0b2d4f6a8c0e2b4d6f8a0c2e4b6d8f0a2c4e6b8d0f
Step 2 - Writing the Docker Compose file
Create the compose file:
sudo nano /opt/registry/compose.yaml
services:
minio:
image: quay.io/minio/minio
container_name: minio
command: server /data --console-address ":9001"
environment:
- MINIO_ROOT_USER=${MINIO_ROOT_USER}
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}
volumes:
- ./minio-data:/data
ports:
- "127.0.0.1:9001:9001"
restart: unless-stopped
registry:
image: registry:3
container_name: registry
volumes:
- ./config.yml:/etc/distribution/config.yml:ro
- ./auth:/auth:ro
ports:
- "127.0.0.1:5000:5000"
depends_on:
- minio
restart: unless-stopped
mc:
image: quay.io/minio/mc
profiles: ["tools"]
environment:
- MC_HOST_local=http://${MINIO_ROOT_USER}:${MINIO_ROOT_PASSWORD}@minio:9000
volumes:
- ./registry-policy.json:/policy.json:ro
How the pieces fit together:
- MinIO's S3 API on port 9000 is not published at all. Only the registry talks to it, over the internal Compose network, using the hostname
minio. - The MinIO web console (port 9001) and the registry (port 5000) are bound to
127.0.0.1. The registry is exposed through Nginx, and the console is reached through an SSH tunnel. - The
mcservice is the MinIO client. It is in thetoolsprofile, so it only runs when you call it withdocker compose run. TheMC_HOST_localvariable defines an alias namedlocalpointing at MinIO with the root credentials. registry:3reads its configuration from/etc/distribution/config.yml.
Start MinIO first, because the registry needs a bucket before it can work:
sudo docker compose up -d minio
sudo docker compose ps
NAME IMAGE SERVICE STATUS PORTS
minio quay.io/minio/minio minio Up 5 seconds 9000/tcp, 127.0.0.1:9001->9001/tcp
Step 3 - Creating the bucket and a restricted access key
The registry should not use MinIO's root account. Give it a user that can only read and write one bucket.
Create the bucket:
sudo docker compose run --rm mc mb local/docker-registry
Bucket created successfully `local/docker-registry`.
Write an IAM-style policy that allows the operations the registry needs on that bucket only:
sudo nano /opt/registry/registry-policy.json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetBucketLocation",
"s3:ListBucket",
"s3:ListBucketMultipartUploads"
],
"Resource": ["arn:aws:s3:::docker-registry"]
},
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
"s3:AbortMultipartUpload",
"s3:ListMultipartUploadParts"
],
"Resource": ["arn:aws:s3:::docker-registry/*"]
}
]
}
Create the policy, create a user called registry with the secret from your .env file, and attach the policy to it. Replace your_registry_s3_secret with the REGISTRY_S3_SECRET value:
sudo docker compose run --rm mc admin policy create local registry-policy /policy.json
sudo docker compose run --rm mc admin user add local registry your_registry_s3_secret
sudo docker compose run --rm mc admin policy attach local registry-policy --user registry
Verify that the user exists and has the policy:
sudo docker compose run --rm mc admin user info local registry
AccessKey: registry
Status: enabled
PolicyName: registry-policy
MemberOf: []
Step 4 - Configuring the registry
Create the registry configuration file:
sudo nano /opt/registry/config.yml
version: 0.1
log:
level: info
storage:
s3:
accesskey: registry
secretkey: your_registry_s3_secret
region: us-east-1
regionendpoint: http://minio:9000
forcepathstyle: true
bucket: docker-registry
secure: false
rootdirectory: /registry
redirect:
disable: true
delete:
enabled: true
cache:
blobdescriptor: inmemory
http:
addr: :5000
secret: your_http_secret
headers:
X-Content-Type-Options: [nosniff]
auth:
htpasswd:
realm: Registry
path: /auth/htpasswd
The important settings:
regionendpointpoints to MinIO instead of AWS.regionis required by the S3 client but MinIO accepts any value.forcepathstyle: truemakes the client requesthttp://minio:9000/docker-registry/...instead of a bucket subdomain, which is what MinIO expects.secure: falseis correct here because the traffic never leaves the Compose network. Usehttps://andsecure: trueif MinIO runs on another host.redirect.disable: truemakes the registry stream layers itself. Without it, clients are redirected to presigned MinIO URLs onhttp://minio:9000, which they cannot reach.delete.enabled: trueallows deleting manifests through the API, which garbage collection relies on.http.secretmust be a random string, and must be identical on all registry instances if you ever run more than one. Generate it withopenssl rand -hex 32.
The file contains a secret, so restrict it:
sudo chmod 600 /opt/registry/config.yml
Step 5 - Creating registry users and starting the registry
The registry reads users from an htpasswd file with bcrypt hashes. Install the htpasswd tool and create the first user:
sudo apt update
sudo apt install apache2-utils
sudo htpasswd -Bc /opt/registry/auth/htpasswd your_user
Add further users with the same command without -c, which would overwrite the file.
Start the registry:
sudo docker compose up -d registry
sudo docker compose logs registry | tail -n 3
A line containing listening on [::]:5000 means the registry started. Check that it answers and demands authentication:
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:5000/v2/
curl -s -u your_user -o /dev/null -w '%{http_code}\n' http://127.0.0.1:5000/v2/
401
200
The second command prompts for the password you set with htpasswd.
Step 6 - Publishing the registry with Nginx and HTTPS
Docker only talks to remote registries over HTTPS, so put Nginx with a Let's Encrypt certificate in front. Install the packages:
sudo apt install nginx certbot python3-certbot-nginx
Create the server block:
sudo nano /etc/nginx/sites-available/registry
server {
listen 80;
listen [::]:80;
server_name registry.your_domain;
client_max_body_size 0;
chunked_transfer_encoding on;
location / {
proxy_pass http://127.0.0.1:5000;
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_request_buffering off;
proxy_read_timeout 900;
}
}
client_max_body_size 0 removes the upload limit, because image layers can be several gigabytes, and proxy_request_buffering off streams uploads straight to the registry instead of buffering them on disk.
Enable the site and request the certificate:
sudo ln -s /etc/nginx/sites-available/registry /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
sudo ufw allow 'Nginx Full'
sudo certbot --nginx -d registry.your_domain
Step 7 - Pushing an image and checking MinIO
From any machine with Docker, log in to your registry:
docker login registry.your_domain
Login Succeeded
Tag a small public image with your registry's name and push it:
docker pull alpine:3
docker tag alpine:3 registry.your_domain/tools/alpine:3
docker push registry.your_domain/tools/alpine:3
List the repositories the registry knows about:
curl -s -u your_user https://registry.your_domain/v2/_catalog
{"repositories":["tools/alpine"]}
On the server, confirm that the layers ended up in the MinIO bucket and not on the registry's filesystem:
sudo docker compose run --rm mc du local/docker-registry
3.6MiB 12 objects docker-registry
To browse the bucket in the MinIO console, open a tunnel from your workstation with ssh -L 9001:127.0.0.1:9001 your_user@your_server_ip and visit http://localhost:9001, logging in with the root credentials from .env.
Step 8 - Scheduling garbage collection
When you overwrite a tag or delete a manifest, the old layers stay in the bucket until garbage collection removes blobs that no manifest references. Garbage collection must not run while clients push, so the script below stops the registry, runs the collector in a one-off container with the same configuration, and starts the registry again even if the collector fails.
Create the script:
sudo nano /usr/local/sbin/registry-gc
#!/usr/bin/env bash
set -euo pipefail
cd /opt/registry
docker compose stop registry
trap 'docker compose start registry' EXIT
docker compose run --rm registry garbage-collect /etc/distribution/config.yml --delete-untagged
Make it executable and do a dry run first, which reports what would be deleted without touching the bucket:
sudo chmod 755 /usr/local/sbin/registry-gc
cd /opt/registry
sudo docker compose run --rm registry garbage-collect /etc/distribution/config.yml --delete-untagged --dry-run
The output lists each repository, the manifests it marks, and the blobs eligible for deletion. When the result looks right, run the real script once by hand:
sudo /usr/local/sbin/registry-gc
Schedule it weekly on Sunday at 03:30 with a cron file:
sudo nano /etc/cron.d/registry-gc
30 3 * * 0 root /usr/local/sbin/registry-gc >> /var/log/registry-gc.log 2>&1
Warning
--delete-untaggeddeletes every manifest that no tag points to. If you pull images by digest from untagged manifests, drop that flag from the script.
Troubleshooting
Push fails with 500 Internal Server Error and the registry log shows AccessDenied. The registry user lacks the policy or has the wrong secret. Check with sudo docker compose run --rm mc admin user info local registry and compare the secret in config.yml.
The registry log shows NoSuchBucket. The bucket name in config.yml does not match the bucket created with mc mb.
Pulls hang or fail with a connection error to minio:9000. redirect.disable: true is missing from the storage section, so clients are sent to an internal hostname.
413 Request Entity Too Large during push. Nginx is still limiting the body size. Confirm client_max_body_size 0; is in the server block that Certbot modified and reload Nginx.
Conclusion
Your private Docker registry now stores all image data in a MinIO bucket through a least-privilege access key, is published over HTTPS with authentication, and reclaims space with scheduled garbage collection. Because the registry itself is stateless, you can next run several registry containers behind Nginx with the same config.yml and http.secret, replicate the bucket to another site with mc mirror, or point regionendpoint at a different S3-compatible provider without changing anything else.
