← Start of tutorial

WIUNIX ACADEMY

Private Docker registry with Nginx HTTPS and native authentication

Page 3 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.

Ubuntu 26.04 LTSReviewed 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
08

LET’S BUILD

Place the existing trusted certificate securely

Use your approved root-only transfer/editor procedure to place the existing fullchain and unencrypted service private key in the two files below; never paste them in browser settings. Require root ownership, real regular files, private key0600, TLS directory0700 and fullchain0644. Do not generate a self-signed public certificate as a workaround. No public CA account or issuance is performed by this guide.

bash

Registry VM

sudo python3 - <<'PY'
import os
for name in ('fullchain.pem','privkey.pem'):
    f='/srv/academy-registry/tls/'+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/tls/fullchain.pem' '/srv/academy-registry/tls/privkey.pem'
sudo chmod 0644 '/srv/academy-registry/tls/fullchain.pem'
How do I know it worked?

Existing trusted chain and matching key are saved privately; neither remains an empty placeholder.

09

LET’S BUILD

Check hostname, expiry and key match

Run on the VM before enabling HTTPS. The checks establish SAN match, at least seven days remaining and public-key equality; they do not establish full-chain trust from every client. Verify full-chain trust from a clean managed client in the HTTPS check below. Renewal must validate the new chain/key, run Nginx syntax validation, reload and check the actually served certificate.

bash

Registry VM

sudo openssl x509 -in '/srv/academy-registry/tls/fullchain.pem' -noout -checkhost 'registry.example.net'
sudo openssl x509 -in '/srv/academy-registry/tls/fullchain.pem' -noout -checkend 604800
sudo python3 - <<'PY'
import subprocess
base='/srv/academy-registry/tls/'
a=subprocess.check_output(['openssl','x509','-in',base+'fullchain.pem','-pubkey','-noout'])
b=subprocess.check_output(['openssl','pkey','-in',base+'privkey.pem','-pubout'])
assert a==b, 'Certificate and private key do not match'
print('Public-key match: OK')
PY
How do I know it worked?

Hostname/expiry checks succeed and public keys match.

10

LET’S BUILD

Validate Nginx before its first reload

Run on the dedicated VM. The site filename must be new, not a symlink. A syntax failure moves only this new file into recovery and leaves the running config alone. A reload failure needs inspection and rollback of this new site file; do not restart unrelated services. Publish TCP443 through your approved network policy only after validating trust. Keep the backend port unavailable remotely; no firewall rules are changed by these commands.

Expect 401 and a Basic challenge for the chosen realm, plus Docker-Distribution-Api-Version: registry/2.0. That is the healthy unauthenticated result. A200 without credentials is a security failure for this design. Do not add a browser/SSO redirect to /v2/.

bash

Registry VM

set -eu
sudo test ! -e '/etc/nginx/conf.d/academy-registry.conf'
sudo test ! -L '/etc/nginx/conf.d/academy-registry.conf'
sudo python3 - <<'PY'
import os,stat
from pathlib import Path
src=Path('/srv/academy-registry/config/nginx.conf')
assert stat.S_ISREG(src.lstat().st_mode) and src.stat().st_uid==0
fd=os.open('/etc/nginx/conf.d/academy-registry.conf',os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o644)
with os.fdopen(fd,'wb') as out: out.write(src.read_bytes())
os.chmod('/etc/nginx/conf.d/academy-registry.conf',0o644)
PY
if sudo nginx -t; then
  sudo systemctl reload nginx
else
  sudo mv '/etc/nginx/conf.d/academy-registry.conf' '/srv/academy-registry/recovery/failed-nginx.conf'
  exit 1
fi
curl --silent --show-error --dump-header - --output /dev/null 'https://registry.example.net/v2/'
How do I know it worked?

Nginx syntax/reload succeed; public verified HTTPS returns the expected 401 challenge.

11

LET’S BUILD

Use a credential helper for Docker login

Run on a Linux client as its normal user, not on the registry VM as root. Initialize pass/GPG and install the official helper using its owning documentation first. This command creates a fresh isolated Docker client configuration containing only helper routing. Docker prompts privately for the password; do not use a password option or an environment dump. Failures must be fixed in the helper/trust setup, never by removing the helper and accepting plaintext JSON credentials.

bash

Linux client

set -eu
command -v docker-credential-pass
pass ls >/dev/null
python3 - <<'PY'
import json, os
from pathlib import Path
p=Path.home()/'.config'/'academy-registry-client'
assert not p.is_symlink() and not p.exists(), 'Choose a fresh client directory'
p.parent.mkdir(mode=0o700,exist_ok=True)
assert not p.parent.is_symlink()
p.mkdir(mode=0o700)
f=p/'config.json'
with f.open('x',encoding='utf-8') as out:
    json.dump({'credHelpers':{'registry.example.net':'pass'}},out)
os.chmod(f,0o600)
print('Only helper routing written; no password in JSON')
PY
docker --config "$HOME/.config/academy-registry-client" login 'registry.example.net' --username 'academy-publisher'
How do I know it worked?

Login Succeeded; config.json contains helper routing without an auth/password entry.

12

LET’S BUILD

Build and push a harmless pilot image

Run on the client in a fresh directory. The scratch image contains only a text marker and has no executable; it is for registry transfer, not container execution. Record the immutable manifest digest printed by push in a nonsecret change record. Never use a production base image containing credentials for this test.

bash

Linux client

set -eu
test ! -e "$HOME/academy-registry-image"
mkdir -m 0700 "$HOME/academy-registry-image"
printf 'WIUNIX Academy registry pilot\n' > "$HOME/academy-registry-image/marker.txt"
printf 'FROM scratch\nCOPY marker.txt /marker.txt\n' > "$HOME/academy-registry-image/Dockerfile"
docker --config "$HOME/.config/academy-registry-client" build \
  --tag 'registry.example.net/academy/academy-registry:pilot' "$HOME/academy-registry-image"
docker --config "$HOME/.config/academy-registry-client" push 'registry.example.net/academy/academy-registry:pilot'
How do I know it worked?

Push completes and reports a sha256 manifest digest.

13

LET’S BUILD

Pull from an independent clean client

On a second client without the pilot image, repeat helper/login setup, then run this panel. Compare the RepoDigest with the push record; a cached image on the original client is weaker evidence. A tag can change: for deployment, copy the recorded digest from your change record into the image reference and pull by digest. Do not infer authenticity or image safety from digest equality alone.

bash

Linux client

docker --config "$HOME/.config/academy-registry-client" pull 'registry.example.net/academy/academy-registry:pilot'
docker image inspect 'registry.example.net/academy/academy-registry:pilot' --format '{{json .RepoDigests}}'
How do I know it worked?

A previously absent image downloads and RepoDigests matches the recorded push digest.

Explore all tutorials ↗