Deployment guide

acme-dns

Overview

acme-dns is an open source, deliberately limited DNS server with a small RESTful HTTP API, created by Joona Hoikkala. It does one job: it answers the DNS-01 challenges that certificate authorities such as Let's Encrypt use to verify that you control a domain.

This SecureAnyCloud image runs acme-dns 2.0.2 on Ubuntu 24.04 on Azure. It installs the official upstream release (github.com/acme-dns/acme-dns, the project formerly published at joohoi/acme-dns), verified against its published SHA-256 checksum. The server is a single statically linked binary with an embedded SQLite datastore, so there is no external database to operate. Unattended security upgrades keep the VM patched.

Why a separate DNS-01 server. DNS-01 is the only ACME challenge type that can issue wildcard certificates. It is also the only one that works for hosts with no public HTTP endpoint. The usual way to automate it is to give an ACME client API credentials for your entire production DNS zone, which places a great deal of trust in an automated process.

acme-dns removes that exposure. You delegate one dedicated subdomain to it and point a CNAME at that subdomain. Each enrolled client receives its own randomly generated account and its own private subdomain. The API only lets an account update its own subdomain, so a compromised client cannot touch the rest of your DNS.

No default credentials. acme-dns has no administrator account and no default password. An account is an ACME client enrolment created through the registration endpoint. This image therefore ships no configuration, no TLS key, no datastore and no account, only a configuration template.

On the first boot of every VM, a one-shot service mints a per-instance TLS keypair for the API, generates a challenge zone unique to that VM and enrols an API account for you. It proves the whole path end to end and writes the details to /root/acme-dns-credentials.txt (mode 0600, root only). A start-time guard runs before every service start and refuses to run if any of upstream's published example values is in effect.

What you get:

  • acme-dns 2.0.2 from the official upstream release, SHA-256 verified, running under systemd as the unprivileged acme-dns user (acme-dns.service) with only the capability needed to bind low ports
  • Authoritative DNS on port 53, UDP and TCP, on all interfaces, so the certificate authority can query it from the public internet
  • The REST API (/register, /update, /health) served over HTTPS on port 443 with the per-instance certificate minted on first boot
  • An embedded SQLite datastore at /var/lib/acme-dns/acme-dns.db, so no separate database instance is required
  • A per-instance challenge zone and API account generated on first boot and documented in /root/acme-dns-credentials.txt (0600)
  • A start-time configuration guard that fails closed on any published example value
  • The systemd-resolved stub listener disabled, so acme-dns owns port 53 cleanly

Prerequisites

You need:

  • An active Azure subscription, an SSH public key, and a VNet with a subnet in the target region
  • A subscription to the acme-dns listing on Azure Marketplace
  • Network security group (NSG) rules that allow TCP 22 for administration, UDP and TCP 53 (DNS) from the internet so the certificate authority can query your challenge records, and TCP 443 from the hosts that run your ACME clients
  • A domain you control, with the ability to add NS and A records at your registrar, so that you can delegate a subdomain to this server

Recommended VM size: Standard_B2s (2 vCPU, 4 GB RAM). acme-dns is very light. It answers a small number of TXT queries per certificate issuance, so this size comfortably serves a large estate.

1

Deploy from the Azure Portal

Search the Azure Marketplace for acme-dns, select the SecureAnyCloud publisher, and click Create.

Configure the network security group to allow:

  • UDP 53 and TCP 53 from any source. Certificate authorities validate from many changing addresses worldwide, so DNS must be publicly reachable.
  • TCP 443 from the hosts that will run your ACME clients.
  • TCP 22 for administration.
2

Deploy from the Azure CLI

