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.

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 mc service is the MinIO client. It is in the tools profile, so it only runs when you call it with docker compose run. The MC_HOST_local variable defines an alias named local pointing at MinIO with the root credentials.
  • registry:3 reads 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:

  • 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:

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

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.