← Start of tutorial

WIUNIX ACADEMY

Private Docker registry with Nginx HTTPS and native authentication

Page 2 of 5. Review the earlier steps and prerequisites before continuing.

YOUR SYSTEM. YOUR STEPS.

Where are you working?

Choose your OS and version. The prerequisites, commands and troubleshooting follow your environment.

Debian 12Reviewed against official documentation ·

USE YOUR OWN VALUES

Configure this tutorial

Set the example values for your environment; related explanations and copy panels update together. Never enter passwords, private keys or tokens. Values stay in this tab and are not sent to the server or share links. Check actual DNS, certificates and network policy before running commands.

Different IP addresses, domains or ports? Open the settings below, change the values and select “Apply to tutorial”, then copy the updated commands.

Set values for my environment8 editable values
04

LET’S BUILD

Create the native bcrypt credential file

The password travels to htpasswd through stdin, never argv. The generated hash is written directly to a new protected file and is not printed. Empty, malformed or non-ASCII input stops. Unlinking the temporary plaintext is not guaranteed physical erasure on SSDs or snapshots; protect storage and backups. Distribution loads credentials at startup: rotation requires a controlled restart and does not provide fine-grained authorization.

bash

Registry VM

sudo python3 - <<'PY'
import os, stat, subprocess
from pathlib import Path
p=Path('/srv/academy-registry/auth/password-input')
s=p.lstat(); assert stat.S_ISREG(s.st_mode) and s.st_uid==0 and stat.S_IMODE(s.st_mode)==0o600
secret=p.read_bytes(); assert secret.endswith(b'\n') and secret.count(b'\n')==1
assert 20 <= len(secret.rstrip(b'\n')) <= 72, 'Use 20..72 ASCII bytes'
assert all(33<=c<=126 for c in secret.rstrip(b'\n')), 'Printable ASCII, no spaces'
result=subprocess.run(['htpasswd','-nBi','-C','12','academy-publisher'],input=secret,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,check=True)
assert result.stdout.startswith(b'academy-publisher:$2')
f=Path('/srv/academy-registry/auth/htpasswd')
fd=os.open(f,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o640)
with os.fdopen(fd,'wb') as out: out.write(result.stdout)
os.chown(f,0,23153); os.chmod(f,0o640)
p.unlink()
print('bcrypt file created; plaintext input removed (not a secure-erasure guarantee)')
PY
How do I know it worked?

Nonempty bcrypt file is root:registry-GID 0640; plaintext input is removed.

05

LET’S BUILD

Open two new configuration files

Create the new files on the VM, then place the following two panels in their respective files. Do not paste terminal commands into YAML/Nginx files. Keep the editor and consecutive file panels together. Neither file contains a password or private key; the next step privately adds the persistent upload-state secret.

bash

Registry VM

