Virtualization · TLS

Using Proxmox VE ACME with DynIP

This guide walks you through issuing a Let's Encrypt TLS certificate for your Proxmox VE web UI using Proxmox's built-in ACME client and the DNS-01 challenge over RFC 2136 / TSIG. By the end, https://your-node:8006 serves a real, automatically renewing certificate — no port 80, no HTTP-01, no self-signed warnings, and the whole setup runs as one non-interactive script.

What this is for

The ACME DNS-01 challenge proves you control a domain by writing a _acme-challenge TXT record. Because DynIP serves your zone and accepts TSIG-authenticated RFC 2136 UPDATEs, Proxmox's ACME client can write that record itself — via its nsupdate DNS plugin — prove ownership, and get a certificate without exposing anything to the internet.

The division of labor is clean: DynIP is authoritative DNS only. Your Proxmox node runs the entire ACME state machine — the Let's Encrypt account key, the certificate order, the challenge TXT publication, cert storage, and renewals. After the initial setup, the DynIP API is never involved in the certificate lifecycle; the only traffic between your node and DynIP is TSIG-signed DNS UPDATE messages to update.dynip.dev:53. This is the same architecture the cert-manager integration uses.

Proxmox ACME with DynIP gets you:

  • A real Let's Encrypt certificate on the web UI (port 8006), replacing the self-signed default
  • Automatic renewal handled by Proxmox's own pve-daily-update.timer — nothing extra to run
  • Port-80-free issuance via DNS-01 (works behind CGNAT, on private networks, anywhere)
  • A fully non-interactive, copy-paste setup — the generated script answers every prompt for you

If instead you want one certificate shared across many hosts — issued by the platform rather than by each node — DynIP's own SSL section (/ssl/generate) does that, and you distribute the files yourself. Node-local ACME, as described here, is the better fit when each node should own and renew its own cert.

What you'll need

  • A Proxmox VE 7.x or 8.x node with root shell access. This guide was validated end-to-end against Proxmox VE 8 and Let's Encrypt production.
  • A DynIP account with a zone provisioned. We'll use pve-lab.ddns.dynip.dev throughout; substitute your own zone. The zone name should resolve to the node's IP — the snippet's optional Update Zone IP step can set that for you, and internal (RFC 1918) addresses are fully supported.
  • The zone's TSIG algorithm set to HMAC-SHA256 (the DynIP default for new zones). The setup script hardcodes hmac-sha256; if your zone is on HMAC-MD5, flip it in the dashboard's Options modal first — the secret stays the same.
  • DNSSEC enabled on the zone — see the next section for why this matters.

The DNSSEC precondition

Let's Encrypt validates the DNS-01 challenge by resolving _acme-challenge.<your-zone> through its own validating resolvers. If your zone sits under a signed parent (DynIP's ddns.dynip.dev base is signed) but is itself unsigned, the chain of trust is broken: validating resolvers return SERVFAIL instead of the TXT record, and the challenge fails as invalid every time.

Fix: enable DNSSEC on the zone

In the dashboard, enable DNSSEC for the zone (the SSL / certificate flow prompts you to do this; it's the same gate DynIP's own certificate issuance uses). Once the child is signed and its DS record is published in the parent, the chain validates and resolvers answer normally. The Snippets panel checks this for you and shows a warning until DNSSEC is on.

If your zone's parent is not signed (for example a delegated domain you brought yourself, where you control the parent's DNSSEC), there's no broken chain to worry about and this step doesn't apply.

How the flow works

Once configured, ordering a certificate runs this sequence — entirely from your node:

  1. Proxmox asks Let's Encrypt for a new order and receives a DNS-01 challenge token.
  2. The nsupdate plugin publishes _acme-challenge.<your-zone> as a TXT record via TSIG-signed RFC 2136 UPDATE against update.dynip.dev:53. DynIP's nameservers verify the signature and serve the record immediately.
  3. Proxmox waits --validation-delay seconds (the script sets 60) for the record to be visible everywhere, then tells Let's Encrypt to validate.
  4. Let's Encrypt resolves the TXT record through its validating resolvers and confirms the token.
  5. The certificate is issued, Proxmox installs it, removes the challenge TXT record, and restarts pveproxy. The web UI on port 8006 now serves the new cert.