RG="acme-dns-prod"; LOCATION="eastus"; VM_NAME="acme-dns-01" IMAGE_URN="sac1647283583153:acme-dns:default:latest" SSH_KEY="$(cat ~/.ssh/id_rsa.pub)" az group create --name "$RG" --location "$LOCATION"
# Accept the Marketplace terms once per subscription (required before the first CLI deploy)
az vm image terms accept --urn sac1647283583153:acme-dns:default:latest
az vm create \
  --resource-group "$RG" --name "$VM_NAME" \
  --image "$IMAGE_URN" \
  --size Standard_B2s \
  --admin-username azureuser \
  --ssh-key-values "$SSH_KEY" \
  --public-ip-sku Standard
az vm open-port --resource-group "$RG" --name "$VM_NAME" --port 53 --priority 1001
az vm open-port --resource-group "$RG" --name "$VM_NAME" --port 443 --priority 1002
az vm open-port --resource-group "$RG" --name "$VM_NAME" --port 22 --priority 1003
3

Read the first-boot credentials

On first boot the image mints this VM's TLS keypair and renders its own configuration with a challenge zone unique to this instance. It starts acme-dns and enrols an API account for you through the registration endpoint. It then verifies that a TXT record written through the API is served over DNS, and writes /root/acme-dns-credentials.txt.

First boot completes within a minute. SSH in as azureuser and read the details:

sudo cat /root/acme-dns-credentials.txt

The file records your account's username, key, subdomain and fulldomain. Point a CNAME at the fulldomain.

4

Confirm the service is running

Check that acme-dns.service is active. The ss output should show acme-dns on port 53, both UDP and TCP, across all interfaces (shown as *:53), and serving the REST API on port 443.

This image disables the systemd-resolved stub listener so that acme-dns can own port 53. The operating system still resolves names normally through the upstream resolver list.

systemctl is-active acme-dns.service ss -tlnup | grep -E ':53 |:443 '
5

Query this instance's challenge zone

Every VM generates its own challenge zone. Query it directly against the server. The server answers the SOA, NS and A records authoritatively, which makes it usable as a delegation target.

ACME_DOMAIN=$(sudo grep '^acmedns.domain=' /root/acme-dns-credentials.txt | cut -d= -f2-)
ACME_IP=$(sudo grep '^acmedns.public.ip=' /root/acme-dns-credentials.txt | cut -d= -f2-)
echo "zone: $ACME_DOMAIN server: $ACME_IP" dig @$ACME_IP "$ACME_DOMAIN" SOA +short dig @$ACME_IP "$ACME_DOMAIN" NS +short dig @$ACME_IP "$ACME_DOMAIN" A +short

The zone name ends in .acme-dns.invalid. This is deliberate. .invalid is a reserved top-level domain that never resolves on the public internet, so a new VM cannot appear to work against a domain you do not own. Step 8 replaces it with a subdomain you control.

6

Use the REST API

The API has three endpoints:

  • /health is an unauthenticated liveness check.
  • /register creates a new account and returns its credentials.
  • /update sets the TXT record for the calling account's subdomain. It requires the X-Api-User and X-Api-Key headers.

The API serves the per-instance self-signed certificate until you switch to a publicly trusted one (Step 10). Until then, the examples use curl with -k.

echo -n 'health: HTTP '; curl -sk -o /dev/null -w '%{http_code}\n' https://127.0.0.1/health curl -sk -X POST https://127.0.0.1/register | jq .

Each call to /register returns a new account with its own username, password and private subdomain.

Now use the account created for you on first boot to write a TXT record and read it straight back over DNS. The txt value must be exactly 43 characters, the length of the validation token a certificate authority issues.

