HDNS is a self-hosted web application for managing DNS records on Hetzner DNS. It keeps your A/AAAA records in sync with your current public IP address and handles the full lifecycle of Let's Encrypt TLS certificates using the DNS-01 challenge, with no extra open ports required.
Built on Hetzner's Cloud API v2, HDNS publishes ACME challenge TXT records directly through the Hetzner DNS API. Certificates are stored encrypted in the database and can be delivered to other services automatically via the accompanying Worker.
The quickest way to run HDNS is with Docker:
docker pull ghcr.io/valentin-kaiser/hdns:latest
docker run -p 443:443 -v ./data:/app/data ghcr.io/valentin-kaiser/hdns:latestTo build from source:
git clone https://github.com/valentin-kaiser/hdns.git
cd hdns
docker build --tag hdns .
docker run -p 443:443 -v ./data:/app/data hdnsThe repository uses a single multi-target Dockerfile. Build images independently with:
# Main app image
docker build --target hdns --tag hdns .
# Worker image
docker build --target hdns-worker --tag hdns-worker .| Variable | Description |
|---|---|
HDNS_LOG_LEVEL |
Log level: 0=debug, 1=info, 2=warn, 3=error, 4=fatal, 5=panic |
HDNS_WEB_PORT |
Port to bind the web server to |
HDNS_CERTIFICATE_PATH |
Path to the TLS certificate file |
HDNS_KEY_PATH |
Path to the TLS private key file |
HDNS_REFRESH_CRON |
Cron expression for the DDNS refresh job |
HDNS_DNS_SERVERS |
Comma-separated list of DNS servers used for propagation checks |
HDNS_IPV4_RESOLVERS |
Comma-separated resolvers to detect the current public IPv4. Supports http(s):// and dns://host/name?type=A URIs |
HDNS_IPV6_RESOLVERS |
Same format as HDNS_IPV4_RESOLVERS; default query type is AAAA |
HDNS_IPV4_RESOLVER_AGREEMENT_THRESHOLD |
Minimum IPv4 resolver agreement ratio required for trusted consensus (0 < value <= 1, default: 0.75) |
HDNS_IPV6_RESOLVER_AGREEMENT_THRESHOLD |
Minimum IPv6 resolver agreement ratio required for trusted consensus (0 < value <= 1, default: 0.75) |
HDNS_IPV4_RESOLVER_MIN_RESPONSES |
Minimum successful IPv4 resolver responses before consensus is considered trusted (default: 2) |
HDNS_IPV6_RESOLVER_MIN_RESPONSES |
Minimum successful IPv6 resolver responses before consensus is considered trusted (default: 2) |
HDNS_DATABASE |
MariaDB connection DSN |
HDNS_ACME_ENABLED |
Set to true to enable Let's Encrypt certificate issuance |
HDNS_ACME_EMAIL |
Contact address registered with the ACME CA |
HDNS_ACME_STAGING |
Set to true to use the Let's Encrypt staging environment (default: true) |
HDNS_ACME_RENEW_BEFORE_DAYS |
Days before expiry at which renewal is triggered (default: 30) |
HDNS_ACME_RENEW_CRON |
Cron expression for the renewal scan (default: 0 3 * * *) |
Place a hdns.yaml file in the data directory. All keys map directly to environment variables without the HDNS_ prefix.
loglevel: 1
webport: 443
certificatepath: "/path/to/cert.pem"
keypath: "/path/to/key.pem"
refreshcron: "0 * * * *"
dnsservers:
- hydrogen.ns.hetzner.com:53
- oxygen.ns.hetzner.com:53
- helium.ns.hetzner.de:53
- 9.9.9.9:53
- 1.1.1.1:53
- 8.8.8.8:53
ipv4resolvers:
- dns://resolver1.opendns.com/myip.opendns.com?type=A
- dns://ns1.google.com/o-o.myaddr.l.google.com?type=TXT
- dns://1.1.1.1/whoami.cloudflare?type=TXT&class=CH
- https://icanhazip.com/
ipv6resolvers:
- dns://resolver1.opendns.com/myip.opendns.com?type=AAAA
- dns://[2606:4700:4700::1111]/whoami.cloudflare?type=TXT&class=CH
- https://ipv6.icanhazip.com
ipv4resolveragreementthreshold: 0.75
ipv6resolveragreementthreshold: 0.75
ipv4resolverminresponses: 2
ipv6resolverminresponses: 2
database: "hdns:hdns@tcp(localhost:3306)/hdns?parseTime=true"
acme:
enabled: false
email: "you@example.com"
staging: true
renewbeforedays: 30
renewcron: "0 3 * * *"HDNS issues and renews TLS certificates from Let's Encrypt using the DNS-01 challenge via the Hetzner DNS API. No extra open ports or publicly reachable HTTP endpoints are needed.
- Configure a DNS record with purpose Certificate or Both (DDNS + certificate).
- HDNS creates a temporary
_acme-challengeTXT record in the Hetzner zone. - After the ACME CA verifies the challenge, the signed certificate and key are stored in the database (encrypted at rest) and written to disk.
- Certificate delivery to other services is handled by the Worker via webhook.
Issuance can be triggered manually from the UI or runs automatically on the renewal schedule.
- Set
acme.enabled: true. - Set
acme.emailto a valid address accepted by Let's Encrypt. - Use
acme.staging: truewhile testing to stay within Let's Encrypt rate limits. Switch tofalsefor production certificates. - The Hetzner API token for each record needs write access to the DNS zone.
Each DNS record can specify its own ACME account email under the record settings. When set, HDNS uses that address for all issuance requests for that record. Records without an explicit email fall back to the global acme.email value.
Account keys and registrations are stored separately per email address:
<data-dir>/acme/staging/<email>/account.key
<data-dir>/acme/staging/<email>/account.json
<data-dir>/acme/production/<email>/account.key
<data-dir>/acme/production/<email>/account.json
Each certificate issuance job records the full lego/ACME client log. The log is available in the certificate details view in the UI and is also written to <data-dir>/logs/acme.log. During manual issuance from the UI, log lines are streamed live.
The Worker is a lightweight webhook receiver that acts on certificate and IP-change events from the main server. Run it on the target host to deploy certificates automatically after issuance.
To run the Worker with Docker, use the following command:
docker pull ghcr.io/valentin-kaiser/hdns-worker:latest
docker run -p 8080:8080 -v ./worker-data:/app/data ghcr.io/valentin-kaiser/hdns-worker:latestTo build the Worker image locally from the combined Dockerfile:
docker build --target hdns-worker --tag hdns-worker .
docker run -p 8080:8080 -v ./worker-data:/app/data hdns-workerlog_level: 1
port: 8080
secret: "<shared-bearer-token>"
tasks:
- name: "deploy-nginx"
actions:
- type: cert_save
cert_dir: /etc/nginx/certs
cert_file: fullchain.pem
key_file: privkey.pem
- type: service_restart
service_name: nginxPOST /<task-name>
Authorization: Bearer <secret>
Content-Type: application/json
{
"cert": "<PEM leaf>",
"chain": "<PEM chain>",
"fullchain": "<PEM leaf+chain>",
"private_key": "<PEM private key>",
"certificate_format": "pem|pkcs12",
"pkcs12_base64": "<base64 PKCS#12, optional>"
}
HDNS sends the Bearer token automatically when delivering a certificate.
| Type | Description |
|---|---|
cert_save |
Writes certificate artifacts from the payload to cert_dir. Supports cert_file, chain_file, fullchain_file, key_file, and optional pkcs12_file. |
service_restart |
Restarts the named OS service via systemctl on Linux or sc stop/start on Windows. |
exec |
Runs an arbitrary shell command (sh -c on Linux, cmd /C on Windows). |
fortios_upload |
Uploads a PKCS#12 certificate to a FortiGate via REST API. |
fortios_profile_cert_replace |
Updates certificate references in FortiOS CMDB profile objects. |
fortios_admin_server_cert_update |
Updates the FortiOS admin server certificate. |
Actions inside a task run sequentially. The first failure stops the chain and returns HTTP 500.
