The problem
Proxmox signs its own certificate, so your browser warns you every time you open the UI, and the UI sits on port 8006.
A browser trusts a certificate without asking only when a public CA, like Let’s Encrypt, signed it. A public CA only signs a name it can check you own: a domain, not an IP or a .lan name.
This guide gets a Let’s Encrypt certificate for a name in your domain, keeps that name only on your router, and puts nginx in front of Proxmox on port 443. Only getting and renewing the certificate needs the internet; opening the UI works with the internet down.
sequenceDiagram
participant B as Browser
participant M as MikroTik
participant N as LAN proxy (CT 203)
participant P as Proxmox
participant C as Cloudflare DNS
participant L as Let's Encrypt
Note over N,L: Once, then about every 30 days (needs internet)
N->>C: TXT record, with your API token
L->>C: Checks the TXT record
L-->>N: Certificate for ho4.axc.sh
Note over B,P: Every visit (LAN only, works offline)
B->>M: Where is ho4.lan? (optional)
M-->>B: 192.168.0.203
B->>N: http://ho4.lan
N-->>B: 301 to https://ho4.axc.sh
B->>M: Where is ho4.axc.sh?
M-->>B: 192.168.0.203
B->>N: https://ho4.axc.sh
Note over B: Checks the certificate against the CA list already on your PC
N->>P: https://192.168.0.9:8006Install
Before you start
- Do this once per Proxmox machine: each one gets its own LAN proxy. For each machine, pick its profile in My Proxmox, then set this guide’s own values (CTID, domain, LAN name, name from outside) in its value box.
- Your own domain, with its DNS on Cloudflare.
- A Cloudflare API token: dash.cloudflare.com → Manage account → Account API tokens → Create token. In the policy, change All Domains to your domain, then under DNS & Zones tick DNS → Edit and nothing else. Create it and copy the token; Cloudflare shows it only once.
- The domain’s Zone ID: Domains → your domain → Overview, in the API box.
- A name for the UI that only your router knows. Not one you also publish through a Cloudflare tunnel; for access from outside, use a second name (see below).
CT Creation
{
read -rs -p "Root password for the new CT: " PW; echo
pct create \
203 \
/mnt/pve/evo/template/cache/alpine-3.24-default_20260714_amd64.tar.xz \
--hostname nginx-ho4 \
--memory 1024 \
--net0 name=eth0,bridge=vmbr0,firewall=1,gw=192.168.0.1,ip=192.168.0.203/24,hwaddr=DE:AD:DE:AD:02:03,type=veth,ip6=dhcp \
--rootfs local-lvm:2 \
--unprivileged 1 \
--ignore-unpack-errors \
--ostype alpine \
--password="$PW" \
--ssh-public-keys /root/.ssh/authorized_keys \
--start 1
unset PW
}pct enter 203Enable DHCP Leasing - Alpine
# Alpine
touch /hdd_request_ip.sh
echo "udhcpc -i eth0 -F $(hostname) -x hostname:$(hostname) -r $(hostname -i)" >> /hdd_request_ip.sh
echo -e "#!/sbin/openrc-run\n\
name=hdd_request_ip\n\
description=\"Request IP Address From Router\"\n\
command=\"sh ./hdd_request_ip.sh\"\n\
command_background=true\n\
pidfile=\"/run/\$name.pid\"\n\
# output_log=\"/var/log/homepage/output.log\"\n\
# error_log=\"/var/log/homepage/error.log\"\n\
\ndepend() {\n\
\tneed net\n\
\tuse dns logger netmount\n\
}" >> /etc/init.d/hdd_request_ip
chmod +x /etc/init.d/hdd_request_ip
rc-update add hdd_request_ip
service hdd_request_ip startInstall nginx and acme.sh
apk add nginx acme.sh openssl
rc-update add nginx default
rc-update add crond default
rc-service nginx start
rc-service crond startDNS record
/ip dns static add name=ho4.axc.sh address=192.168.0.203Get the certificate
{
read -rs -p "Cloudflare API token: " CF_Token; echo
read -r -p "Cloudflare Zone ID: " CF_Zone_ID
export CF_Token CF_Zone_ID
acme.sh --issue --server letsencrypt --dns dns_cf -d ho4.axc.sh
unset CF_Token CF_Zone_ID
}Install the certificate
mkdir -p /etc/nginx/ssl
acme.sh --install-cert -d ho4.axc.sh \
--key-file /etc/nginx/ssl/ho4.axc.sh.key \
--fullchain-file /etc/nginx/ssl/ho4.axc.sh.pem \
--reloadcmd "rc-service nginx reload"
acme.sh --install-cronjobProxy to Proxmox
cat > /etc/nginx/http.d/proxmox.conf <<'EOF'
server {
listen 80;
server_name ho4.axc.sh;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name ho4.axc.sh;
ssl_certificate /etc/nginx/ssl/ho4.axc.sh.pem;
ssl_certificate_key /etc/nginx/ssl/ho4.axc.sh.key;
location / {
proxy_pass https://192.168.0.9:8006;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_buffering off;
client_max_body_size 0;
proxy_connect_timeout 3600s;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
send_timeout 3600s;
}
}
EOF
nginx -t && rc-service nginx reloadOptional: reach it at a .lan name
cat > /etc/nginx/http.d/lan-name.conf <<'EOF'
server {
listen 80;
server_name ho4.lan;
return 301 https://ho4.axc.sh/;
}
EOF
nginx -t && rc-service nginx reload/ip dns static add name=ho4.lan address=192.168.0.203Optional: reach it from outside
Needs the Cloudflared tunnel, and a second name. On the node that hosts the tunnel:
pct exec 102 -- add-cf-app --https prox.axc.sh 192.168.0.9 8006
pct exec 102 -- protect-cf-app prox.axc.sh "HDD"Heart Beat
https://ho4.axc.sh/A white page in a phone browser: open https://ho4.axc.sh/?mobile=0 for the desktop UI.
Update
apk upgrade nginx acme.sh
rc-service nginx restartRemove
Remove the .lan name
Only if you added it.
rm /etc/nginx/http.d/lan-name.conf
rc-service nginx reload/ip dns static remove [find name=ho4.lan]Remove it from the tunnel
Only if you added it. On the node that hosts the Cloudflared tunnel:
pct exec 102 -- del-cf-app prox.axc.shDelete the CT
/ip dns static remove [find name=ho4.axc.sh]pct stop 203
pct destroy 203Delete the API token on Cloudflare too.
