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.
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
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.
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
Nonempty bcrypt file is root:registry-GID 0640; plaintext input is removed.
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.
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'
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.
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
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.
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; }
}
Save exactly in the displayed filename; later native startup/syntax checks validate its use.
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.
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
A persistent random secret exists privately; file mode is 0640.
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.
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
Local /v2/ returns 401, and ss shows only 127.0.0.1:5000 for the host mapping.