How to Set Up a Secure Private Docker Registry with HTTPS and a Web UI

Generate SSL Certificates

Let's generate a self-signed certificate:

mkdir certs
openssl req -newkey rsa:4096 -nodes -sha256 -keyout certs/domain.key -x509 -days 365 -out certs/domain.crt

Create a Docker Compose File

Here’s a minimal docker-compose.yml for a secure registry:

version: '3.8'

services:
  registry:
    image: registry:2
    ports:
      - "5000:5000"
    restart: always
    environment:
      REGISTRY_HTTP_TLS_CERTIFICATE: /certs/domain.crt
      REGISTRY_HTTP_TLS_KEY: /certs/domain.key
      REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY: /var/lib/registry
      REGISTRY_STORAGE_DELETE_ENABLED: 'true'
    volumes:
      - ./docker-registry-data:/var/lib/registry
      - ./certs:/certs

Start the registry:

docker compose up -d

Accessing Your Secure Registry

a. Trust the Certificate

  • On your client machine, add domain.crt to your trusted certificates.
  • For self-signed certs, Docker clients need to trust the cert. Copy domain.crt to /etc/docker/certs.d/<your-registry-domain>:5000/ca.crt on each client.

b. Test Access

curl -v --cacert certs/domain.crt https://<your-registry-domain>:5000/v2/

You should see an empty JSON {} response, indicating the registry is running .


5. Pushing an Image to Your Private Registry

a. Tag Your Image

Suppose you have a local image called myapp:latest:

docker tag myapp:latest <your-registry-domain>:5000/myapp:latest

b. Push the Image

docker push <your-registry-domain>:5000/myapp:latest

If you see errors about certificates, double-check that your client trusts the registry’s certificate .

c. Pull the Image (from another machine)

docker pull <your-registry-domain>:5000/myapp:latest

6. Installing a Web UI for Your Registry

A web UI makes it much easier to browse, manage, and delete images in your registry. One of the most popular options is Joxit/docker-registry-ui .

a. Add the UI to Your Compose File

Here’s a working example:

services:
  registry-ui:
    image: joxit/docker-registry-ui:main
    restart: always
    ports:
      - 80:80
    environment:
      - SINGLE_REGISTRY=true
      - REGISTRY_TITLE=Docker Registry UI
      - DELETE_IMAGES=true
      - SHOW_CONTENT_DIGEST=true
      - NGINX_PROXY_PASS_URL=https://registry-server:5000
      - SHOW_CATALOG_NB_TAGS=true
      - CATALOG_MIN_BRANCHES=1
      - CATALOG_MAX_BRANCHES=1
      - TAGLIST_PAGE_SIZE=100
      - REGISTRY_SECURED=false
      - CATALOG_ELEMENTS_LIMIT=1000
    container_name: registry-ui

  registry-server:
    image: registry:2.8.2
    restart: always
    environment:
      REGISTRY_HTTP_HEADERS_Access-Control-Allow-Origin: '[http://registry-ui.example.com]'
      REGISTRY_HTTP_HEADERS_Access-Control-Allow-Methods: '[HEAD,GET,OPTIONS,DELETE]'
      REGISTRY_HTTP_HEADERS_Access-Control-Allow-Credentials: '[true]'
      REGISTRY_HTTP_HEADERS_Access-Control-Allow-Headers: '[Authorization,Accept,Cache-Control]'
      REGISTRY_HTTP_HEADERS_Access-Control-Expose-Headers: '[Docker-Content-Digest]'
      REGISTRY_STORAGE_DELETE_ENABLED: 'true'
      REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY: /var/lib/registry
      REGISTRY_HTTP_TLS_CERTIFICATE: /certs/domain.crt
      REGISTRY_HTTP_TLS_KEY: /certs/domain.key
    volumes:
      - ./docker-registry-data:/var/lib/registry
      - ./certs:/certs
    container_name: registry-server
  • The UI connects to the registry via the Docker network using the service name (registry-server).
  • CORS headers are set so the UI can communicate with the registry securely.

Start everything:

docker compose up -d

Visit http://<your-server-ip>/ in your browser to access the UI.


7. Lessons Learned: Troubleshooting and Key Takeaways

Setting up a secure Docker registry with a web UI is powerful, but it comes with some tricky parts:

  • CORS headers must match exactly: The registry must send the correct Access-Control-Allow-Origin header matching your UI’s URL, or the browser will block requests.
  • Certificates must be trusted: Both the registry and any clients (including the UI) must trust your SSL certificate.
  • Networking matters: Use Docker Compose service names for internal communication, and make sure ports are mapped correctly for external access.
  • Environment variable formatting: Docker Compose environment variables must be strings, and for CORS headers, you often need to use JSON array syntax as a string (e.g., '["http://your-ui-domain"]').
  • Patience and careful debugging: Many issues come from small mismatches in URLs, ports, or certificate trust. Use curl -v and browser dev tools to inspect headers and responses.

Disclaimer: I wrote the raw version of this blogpost and beautified it via AI.