In our end-to-end test, the pvenode acme cert order step completed in well under a minute once the challenge record was visible. From pasting the script to a working certificate is roughly 90 seconds.

Step-by-step setup

In the dashboard, click Snippets on your zone, choose Proxmox VE ACME (RFC 2136), and fill in the Contact Email — it becomes the Let's Encrypt account contact where renewal and expiry notifications go. Copy the generated script and paste it into a root shell on the node. That's the whole procedure; the rest of this section explains what each step of the script does.

Step 0: Register the ACME account — non-interactively

Run by hand, pvenode acme account register prompts twice: once to pick the ACME endpoint and once to accept the Terms of Service. The script suppresses both:

if ! pvenode acme account info default >/dev/null 2>&1; then
    echo "y" | setsid pvenode acme account register default [email protected] \
        --directory https://acme-v02.api.letsencrypt.org/directory
fi
  • --directory pre-selects the Let's Encrypt production endpoint, skipping the endpoint menu.
  • echo "y" | setsid ... answers the ToS prompt. pvenode uses a readline that insists on reading from /dev/tty; setsid detaches the process from the terminal, forcing it to fall back to stdin — where the y is waiting. This is the standard workaround for scripting pvenode.
  • The account info guard makes the script safe to re-run: if a default account already exists, registration is skipped.
Testing first? Swap the --directory URL for https://acme-staging-v02.api.letsencrypt.org/directory to issue against Let's Encrypt staging (untrusted certs, generous rate limits). The snippet header includes this as a comment. Note the account is endpoint-specific — to move from staging to production later, deactivate and re-register: pvenode acme account deactivate default.

Step 1: Write the TSIG key file

The nsupdate plugin reads a BIND-style key file. The script writes it with your zone's key name and secret filled in:

cat > /etc/dynip-pve-lab-ddns-dynip-dev.tsig <<'EOF'
key "key-pve-lab.ddns.dynip.dev" {
    algorithm hmac-sha256;
    secret "YOUR_44_CHAR_BASE64_TSIG_SECRET";
};
EOF
chown nobody:nogroup /etc/dynip-pve-lab-ddns-dynip-dev.tsig
chmod 640 /etc/dynip-pve-lab-ddns-dynip-dev.tsig

Why /etc/ and not /etc/pve/

It's tempting to keep the key file in /etc/pve/ so it replicates across the cluster — don't. /etc/pve is the pmxcfs fuse mount, owned www-data:root, and its permission model can't be changed with chown/chmod. The ACME challenge runs as the nobody user, which cannot read files there, and the order fails with a permission error. The key file must live on the real filesystem — /etc/ works — owned nobody:nogroup, mode 0640. This is a well-documented community gotcha; the generated script gets it right by default.

Note the file also contains your zone's write credential — anyone who can read it can update your DNS records. Keep the 0640 mode.

Step 2: Register the ACME plugin

DATA_FILE=$(mktemp)
cat > "$DATA_FILE" <<EOF
NSUPDATE_KEY=/etc/dynip-pve-lab-ddns-dynip-dev.tsig
NSUPDATE_SERVER=update.dynip.dev
NSUPDATE_ZONE=pve-lab.ddns.dynip.dev
EOF

# Remove existing plugin if present (idempotent re-run — no-op on first setup).
pvenode acme plugin remove dynip-pve-lab-ddns-dynip-dev 2>/dev/null || true

pvenode acme plugin add dns dynip-pve-lab-ddns-dynip-dev \
    --api nsupdate \
    --data "$DATA_FILE" \
    --validation-delay 60

rm -f "$DATA_FILE"
  • --api nsupdate selects the RFC 2136 provider (Proxmox's ACME plugins are acme.sh's DNS APIs; this is dns_nsupdate). The three NSUPDATE_* variables are its full configuration.
  • The plugin name is derived from the zone (dots → dashes, dynip- prefix), so multiple DynIP zones on the same node get distinct plugins and never collide.
  • The plugin remove line makes re-runs idempotent: pvenode acme plugin add refuses to overwrite an existing plugin ID, so a previous run's config is dropped and re-created from the current values. On a first run there's nothing to remove and the || true swallows the error.
  • --validation-delay 60 is how long Proxmox waits between publishing the TXT record and asking Let's Encrypt to validate. DynIP serves the record immediately, but Let's Encrypt's resolvers occasionally take slightly over 30 seconds to see a fresh _acme-challenge name (a resolver-cache effect on names that have never existed before). 60 seconds is defensively safe without being wasteful.

