Giving My Proxmox Server a Domain, Dropping :8006, and Locking Down the Login
Last time, I got Proxmox running on the old laptop after fighting through the WiFi networking mess. This time: giving it a proper domain name, getting rid of the :8006 in the URL, and locking the login down before I forget to circle back to it.
No detours this time. Just the setup, the problems I actually hit, and how I fixed each one.
- The Plan
- Step 1: Tailscale for Private Access
- Step 2: Pointing a Domain at It via Cloudflare
- Step 3: A Real SSL Cert via ACME + Cloudflare DNS Challenge
- Step 4: Caddy So I Don't Have to Type :8006
- Considered and Skipped: SSH-Tunnel-Only Access
- Step 5: Two-Factor Authentication
- Step 6: fail2ban
- Final Checklist
- Closing Thoughts
The Plan
Three pieces, each doing one job:
- Tailscale: private network access. No public exposure at all.
- Cloudflare: DNS for the domain, and later, the DNS challenge for a real SSL cert.
- Caddy: reverse proxy on port 443, so I stop typing
:8006every single time.
On top of that: 2FA on the Proxmox login, and fail2ban to auto-ban repeated failed logins.
Step 1: Tailscale for Private Access
Installed directly on the Proxmox host:
curl -fsSL https://tailscale.com/install.sh | sh
tailscale up
Followed the auth link, logged into my Tailscale account. Grabbed the assigned IP:
tailscale ip -4
Got back something like 100.x.y.z. Installed Tailscale on my other devices too (phone, laptop), same account.
Step 2: Pointing a Domain at It via Cloudflare
In the Cloudflare dashboard: DNS → Records → Add record
- Type:
A - Name:
proxmox - IPv4 address: the Tailscale IP from Step 1
- Proxy status: DNS only (gray cloud). This is mandatory: Cloudflare's orange-cloud proxy can't route to a private Tailscale IP.
Step 3: A Real SSL Cert via ACME + Cloudflare DNS Challenge
Proxmox ships with a self-signed cert, which throws a browser warning every time. Fixed it with a Let's Encrypt cert issued via Cloudflare's DNS challenge (works even though the Proxmox UI itself isn't publicly reachable).
Create a Cloudflare API token: Cloudflare dashboard → profile icon → My Profile → API Tokens → Create Token → template "Edit zone DNS" → scope it to the specific domain. Copy the token, it's shown once.
Add the DNS challenge plugin in Proxmox: Datacenter → ACME → Challenge Plugins → Add → DNS.
| Field | Value |
|---|---|
| Plugin ID | cloudflare |
| DNS API | Cloudflare Managed DNS |
| CF_Token | the token from above |
| CF_Zone_ID | see below |
| CF_Account_ID, CF_Email, CF_Key | leave empty |
Zone ID is on the domain's Overview page in the Cloudflare dashboard, right sidebar under "API".
Quick gotcha: "Cloudflare Managed DNS" in that dropdown is not the same thing as ClouDNS (
cloudns.net). Different provider entirely, easy to mix up on a glance.
Add the domain and order the cert:
Node → Certificates → ACME → Domains → Add. Challenge Type: DNS, Plugin: cloudflare, Domain: proxmox.yourdomain.com. Create, then click Order Certificates Now to actually trigger the Let's Encrypt request (adding the domain alone doesn't issue anything).
Step 4: Caddy So I Don't Have to Type :8006
DNS maps a name to an IP, not a port. Browsers default to 443, Proxmox listens on 8006, hence the port always ending up in the URL. Fixed with a reverse proxy on 443.
Install:
apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list
apt update
apt install caddy
Configure /etc/caddy/Caddyfile, reusing the ACME cert Proxmox already got in Step 3 instead of having Caddy request its own:
proxmox.yourdomain.com {
tls /etc/pve/local/pveproxy-ssl.pem /etc/pve/local/pveproxy-ssl.key
reverse_proxy localhost:8006 {
transport http {
tls_insecure_skip_verify
}
}
}
Problem: Caddy can't read the cert files
What was wrong: Caddy runs as user caddy by default. The Proxmox cert files are root-only.
The fix: override the systemd unit to run Caddy as root, an acceptable trade-off for a homelab box:
systemctl edit caddy
[Service]
User=root
Group=root
systemctl daemon-reload
systemctl restart caddy
systemctl status caddy
https://proxmox.yourdomain.com now loads clean, no port, no cert warning.
Considered and Skipped: SSH-Tunnel-Only Access
Briefly looked into binding Caddy to 127.0.0.1 only and requiring an SSH tunnel before the web UI is reachable at all, even from devices already on the tailnet. Decided against it for now: Tailscale already covers the actual threat model (nobody off-tailnet can reach the box), and requiring a manual SSH tunnel just to open the dashboard adds daily friction for marginal extra protection. Put that effort into 2FA and fail2ban instead, which cover threats Tailscale doesn't touch at all (leaked password, brute force).
Step 5: Two-Factor Authentication
Datacenter → user icon (top right) → My Settings → TFA tab → Add → TOTP. Scanned the QR code with an authenticator app, entered the 6-digit code to confirm, saved the recovery codes somewhere safe. Logged out and back in to confirm the OTP prompt actually appears.
Problem: "failed to verify TOTP challenge (500)"
What was wrong: adding TOTP failed with a 500, not the usual "wrong code" error, but an actual server-side exception.
How I found out: checked system time:
timedatectl status
System clock synchronized: no. There it was.
The fix:
apt install -y chrony
systemctl enable --now chrony
timedatectl status
Waited a few seconds for it to sync, retried the TOTP setup from scratch (new QR code), and it went through clean.
Step 6: fail2ban
Install:
apt update
apt install -y fail2ban
SSH jail, in /etc/fail2ban/jail.local:
[sshd]
enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
findtime = 600
bantime = 3600
Proxmox web UI jail, in /etc/fail2ban/jail.d/proxmox.conf:
[proxmox]
enabled = true
port = https,http,8006
filter = proxmox
backend = systemd
journalmatch = _SYSTEMD_UNIT=pvedaemon.service
maxretry = 3
findtime = 600
bantime = 3600
Problem: the proxmox jail doesn't exist
What was wrong: fail2ban-client status only showed sshd. No proxmox jail, no error message pointing anywhere obvious.
How I found out: the jail definition originally pointed logpath at /var/log/daemon.log. That file doesn't exist on this box. Proxmox VE 8 is based on Debian 12, which doesn't install rsyslog by default anymore, only systemd-journald. When a jail's log file is missing at startup, fail2ban just silently drops that jail instead of throwing a loud error.
The fix: point the jail at the systemd journal directly instead of a log file (the config above already reflects this: backend = systemd + journalmatch).
Problem: the filter file itself is missing
What was wrong: even after fixing the jail backend, still no proxmox jail. Checked:
ls /etc/fail2ban/filter.d/proxmox.conf
Not there. Assumed it shipped with the package, but it didn't, at least not on this install.
The fix: created it manually:
nano /etc/fail2ban/filter.d/proxmox.conf
[Definition]
failregex = pvedaemon\[.*authentication (verification )?failure; rhost=<HOST> user=.* msg=.*
ignoreregex =
The <HOST> tag matters: that's what tells fail2ban which IP to actually ban.
Tested it against the journal directly, no restart needed:
fail2ban-regex systemd-journal /etc/fail2ban/filter.d/proxmox.conf
Showed matches against past failed logins, confirming the regex was right.
Restarted and verified:
systemctl restart fail2ban
fail2ban-client status
fail2ban-client status sshd
fail2ban-client status proxmox
Both jails listed, both active.
One thing worth keeping handy: the unban command, for whenever I inevitably lock myself out typing my own password wrong:
fail2ban-client set sshd unbanip <your-ip>
fail2ban-client set proxmox unbanip <your-ip>
Final Checklist
-
https://proxmox.yourdomain.comloads with no port number and no cert warning - Login prompts for a TOTP code after the password
-
fail2ban-client statuslists bothsshdandproxmox -
fail2ban-regex systemd-journal /etc/fail2ban/filter.d/proxmox.confshows matched lines -
timedatectl statusshows the clock synchronized
Closing Thoughts
Every problem in this post traced back to the same root cause, really: Proxmox 8 sitting on Debian 12, which quietly changed defaults (no rsyslog, journal-only logging) in ways that broke tooling written with the old assumptions in mind. Worth remembering for anything else I bolt onto this box later.
Considered going further with SSH-tunnel-only access and skipped it. Still might revisit that if this ever stops being "just a homelab." For now, Tailscale + a real cert + 2FA + fail2ban is a solid baseline.
Next up: actually backing this thing up before I lose a VM to my own mistakes. Backups are the thing everyone skips until they need one.