sudo python3 - <<'PY'
import os
for name in ('config/config.yml','config/nginx.conf'):
    f='/srv/academy-registry/'+name
    fd=os.open(f,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
    os.close(fd)
PY
sudo nano '/srv/academy-registry/config/config.yml' '/srv/academy-registry/config/nginx.conf'
How do I know it worked?

Both new files contain the following exact file panels.

Distribution configuration

The container listens on its private interface; only the Docker host loopback mapping is published. Fixed external host makes upload Location use the trusted HTTPS origin. Deletion and background upload purging are deliberately disabled for this first pilot. Keep incomplete-upload disk usage under observation.

yaml · /srv/academy-registry/config/config.yml

Registry VM

version: 0.1
log:
  level: error
storage:
  filesystem:
    rootdirectory: /var/lib/registry
  delete:
    enabled: false
  maintenance:
    uploadpurging:
      enabled: false
    readonly:
      enabled: false
http:
  addr: 0.0.0.0:5000
  host: https://registry.example.net
  relativeurls: false
  draintimeout: 60s
auth:
  htpasswd:
    realm: academy-registry
    path: /auth/htpasswd
How do I know it worked?

Save exactly in the displayed filename; later native startup/syntax checks validate its use.

TLS proxy configuration

This file belongs to the standard Nginx HTTP conf.d include. Nginx does not perform a second authentication layer. It forwards Authorization to native Distribution, replaces client-supplied trust headers and rejects a different Host. This is a direct-client boundary; do not put it behind another proxy without designing trusted-client-IP handling.

1024 MiB bounds each HTTP request, not the total size of a multi-layer image. Request/response buffering is off and HTTP/1.1 is explicit for chunked uploads. 900 seconds is an idle interval, not a whole-transfer deadline or unlimited-upload guarantee. Retry an interrupted push through the client; never replay a partially streamed body to another upstream automatically.

nginx · /srv/academy-registry/config/nginx.conf

Registry VM

server {
    listen 443 ssl;
    server_name registry.example.net;
    if ($host != registry.example.net) { return 421; }
    ssl_certificate /srv/academy-registry/tls/fullchain.pem;
    ssl_certificate_key /srv/academy-registry/tls/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    client_max_body_size 1024m;
    client_body_timeout 900s;
    access_log off;
    error_log /var/log/nginx/academy-registry-error.log error;
    location /v2/ {
        proxy_pass http://127.0.0.1:5000;
        proxy_http_version 1.1;
        proxy_set_header Host registry.example.net;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header X-Forwarded-Host registry.example.net;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header Forwarded "";
        proxy_set_header Connection "";
        proxy_request_buffering off;
        proxy_buffering off;
        proxy_connect_timeout 5s;
        proxy_read_timeout 900s;
        proxy_send_timeout 900s;
        proxy_next_upstream off;
    }
    location / { return 404; }
}
How do I know it worked?

Save exactly in the displayed filename; later native startup/syntax checks validate its use.

06

LET’S BUILD

Persist a private upload-state signing secret

Run once after saving the YAML. A cryptographically random secret is inserted directly into the root-owned file, which is then readable by the container GID. Keep it in the protected backup with configuration. Changing it invalidates in-flight upload state; an automatically changing secret on each restart makes interrupted uploads unreliable. Do not display the resulting YAML in logs or Git.

bash

Registry VM

sudo python3 - <<'PY'
import os, secrets
from pathlib import Path
p=Path('/srv/academy-registry/config/config.yml')
text=p.read_text(encoding='utf-8')
assert text.count('http:\n')==1 and '  secret:' not in text
text=text.replace('http:\n','http:\n  secret: '+secrets.token_hex(32)+'\n',1)
p.write_text(text,encoding='utf-8')
os.chown(p,0,23153); os.chmod(p,0o640)
print('Persistent upload-state signing secret generated privately')
PY
How do I know it worked?

A persistent random secret exists privately; file mode is 0640.

07

LET’S BUILD

Start the digest-pinned registry on loopback

Reviewed on 2026-10-21: Distribution 3.1.2 is the stable upstream release. The official registry image multi-platform index digest was resolved from Docker Hub and pulled successfully; the complete immutable reference is in the panel. Verify the pulled RepoDigests before starting. Do not replace it with latest or a floating major tag. Schedule security review; an immutable digest does not update itself.

The container runs as numeric 23153, with a read-only root filesystem, dropped capabilities and no Docker socket. Only the data bind mount is writable. Logging is disabled for this bounded pilot to avoid accidental credential output, including the upstream missing-htpasswd bootstrap behavior; arrange protected operational monitoring before production. The explicit nonempty auth-file check and precreated mount prevent that bootstrap. Do not expose the loopback HTTP URL to clients.

bash

Registry VM

set -eu
sudo test -s '/srv/academy-registry/auth/htpasswd'
sudo test -s '/srv/academy-registry/config/config.yml'
sudo docker pull 'docker.io/library/registry:3.1.2@sha256:ddf754342cfc8acc51a56d5d0ab6af06826461864460636d8bd5c546dab2a7b8'
sudo docker image inspect 'docker.io/library/registry:3.1.2@sha256:ddf754342cfc8acc51a56d5d0ab6af06826461864460636d8bd5c546dab2a7b8' --format '{{json .RepoDigests}}'
sudo docker run -d --name 'academy-registry' --restart unless-stopped \
  --user '23153:23153' \
  --read-only --cap-drop ALL --security-opt no-new-privileges \
  --log-driver none \
  -p '127.0.0.1:5000:5000' \
  --mount 'type=bind,src=/srv/academy-registry/data,dst=/var/lib/registry' \
  --mount 'type=bind,src=/srv/academy-registry/auth,dst=/auth,readonly' \
  --mount 'type=bind,src=/srv/academy-registry/config,dst=/etc/distribution,readonly' \
  'docker.io/library/registry:3.1.2@sha256:ddf754342cfc8acc51a56d5d0ab6af06826461864460636d8bd5c546dab2a7b8' /etc/distribution/config.yml
ready=no
for attempt in $(seq 1 30); do
  status=$(curl --silent --show-error --max-time 2 --output /dev/null --write-out '%{http_code}' 'http://127.0.0.1:5000/v2/' || true)
  if test "$status" = 401; then ready=yes; break; fi
  sleep 1
done
test "$ready" = yes
printf 'Loopback /v2/: 401\n'
sudo ss -lntp
How do I know it worked?

Local /v2/ returns 401, and ss shows only 127.0.0.1:5000 for the host mapping.

Explore all tutorials ↗