Step 3: Attach the domain and order

pvenode config set --acmedomain0 pve-lab.ddns.dynip.dev,plugin=dynip-pve-lab-ddns-dynip-dev
pvenode acme cert order --force

Each node has a handful of acmedomain slots (acmedomain0 through acmedomain5). The script uses slot 0; if it's already taken on your node, check with pvenode config get and switch to a free slot. --force tells Proxmox to overwrite an existing certificate — without it, a re-run fails with "Custom certificate exists but 'force' is not set"; on a first run there's nothing to overwrite and the flag is a no-op. The order step prints a task log ending in:

Status is 'valid', domain 'pve-lab.ddns.dynip.dev' OK!
...
Setting pveproxy certificate and key
Restarting pveproxy
TASK OK

pveproxy restarts automatically with the new certificate. Your browser session on :8006 reconnects within a few seconds.

Verify the certificate

From another host:

openssl s_client -connect pve-lab.ddns.dynip.dev:8006 </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates
subject=CN = pve-lab.ddns.dynip.dev
issuer=C = US, O = Let's Encrypt, CN = R13

Or just open https://pve-lab.ddns.dynip.dev:8006 in a browser — the padlock should be clean, with a Let's Encrypt issuer. In the web UI, the cert also appears under Node → System → Certificates.

Renewals

Nothing to set up: pve-daily-update.timer (installed and enabled by default) checks certificate age daily and re-orders automatically when the cert is within 30 days of expiry, using the same plugin and TSIG key. Confirm the timer is active:

systemctl status pve-daily-update.timer

To force a renewal manually (for example after changing the plugin config):

pvenode acme cert renew --force

Every order and renewal shows up as a task in the web UI's task history (bottom panel, or Node → Task History) — look for ACME certificate order entries. That's also where you'll find the full log if a renewal ever fails.

Multi-node clusters

Certificates are per-node — each node serves its own web UI and needs its own cert for its own hostname. Run the setup independently on each node: create a zone per node (e.g. pve1.ddns.dynip.dev, pve2.ddns.dynip.dev), and paste each node's generated script on that node. Every node then has its own ACME account, its own TSIG key file, and its own renewal cycle. (ACME plugin definitions live in the cluster filesystem and replicate automatically, but the key file in /etc/ and the acmedomain config are node-local — another reason each node runs its own setup.)

If you'd rather have one certificate shared across nodes (a single hostname behind a VIP, say), that's not a node-local ACME job: generate a cert through the platform's SSL section instead and distribute the key/cert pair to each node manually.

PBS and PMG variants

Proxmox Backup Server and Proxmox Mail Gateway ship the same ACME client with a different CLI entry point. The flow is identical — substitute the command:

Product Command
Proxmox VEpvenode ...
Proxmox Backup Serverproxmox-backup-manager ...
Proxmox Mail Gatewaypmgnode ...

Everything else — the TSIG key file, the nsupdate plugin data, the --validation-delay, the DNSSEC precondition — carries over unchanged. (PBS serves its UI on :8007, PMG on :8006; adjust the verification port accordingly.)

Troubleshooting

Permission denied reading the TSIG key file

The challenge runs as nobody and can't read the key file. Usually the chown step didn't run, or the file was placed in /etc/pve/ (see Step 1 — the fuse mount's ownership can't be fixed). Re-do Step 1: put the file in /etc/, chown nobody:nogroup, chmod 640.

Challenge ends invalid once, then works on retry

Almost always a resolver-cache miss on the freshly created _acme-challenge name taking slightly longer than the validation delay. Wait a minute and re-run pvenode acme cert order. The generated script already sets --validation-delay 60 to make this rare; if you registered the plugin by hand with a lower delay, raise it: pvenode acme plugin set <name> --validation-delay 60.

NOTAUTH from update.dynip.dev

The TSIG signature was rejected. Verify the secret in the key file matches the dashboard exactly (44-character base64), the key name is key-<your-zone>, and the algorithm line says hmac-sha256 while the zone's Options show HMAC-SHA256. If the zone is on HMAC-MD5, switch it — the secret is unchanged.