API_USER=$(sudo grep '^acmedns.api.user=' /root/acme-dns-credentials.txt | cut -d= -f2-)
API_KEY=$(sudo grep '^acmedns.api.key=' /root/acme-dns-credentials.txt | cut -d= -f2-)
SUBDOMAIN=$(sudo grep '^acmedns.subdomain=' /root/acme-dns-credentials.txt | cut -d= -f2-)
FULLDOMAIN=$(sudo grep '^acmedns.fulldomain=' /root/acme-dns-credentials.txt | cut -d= -f2-)
ACME_IP=$(sudo grep '^acmedns.public.ip=' /root/acme-dns-credentials.txt | cut -d= -f2-)
TOKEN=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=')
echo "token (${#TOKEN} chars): $TOKEN" curl -sk -X POST -H "X-Api-User: $API_USER" -H "X-Api-Key: $API_KEY" \
  -d "{\"subdomain\": \"$SUBDOMAIN\", \"txt\": \"$TOKEN\"}" https://$ACME_IP/update
echo dig @$ACME_IP TXT "$FULLDOMAIN" +short

acme-dns keeps two TXT values per subdomain and serves both. This lets a single name satisfy the base-domain and wildcard challenges for the same certificate at the same time. Your new token appears alongside the previous one, and the certificate authority accepts the answer as long as the token it is looking for is present.

A request with the wrong key is rejected:

API_USER=$(sudo grep '^acmedns.api.user=' /root/acme-dns-credentials.txt | cut -d= -f2-)
SUBDOMAIN=$(sudo grep '^acmedns.subdomain=' /root/acme-dns-credentials.txt | cut -d= -f2-)
ACME_IP=$(sudo grep '^acmedns.public.ip=' /root/acme-dns-credentials.txt | cut -d= -f2-)
echo -n 'wrong key: HTTP '; curl -sk -o /dev/null -w '%{http_code}\n' -X POST \
  -H "X-Api-User: $API_USER" -H 'X-Api-Key: definitely-wrong-key' \
  -d "{\"subdomain\": \"$SUBDOMAIN\", \"txt\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}" \
  https://$ACME_IP/update
7

Review the secure-by-default configuration

No default, shared or example credentials ship in the image. The credentials file is 0600 root:root. The API certificate was generated on this VM and carries this VM's own zone as its subject. The effective configuration contains none of upstream's published example values.

The start-time guard runs before every service start and fails closed if any of those values ever appears. Run the commands below to confirm each point.

sudo stat -c '%n %a %U:%G' /root/acme-dns-credentials.txt /etc/acme-dns/config.cfg /etc/acme-dns/tls/privkey.pem
sudo openssl x509 -in /etc/acme-dns/tls/fullchain.pem -noout -subject -dates sudo grep -vE '^\s*#|^\s*$' /etc/acme-dns/config.cfg | grep -E 'listen|domain|tls|connection'
sudo /usr/local/sbin/acme-dns-config-guard.sh
8

Delegate a subdomain you control

This step makes the server usable for real certificate issuance. Pick a subdomain of a domain you own, for example auth.example.com. Delegate it to this VM at your registrar or DNS provider by adding two records to the parent zone:

Record Type Value

auth.example.com NS auth.example.com.

auth.example.com A the public IP of this VM

The A record is the glue record. It tells the internet where to find the name server you delegated to.

Next, tell acme-dns to serve that zone instead of the zone generated on first boot. Edit /etc/acme-dns/config.cfg and update the domain, nsname, nsadmin and records settings:

[general] listen = "0.0.0.0:53" protocol = "both" domain = "auth.example.com" nsname = "auth.example.com" nsadmin = "admin.example.com" records = [ "auth.example.com. A 203.0.113.10", "auth.example.com. NS auth.example.com.", ]

Replace 203.0.113.10 with this VM's public IP. Then restart the service and confirm that the new zone answers:

sudo systemctl restart acme-dns.service
sudo systemctl is-active acme-dns.service

Existing accounts keep working, but their fulldomain moves to the new zone. Re-register your clients (Step 9) after you change the domain.

Finally, confirm from an external client that the delegation has propagated:

dig auth.<your-domain> NS +short dig @auth.<your-domain> auth.<your-domain> SOA +short
9

Point an ACME client at this server

Register an account for the client, then add one CNAME to your real zone. This is the only change your production DNS needs, and you make it once per certificate name.

