--- title: "How to Use MinIO as Storage for a Private Docker Registry" description: "Run a private Docker registry on Ubuntu 24.04 that stores images in a MinIO S3 bucket, with a least-privilege access key, HTTPS, login and garbage collection." category: "Development Tools" language: en source: https://cubepath.com/docs/development-tools/minio-docker-registry-storage-backend updated: 2026-09-25 --- # How to Use MinIO as Storage for a Private Docker Registry 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 `sudo` privileges. - Docker Engine and the Docker Compose plugin installed from Docker's official repository. - A domain such as `registry.your_domain` with a DNS `A` record pointing to the server. - UFW enabled with OpenSSH allowed. > Note: in 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: ```bash 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: ```bash sudo tee /opt/registry/.env > /dev/null <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: ```bash sudo docker compose run --rm mc mb local/docker-registry ``` ```output Bucket created successfully `local/docker-registry`. ``` Write an IAM-style policy that allows the operations the registry needs on that bucket only: ```bash sudo nano /opt/registry/registry-policy.json ``` ```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: ```bash 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: ```bash sudo docker compose run --rm mc admin user info local registry ``` ```output AccessKey: registry Status: enabled PolicyName: registry-policy MemberOf: [] ``` ## Step 4 - Configuring the registry Create the registry configuration file: ```bash sudo nano /opt/registry/config.yml ``` ```yaml 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: - `regionendpoint` points to MinIO instead of AWS. `region` is required by the S3 client but MinIO accepts any value. - `forcepathstyle: true` makes the client request `http://minio:9000/docker-registry/...` instead of a bucket subdomain, which is what MinIO expects. - `secure: false` is correct here because the traffic never leaves the Compose network. Use `https://` and `secure: true` if MinIO runs on another host. - `redirect.disable: true` makes the registry stream layers itself. Without it, clients are redirected to presigned MinIO URLs on `http://minio:9000`, which they cannot reach. - `delete.enabled: true` allows deleting manifests through the API, which garbage collection relies on. - `http.secret` must be a random string, and must be identical on all registry instances if you ever run more than one. Generate it with `openssl rand -hex 32`. The file contains a secret, so restrict it: ```bash 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: ```bash 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: ```bash 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: ```bash 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/ ``` ```output 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: ```bash sudo apt install nginx certbot python3-certbot-nginx ``` Create the server block: ```bash sudo nano /etc/nginx/sites-available/registry ``` ```nginx 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: ```bash 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: ```bash docker login registry.your_domain ``` ```output Login Succeeded ``` Tag a small public image with your registry's name and push it: ```bash 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: ```bash curl -s -u your_user https://registry.your_domain/v2/_catalog ``` ```output {"repositories":["tools/alpine"]} ``` On the server, confirm that the layers ended up in the MinIO bucket and not on the registry's filesystem: ```bash sudo docker compose run --rm mc du local/docker-registry ``` ```output 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: ```bash sudo nano /usr/local/sbin/registry-gc ``` ```bash #!/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: ```bash 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: ```bash sudo /usr/local/sbin/registry-gc ``` Schedule it weekly on Sunday at 03:30 with a cron file: ```bash sudo nano /etc/cron.d/registry-gc ``` ```text 30 3 * * 0 root /usr/local/sbin/registry-gc >> /var/log/registry-gc.log 2>&1 ``` > Warning: `--delete-untagged` deletes 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.