Table of Contents
- Overview
- Prerequisites
- Quick Architecture
- Variables
- Step 1 – Install / Setup
- Step 2 – Base Configuration
- Step 3 – Enable Service
- Step 4 – Firewall & NAT
- Step 5 – Add-Ons / Plugins
- Step 6 – Client Files / Access
- Advanced Add-Ons
- Security
- Backup & Restore
- Troubleshooting
- Next Steps
Overview
This guide walks you through a clean, reproducible setup for a Private Docker Registry HTTPS environment. You’ll place Nginx in front for TLS on port 443, protect the registry with basic authentication, and store images on local disk or S3. Everything is copy‑paste friendly and uses per‑OS boxes so you can follow on any major Linux distribution. Reference: Docker Registry documentation.
Prerequisites
One Linux VM or server with a public DNS name for the registry, ports 80/443 available, Docker Engine installed, and shell access with sudo. A second machine with Docker CLI helps to test push/pull.
Quick Architecture

Variables
Before you copy: These variables are here so you can customize the guide to your environment.
Change them to any values you need (domain names, email, directories, ports, usernames). After saving the file and running
source /root/vars.sh, all commands in this guide will read from these variables automatically—so you only need to set them once.
REGISTRY_DOMAIN– your public FQDN (e.g.,registry.example.com)EMAIL– mailbox used by Certbot/ACME for renewal noticesREGISTRY_DATA– on‑disk storage path for image layersREGISTRY_PORT– local port where the container listens (behind Nginx)NGINX_SITE– path to Nginx site config used in this guideAUTH_DIR– directory holdinghtpasswdcredentialsAUTH_USER– the username for basic auth (pick your own)
Load a few variables so the rest of the guide stays consistent.
All Linux
sudo install -d -m 700 /root
cat <<'EOF' | sudo tee /root/vars.sh
export REGISTRY_DOMAIN="registry.example.com"
export EMAIL="[email protected]"
export REGISTRY_DATA="/var/lib/registry"
export REGISTRY_PORT="5000"
export NGINX_SITE="/etc/nginx/sites-available/registry.conf"
export AUTH_DIR="/etc/docker/registry/auth"
export AUTH_USER="registryuser"
EOF
source /root/vars.sh
echo "$REGISTRY_DOMAIN $REGISTRY_PORT" # Check
Step 1 – Install / Setup
Install the registry service and Nginx. You will run the official Docker Registry container and place Nginx in front for TLS offload.
On Debian / Ubuntu
sudo apt update
sudo apt -y install docker.io nginx apache2-utils
docker --version | head -n 1 # Check
nginx -v 2>&1 | head -n 1 # Check
On RHEL / Rocky / Alma / CentOS Stream / Fedora
sudo dnf -y install docker nginx httpd-tools
docker --version | head -n 1 # Check
nginx -v 2>&1 | head -n 1 # Check
On Arch / Manjaro
sudo pacman -Syu --noconfirm docker nginx apache-tools
docker --version | head -n 1 # Check
nginx -v 2>&1 | head -n 1 # Check
On openSUSE / SLE
sudo zypper refresh
sudo zypper install -y docker nginx apache2-utils
docker --version | head -n 1 # Check
nginx -v 2>&1 | head -n 1 # Check
Step 2 – Base Configuration
Create directories, add basic auth, run the registry container, and publish it on localhost:5000. Nginx will proxy it on 443 with HTTPS.
All Linux
# Create data and auth folders
sudo mkdir -p "$REGISTRY_DATA" "$AUTH_DIR"
sudo chown -R root:root "$REGISTRY_DATA" "$AUTH_DIR"
sudo chmod 700 "$AUTH_DIR"
# Create an htpasswd file (you'll be prompted for a password)
sudo htpasswd -Bc "$AUTH_DIR/htpasswd" "$AUTH_USER"
# Run the Docker Registry bound to localhost only
sudo docker run -d --name registry --restart unless-stopped -p 127.0.0.1:$REGISTRY_PORT:5000 -v "$REGISTRY_DATA":/var/lib/registry -e REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY=/var/lib/registry registry:2
# Minimal health check
curl -s http://127.0.0.1:$REGISTRY_PORT/v2/ | jq . 2>/dev/null || echo "OK if empty" # Check
Now configure Nginx as a TLS reverse proxy for your domain. This example uses a modern TLS config and forwards auth to the registry.
On Debian / Ubuntu
sudo tee "$NGINX_SITE" >/dev/null <<'NGINX'
map $http_upgrade $connection_upgrade {{ default = "close"; "~websocket" = "upgrade"; }}
server {
listen 80;
server_name $REGISTRY_DOMAIN;
location /.well-known/acme-challenge/ { root /var/www/_letsencrypt; }
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl http2;
server_name $REGISTRY_DOMAIN;
# TLS (provide your certs)
ssl_certificate /etc/letsencrypt/live/$REGISTRY_DOMAIN/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/$REGISTRY_DOMAIN/privkey.pem;
client_max_body_size 1g;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Real-IP $remote_addr;
# Basic auth
auth_basic "Private Registry";
auth_basic_user_file $AUTH_DIR/htpasswd;
location /v2/ { proxy_pass http://127.0.0.1:$REGISTRY_PORT; }
}
NGINX
sudo ln -sf "$NGINX_SITE" /etc/nginx/sites-enabled/registry.conf
sudo nginx -t && sudo systemctl reload nginx # Check
On RHEL / Rocky / Alma / CentOS Stream / Fedora / Arch / openSUSE
# Single-file nginx.conf style (common on these distros)
sudo cp /etc/nginx/nginx.conf /etc/nginx/nginx.conf.bak.$(date +%s)
sudo tee /etc/nginx/conf.d/registry.conf >/dev/null <<'NGINX'
server {
listen 80;
server_name $REGISTRY_DOMAIN;
location /.well-known/acme-challenge/ { root /var/www/_letsencrypt; }
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl http2;
server_name $REGISTRY_DOMAIN;
ssl_certificate /etc/letsencrypt/live/$REGISTRY_DOMAIN/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/$REGISTRY_DOMAIN/privkey.pem;
client_max_body_size 1g;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Real-IP $remote_addr;
auth_basic "Private Registry";
auth_basic_user_file $AUTH_DIR/htpasswd;
location /v2/ { proxy_pass http://127.0.0.1:$REGISTRY_PORT; }
}
NGINX
sudo nginx -t && sudo systemctl reload nginx # Check
Step 3 – Enable Service
Make sure Docker and Nginx start automatically and are healthy.
All Linux
sudo systemctl enable --now docker nginx
sudo docker ps --format "table {{.Names}} {{.Status}} {{.Ports}}" | grep -E '^registry' || echo "registry not started?"
sudo ss -tlnp | grep -E ':(80|443)' # Check
Step 4 – Firewall & NAT
Open TCP 80 and 443 so clients can reach the proxy. If your distro uses a different firewall, use the matching commands.
On Debian / Ubuntu (UFW)
sudo ufw allow 80,443/tcp
sudo ufw reload
sudo ufw status verbose | grep -E '80|443' # Check
On RHEL / Rocky / Alma / CentOS Stream / Fedora (firewalld)
sudo firewall-cmd --add-service=http --add-service=https --permanent
sudo firewall-cmd --reload
sudo firewall-cmd --list-all | grep services # Check
On Arch / Manjaro / openSUSE (nftables)
sudo nft add rule inet filter input tcp dport {80,443} accept
sudo nft list ruleset | sed -n '1,120p' # Check
Step 5 – Add-Ons / Plugins
You can add S3 or other remote storage, enable Content Trust, or integrate with OAuth/SSO. For a quick win, filesystem storage on fast SSDs is often enough.
Step 6 – Client Files / Access
Log in from a client and push a test image. Replace the domain with your registry FQDN.
All Linux
docker login https://$REGISTRY_DOMAIN
docker pull alpine:3
docker tag alpine:3 $REGISTRY_DOMAIN/demo/alpine:3
docker push $REGISTRY_DOMAIN/demo/alpine:3
docker pull $REGISTRY_DOMAIN/demo/alpine:3 # Check
Advanced Add-Ons
Below are optional but highly recommended enhancements. They are safe defaults designed to work on most Linux servers and CI systems.
Docker Compose Variant
Run the registry and Nginx behind a dedicated bridge network. Certificates are read from /etc/letsencrypt/live/$REGISTRY_DOMAIN/.
version: "3.9"
services:
registry:
image: registry:2
container_name: registry
restart: unless-stopped
environment:
REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY: /var/lib/registry
volumes:
- /var/lib/registry:/var/lib/registry
- /etc/docker/registry/auth:/auth:ro
networks: [regnet]
expose:
- "5000"
nginx:
image: nginx:stable
container_name: reg-nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- /etc/letsencrypt:/etc/letsencrypt:ro
- /etc/nginx/conf.d:/etc/nginx/conf.d
- /etc/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
depends_on: [registry]
networks: [regnet]
networks:
regnet:
driver: bridge
Check: docker compose ps should show both services healthy and ss -tlnp should list 80/443.
Certbot Automation
Automate certificate issuance and renewal using webroot ACME. Use the OS-specific block below so you can copy/paste safely.
Debian / Ubuntu
sudo install -d -m 755 /var/www/_letsencrypt
sudo apt -y install certbot
sudo certbot certonly --webroot -w /var/www/_letsencrypt -d "$REGISTRY_DOMAIN" --email "$EMAIL" --agree-tos --no-eff-email
sudo openssl x509 -in /etc/letsencrypt/live/$REGISTRY_DOMAIN/fullchain.pem -noout -dates # Check
sudo certbot renew --dry-run # Check
RHEL / Rocky / Alma / CentOS Stream / Fedora
sudo install -d -m 755 /var/www/_letsencrypt
sudo dnf -y install certbot
sudo certbot certonly --webroot -w /var/www/_letsencrypt -d "$REGISTRY_DOMAIN" --email "$EMAIL" --agree-tos --no-eff-email
sudo openssl x509 -in /etc/letsencrypt/live/$REGISTRY_DOMAIN/fullchain.pem -noout -dates # Check
sudo certbot renew --dry-run # Check
Arch / Manjaro
sudo install -d -m 755 /var/www/_letsencrypt
sudo pacman -Syu --noconfirm certbot
sudo certbot certonly --webroot -w /var/www/_letsencrypt -d "$REGISTRY_DOMAIN" --email "$EMAIL" --agree-tos --no-eff-email
sudo openssl x509 -in /etc/letsencrypt/live/$REGISTRY_DOMAIN/fullchain.pem -noout -dates # Check
sudo certbot renew --dry-run # Check
openSUSE / SLE
sudo install -d -m 755 /var/www/_letsencrypt
sudo zypper install -y certbot
sudo certbot certonly --webroot -w /var/www/_letsencrypt -d "$REGISTRY_DOMAIN" --email "$EMAIL" --agree-tos --no-eff-email
sudo openssl x509 -in /etc/letsencrypt/live/$REGISTRY_DOMAIN/fullchain.pem -noout -dates # Check
sudo certbot renew --dry-run # Check
Object Storage Backend (S3/GCS compatible)
Switch to object storage for durability and easier scaling. Example with AWS S3:
sudo docker stop registry
sudo docker rm registry || true
sudo docker run -d --name registry --restart unless-stopped -p 127.0.0.1:$REGISTRY_PORT:5000 -e REGISTRY_STORAGE=s3 -e REGISTRY_STORAGE_S3_REGION=eu-central-1 -e REGISTRY_STORAGE_S3_BUCKET=my-registry-bucket -e REGISTRY_STORAGE_S3_ACCESSKEY=<AWS_ACCESS_KEY_ID> -e REGISTRY_STORAGE_S3_SECRETKEY=<AWS_SECRET_ACCESS_KEY> -e REGISTRY_STORAGE_S3_ENCRYPT=true -e REGISTRY_STORAGE_S3_SECURE=true -e REGISTRY_STORAGE_S3_ROOTDIRECTORY=/docker/registry registry:2
curl -s https://$REGISTRY_DOMAIN/v2/ | head -n 1 # Check (401 expected due to auth)
Garbage Collection
Reclaim space after deletes by running offline garbage collection on a schedule.
cat <<'EOF' | sudo tee /usr/local/sbin/registry-gc.sh
#!/usr/bin/env bash
set -euo pipefail
sudo docker stop registry
sudo docker run --rm -v "$REGISTRY_DATA":/var/lib/registry registry:2 bin/registry garbage-collect /etc/docker/registry/config.yml || true
sudo docker start registry
EOF
sudo chmod +x /usr/local/sbin/registry-gc.sh
echo "0 3 * * sun root /usr/local/sbin/registry-gc.sh" | sudo tee /etc/cron.d/registry-gc
sudo systemctl restart cron 2>/dev/null || sudo systemctl restart crond # Check
CI/CD Push Example (GitHub Actions)
Push images to your registry on tag creation.
name: Push image
on:
push:
tags: ["v*"]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Login
run: echo "${{ '{' }}secrets.REGISTRY_PASSWORD{{ '}' }}" | docker login https://$REGISTRY_DOMAIN -u "$AUTH_USER" --password-stdin
- name: Build & Push
run: |
docker build -t $REGISTRY_DOMAIN/demo/app:${{ '{' }}github.ref_name{{ '}' }} .
docker push $REGISTRY_DOMAIN/demo/app:${{ '{' }}github.ref_name{{ '}' }}
Extra Hardening
- Restrict access to
/v2/by IP allowlists in Nginx or a WAF. - Prefer TLS 1.3 (if clients support it), enable OCSP stapling, and use modern ciphers.
- Set
client_max_body_sizethoughtfully and monitor disk/network usage.
Security
Tighten TLS and access. Ensure only HTTPS is exposed, keep the registry bound to localhost, and rotate credentials regularly.
All Linux
# Ensure the container only listens on localhost
sudo docker inspect -f '{{range .NetworkSettings.Ports}}{{.}}{{end}}' registry
# Minimal strong TLS tweaks (example ciphers if you manage nginx.conf)
# ssl_protocols TLSv1.2 TLSv1.3;
# ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256';
# Permissions for auth folder
sudo chown -R root:root "$AUTH_DIR" && sudo chmod -R 700 "$AUTH_DIR"
test -f "$AUTH_DIR/htpasswd" && echo "auth file present" # Check
Backup & Restore
Back up the auth file and the registry data directory. Store off the server.
Backup
sudo tar -C / -czf /root/registry-backup.tgz "$AUTH_DIR" "${REGISTRY_DATA}"
ls -lh /root/registry-backup.tgz # Check
Restore
sudo systemctl stop nginx
sudo docker stop registry
sudo tar -C / -xzf /root/registry-backup.tgz
sudo docker start registry
sudo systemctl start nginx
sudo docker ps | grep -E '^registry\b' # Check
Troubleshooting
Service not running (systemd unit names)
sudo systemctl status --no-pager docker
sudo systemctl status --no-pager nginx
sudo journalctl -u docker -u nginx --since "20 min ago" | tail -n 200
Port blocked (80/443 not reachable)
ss -tulpen | grep -E ':(80|443)\b' || echo "not listening"
sudo ufw status verbose 2>/dev/null || sudo firewall-cmd --list-all 2>/dev/null || sudo nft list ruleset 2>/dev/null | sed -n '1,120p'
Auth failures (wrong password or missing file)
sudo ls -l "$AUTH_DIR/htpasswd"
sudo htpasswd -B "$AUTH_DIR/htpasswd" "$AUTH_USER"
sudo nginx -t && sudo systemctl reload nginx # Check
Image push too large (client_max_body_size)
# Ensure this appears inside the 443 server block
client_max_body_size 1g; # Check
Wrong upstream port (registry not bound to 127.0.0.1)
sudo docker ps --format "table {{.Names}} {{.Ports}}"
# If exposed externally, remove and re-run with -p 127.0.0.1:$REGISTRY_PORT:5000
Next Steps
Consider adding object storage (S3/GCS), enabling garbage collection, and integrating with your CI/CD pipeline. Keep your TLS certificates renewed and monitor disk space. Reference again the official docs: Docker Registry documentation
