← Back to Blog

Giving My Proxmox Server a Domain, Dropping :8006, and Locking Down the Login

proxmoxhomelabtailscalecloudflarecaddysecurityfail2ban

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.

  1. The Plan
  2. Step 1: Tailscale for Private Access
  3. Step 2: Pointing a Domain at It via Cloudflare
  4. Step 3: A Real SSL Cert via ACME + Cloudflare DNS Challenge
  5. Step 4: Caddy So I Don't Have to Type :8006
  6. Considered and Skipped: SSH-Tunnel-Only Access
  7. Step 5: Two-Factor Authentication
  8. Step 6: fail2ban
  9. Final Checklist
  10. 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 :8006 every 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.

FieldValue
Plugin IDcloudflare
DNS APICloudflare Managed DNS
CF_Tokenthe token from above
CF_Zone_IDsee below
CF_Account_ID, CF_Email, CF_Keyleave 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.com loads with no port number and no cert warning
  • Login prompts for a TOTP code after the password
  • fail2ban-client status lists both sshd and proxmox
  • fail2ban-regex systemd-journal /etc/fail2ban/filter.d/proxmox.conf shows matched lines
  • timedatectl status shows 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.