curl -sk -X POST https://<your-domain>/register | jq .

Take the fulldomain from the response and create this record in your production zone:

Record Type Value

_acme-challenge.www.example.com CNAME the fulldomain returned by /register

Your ACME client now updates only the acme-dns subdomain, and the certificate authority follows the CNAME to find the token. Most clients support this natively. With acme.sh, export the account details and request the certificate:

export ACMEDNS_BASE_URL="https://auth.<your-domain>"
export ACMEDNS_USERNAME="<username-from-register>"
export ACMEDNS_PASSWORD="<password-from-register>"
export ACMEDNS_SUBDOMAIN="<subdomain-from-register>" acme.sh --issue --dns dns_acmedns -d www.example.com -d '*.example.com'

With certbot, install the certbot-dns-acmedns plugin. You can also use any client that supports the acme-dns protocol, including lego and Traefik.

10

Give the API a publicly trusted certificate

Once a real domain is delegated (Step 8), acme-dns can obtain a certificate for its own API. It solves the challenge itself, because it is its own DNS-01 provider. Change the [api] section of /etc/acme-dns/config.cfg:

[api] ip = "0.0.0.0" port = "443" tls = "letsencrypt" notification_email = "admin@example.com" acme_cache_dir = "/var/lib/acme-dns/api-certs"

Restart the service. Your clients can then drop the -k (insecure) flag:

sudo systemctl restart acme-dns.service
sudo systemctl is-active acme-dns.service
11

Harden the deployment

Close registration once your clients are enrolled. Registration is open by default because it is how clients enrol, and each account is confined to its own subdomain. When every client has an account, set disable_registration = true in the [api] section and restart. The endpoint then disappears, and existing accounts continue to work.

Pin accounts to source addresses. The registration endpoint accepts an allowfrom list of CIDRs. The account then accepts updates only from those addresses. Register with a body such as {"allowfrom": ["203.0.113.0/24"]}.

Scope the network security group. Port 53 must stay open to the internet so that certificate authorities can validate. Restrict port 443 to the hosts that run your ACME clients, and port 22 to your administration network.

Remember this is not a general-purpose DNS server. It answers only for its own challenge zone and performs no recursion, so it cannot be abused as an open resolver.

Keep the OS and acme-dns patched. Unattended security upgrades remain enabled on the running VM. To upgrade acme-dns, check the upstream project for new releases and replace /usr/local/bin/acme-dns.

Back up the datastore. Your accounts live in /var/lib/acme-dns/acme-dns.db. Take regular copies. If you lose the file, you must re-enrol every client and update every CNAME.

sudo systemctl stop acme-dns.service
sudo cp /var/lib/acme-dns/acme-dns.db /var/backups/acme-dns.db.$(date +%F)
sudo systemctl start acme-dns.service
sudo ls -l /var/backups/ | grep acme-dns
12

Support and Licensing

acme-dns is developed by Joona Hoikkala and distributed under the MIT License. This SecureAnyCloud image bundles the unmodified official upstream release binary.

SecureAnyCloud provides the packaging, the per-instance TLS, challenge zone and account automation, the start-time configuration guard, and 24/7 support with a guaranteed 24-hour response SLA.

SecureAnyCloud is not affiliated with or endorsed by the acme-dns project or its authors. acme-dns is used here only to identify the software. Let's Encrypt is a trademark of the Internet Security Research Group.

Deploy on Azure

Find acme-dns on Ubuntu 24.04 LTS on the Azure Marketplace, published by SecureAnyCloud. Deploy from the Portal or the Azure CLI as shown above.

Need Help?

Email sales@secureanycloud.com for deployment help, configuration questions, or licensing enquiries.

Deploy on Azure

Launch acme-dns 2.0.2 on Ubuntu 24.04 LTS on Azure with 24/7 support from SecureAnyCloud.

View this image

Need Help?

Our support team is available 24/7.

sales@secureanycloud.com

Deploy on Azure