Let's Encrypt reports SERVFAIL / "no valid A records" for the challenge name

Validating-resolver failure on an unsigned child of a signed parent. Verify directly: dig +dnssec @1.1.1.1 _acme-challenge.pve-lab.ddns.dynip.dev TXT — SERVFAIL means the DNSSEC chain is broken. Enable DNSSEC on the zone in the dashboard (see the DNSSEC precondition).

ACME plugin ID '<name>' already exists

A previous run of the snippet left a plugin config behind, and pvenode acme plugin add refuses to overwrite it. The current snippet removes any existing plugin before adding a new one, so re-runs are clean; if you're seeing this from an older copy of the snippet, re-copy it from the dashboard, or run pvenode acme plugin remove dynip-<zone-with-dashes> first.

Custom certificate exists but 'force' is not set

Proxmox refuses to overwrite an existing certificate by default, so pvenode acme cert order fails on a node that already has one. The current snippet passes --force to make this a no-op; if you're seeing it from an older copy of the snippet, re-copy from the dashboard, or run pvenode acme cert order --force directly.

Browser shows a certificate error when I browse to the Proxmox IP

The certificate is valid for the FQDN only. Browse to https://pve-lab.ddns.dynip.dev:8006, not https://<internal-ip>:8006 — when the URL doesn't match the certificate's Subject, the browser warns. Not a bug; that's expected TLS behavior. If you need IP access without warnings, keep using the FQDN internally — the simplest way is to point the zone itself at the internal IP with the Update Zone IP step (fully supported); split-horizon DNS or a hosts entry work too.

Account registration hangs waiting for input

The ToS prompt is reading from the terminal. That happens if the setsid was dropped from the command — without it, pvenode opens /dev/tty directly and ignores the piped echo "y". Use the script verbatim, or register interactively once (pvenode acme account register default [email protected]) and re-run the script; Step 0 will skip.

Optional: point the zone at your node's IP

Check the "Update Zone IP" box in the snippet generator and fill in the node's IP to append a Step 4 that sets the A record for your zone as part of the same script. It's a one-off update, not a scheduled job — run it once and the record stays until you change it. The box is off by default; the certificate flow works either way, neither depends on the other.

Internal IPs are fully supported. Most Proxmox deployments live on internal networks, and that's fine: publish the node's private address (e.g. 192.168.100.2) and the whole setup works together — https://pve-lab.ddns.dynip.dev:8006 resolves to your LAN address while the certificate is still a valid, publicly-trusted Let's Encrypt cert. That works because the DNS-01 challenge is validated purely through DNS TXT records; Let's Encrypt never connects to the address the zone points at.

The update uses the same /update endpoint as the generic curl snippet, but as a POST with a JSON body instead of a GET with query parameters — the TSIG key travels in the request body, keeping it out of URLs and proxy access logs. If you leave the IP field blank, the script contains a YOUR_IP placeholder to edit before running.

When the node's IP changes

curl -sf -X POST "https://api.dynip.dev/update" \
    -H "Content-Type: application/json" \
    -d '{"domain": "pve-lab.ddns.dynip.dev", "ip": "<new-ip>", "key": "<your-tsig-key>"}'

# Confirm:
dig pve-lab.ddns.dynip.dev +short

Re-run just the Step 4 curl (it's self-contained), or update the record from the dashboard. If your node is on a genuinely dynamic public IP that changes on its own, use a scheduled updater instead — any of the DDNS clients from the snippet generator (the docker-compose updater, ddclient, OpenWrt) keeps the record current automatically.

Removing the setup

To unwind everything the script configured (the issued certificate stays in place until it expires or you delete it under Node → System → Certificates):

# Detach the domain from ACME
pvenode config set --delete acmedomain0

# Remove the plugin
pvenode acme plugin remove dynip-pve-lab-ddns-dynip-dev

# Delete the TSIG key file
rm /etc/dynip-pve-lab-ddns-dynip-dev.tsig

# Optional: deactivate the LE account too
pvenode acme account deactivate default

If the zone's TSIG key may have been exposed, rotate it from the dashboard afterwards.

What's next