Automatic Let’s Encrypt certificates on a hypervisor via ACME DNS-01
Proxmox VE has built-in ACME support for issuing Let’s Encrypt TLS certificates for its web UI. Using the DNS-01 challenge instead of the HTTP-01 challenge means the node doesn’t need to be publicly reachable on port 80/443 — only the DNS provider’s API needs to be reachable from the node, which makes it the right choice for hypervisors on a private/internal network.
How it works
- Proxmox places an ACME order with Let’s Encrypt for the configured domain.
- Let’s Encrypt responds with a DNS-01 challenge token.
- Proxmox calls the configured DNS plugin (
acme.shunder the hood) to create a_acme-challenge.<domain>TXT record via the provider’s API. - Let’s Encrypt validates the TXT record.
- The plugin removes the TXT record and Proxmox installs the issued certificate on
pveproxy. - Proxmox handles automatic renewal (~30 days before expiry) via a systemd timer — no manual action needed in normal operation.
DNS plugins live at /usr/share/proxmox-acme/dnsapi/ on the node — one shell script per supported
provider.
Prerequisites
- A domain pointed at your DNS provider (e.g.
myhost.<domain>). - API credentials from that DNS provider.
- An ACME account registered in Proxmox (one shared account covers every node in a cluster).
Steps
1. Register (or check) an ACME account
Only needed once per cluster:
pvenode acme account register default your@email.comCheck existing accounts:
pvenode acme account list
pvenode acme account info default2. Add the DNS plugin
Proxmox stores plugin configs in /etc/pve/priv/acme/plugins.cfg — cluster-wide, synced by
pmxcfs. The data field holding API credentials is stored base64url-encoded, and the
pvesh create /cluster/acme/plugins CLI command rejects --data values containing a literal
newline. Since most providers’ credential blocks are multi-line (KEY=value pairs joined by
\n), write the config file directly instead of going through the CLI:
perl -e '
use MIME::Base64 qw(encode_base64url);
my $raw = "PROVIDER_KEY1=<your-key1>\nPROVIDER_KEY2=<your-key2>";
my $enc = encode_base64url($raw);
$enc =~ s/\n//g;
my $cfg = "standalone: standalone\n\ndns: <plugin-name>\n\tapi <plugin-name>\n\tdata $enc\n";
open(my $fh, ">", "/etc/pve/priv/acme/plugins.cfg") or die $!;
print $fh $cfg;
close $fh;
'Run this on any one cluster node — the file is synced cluster-wide.
Each DNS plugin expects different environment-variable names for its credentials (e.g.
CLOUDFLARE_Token, HETZNER_Token, or a <PROVIDER>_PREFIX/<PROVIDER>_SECRET pair for
API-key/secret style providers). Check if a plugin exists for your provider and read its header
comments for the exact variable names:
ls /usr/share/proxmox-acme/dnsapi/ | grep <provider>Why Perl, not the API?
pvesh create /cluster/acme/pluginsrefuses input with embedded newlines, making a direct API call impractical for any multi-line credential block. Writing the already-base64url-encoded value straight into the config file sidesteps that CLI limitation entirely, andpmxcfsstill syncs it cluster-wide like any other write to/etc/pve/.
3. Configure the domain on each node
This is a per-node setting — repeat it on every node individually, even though the plugin config itself is cluster-wide:
pvenode config set --acmedomain0 <hostname>.<domain>,plugin=<plugin-name>Verify:
pvenode config get | grep acmedomain4. Order the certificate
pvenode acme cert orderThis places the ACME order, creates the DNS TXT record, waits for propagation (default 30 s),
validates, downloads the cert, installs it on pveproxy, and restarts pveproxy. Takes roughly
60–90 seconds end to end.
Verify
systemctl status pvenode-acme-update.timer # confirms the renewal timer is active
pvenode acme cert renew # force a manual renewal to test the full flowTroubleshooting
- “You didn’t specify an API prefix/secret yet” (or similar provider-specific message): the
datafield inplugins.cfgisn’t correctly base64url-encoded, so the raw content handed toproxmox-acmeis malformed — re-run the Perl one-liner from step 2, double-checking the variable names against the plugin’s own header comments. - “property contains a line feed”: you tried to pass a multi-line credential block through
pvesh --datadirectly — use the file-write workaround instead, not the API. - TXT record not found / validation fails: DNS propagation can be slower than the default
30-second wait. Increase the plugin’s validation delay:
pvesh set /cluster/acme/plugins/<plugin-name> --validation-delay 120