Ad
Security Azure

acme-dns on Ubuntu 24.04 on Azure User Guide

| Product: acme-dns 2.0.2 on Ubuntu 24.04 LTS on Azure

Overview

This guide covers the deployment and configuration of acme-dns on Ubuntu 24.04 on Azure using cloudimg Azure Marketplace images. acme-dns is an open source, deliberately limited DNS server with a small RESTful HTTP API, created by Joona Hoikkala. It exists to do one job well: answer the DNS-01 challenges that certificate authorities such as Let's Encrypt use to verify that you control a domain.

The image installs acme-dns 2.0.2 from 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. It runs as a single statically linked binary with an embedded SQLite datastore, so there is no external database to operate. Unattended security upgrades are configured to keep the server patched on your running VM.

Why a separate DNS-01 server. DNS-01 is the only ACME challenge type that can issue wildcard certificates, and the only one that works for hosts with no public HTTP endpoint. The usual way to automate it is to hand an ACME client API credentials for your entire production DNS zone, which is a large amount of trust to place 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 then receives its own randomly generated account and its own private subdomain, and the API will only ever let that account update that one subdomain. A compromised client cannot touch the rest of your DNS.

Security by design — no default credentials. acme-dns has no administrator account and no default password at all; 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 very 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, enrols an API account for you, 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, so the appliance cannot be made to serve a configuration that is not its own.

What is included:

  • acme-dns 2.0.2 from the official upstream release, SHA-256 verified, run 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, because the certificate authority must be able to 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, documented in /root/acme-dns-credentials.txt (0600)

  • A start time configuration guard that fails closed on any published example value, and the systemd-resolved stub listener disabled so acme-dns owns port 53 cleanly

Prerequisites

  • Active Azure subscription, SSH public key, VNet + subnet in target region

  • Subscription to the acme-dns listing on Azure Marketplace

  • Network Security Group rules allowing TCP 22 (admin), both 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, in order to delegate a subdomain to this server

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

Step 1: Deploy from the Azure Portal

Search acme-dns in Marketplace, select the cloudimg 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, and TCP 22 for administration.

Step 2: Deploy from the Azure CLI

RG="acme-dns-prod"; LOCATION="eastus"; VM_NAME="acme-dns-01"
GALLERY_IMAGE_ID="/subscriptions/<sub-id>/resourceGroups/azure-cloudimg/providers/Microsoft.Compute/galleries/cloudimgGallery/images/acme-dns/versions/<version>"
SSH_KEY="$(cat ~/.ssh/id_rsa.pub)"
az group create --name "$RG" --location "$LOCATION"
az vm create \
  --resource-group "$RG" --name "$VM_NAME" \
  --image "$GALLERY_IMAGE_ID" \
  --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

Step 3: First boot

On first boot the image mints this VM's TLS keypair, renders its own configuration with a challenge zone unique to this instance, starts acme-dns, enrols an API account for you through the product's own registration endpoint, verifies that a TXT record written through the API is served over DNS, and writes /root/acme-dns-credentials.txt. This 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. The fulldomain is the name you will point a CNAME at.

Step 4: Confirm the service is running

acme-dns.service is active. ss confirms acme-dns owns port 53 on both UDP and TCP across all interfaces (shown as *:53), and serves the REST API on port 443. Note that the systemd-resolved stub listener has been disabled on this image 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 '

acme-dns.service reports active, and ss shows the acme-dns process listening on port 53 over both UDP and TCP on all interfaces and on port 443 for the HTTPS REST API

Step 5: Query this instance's challenge zone

Every VM generates its own challenge zone. Query it directly against the server. The SOA, NS and A records are answered authoritatively, which is what makes this server 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 per instance challenge zone is printed along with the server address, and dig returns the SOA, NS and A records for that zone answered directly by acme-dns on the routable address

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

Step 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, and requires the X-Api-User and X-Api-Key headers.

Because the API serves the per instance self signed certificate until you switch it to a publicly trusted one (Step 10), curl is used with -k here.

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 brand 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, which is 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, so that a single name can satisfy the base domain and wildcard challenges for the same certificate at the same time. Your newly written token appears alongside the previous one; 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 outright:

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

the health endpoint returns HTTP 200, a registration call returns a new account as JSON with username password fulldomain and subdomain, a 43 character token written through the authenticated update endpoint is echoed back and then returned by dig as a TXT record, and a request with the wrong key is rejected with HTTP 401

Step 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, and 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 them ever appears.

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

the credentials file is 0600 root root and the configuration and TLS private key are 0640 root acme-dns, the API certificate subject is this VM's own generated zone, the effective configuration shows the listen address database path and TLS settings with no upstream example values, and the start time guard reports that a per VM configuration is in effect

Step 8: Delegate a subdomain you control

This is the step that makes the server usable for real certificate issuance. Pick a subdomain of a domain you own, for example auth.example.com, and 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 just delegated to.

Then tell acme-dns to serve that zone instead of the generated placeholder. 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 and confirm the new zone answers:

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

Existing accounts keep working: their fulldomain simply moves to the new zone, so re-register your clients (Step 9) after changing the domain.

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

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

Step 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 ever needs, and it is a one time change 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

Now your ACME client updates only the acme-dns subdomain, and the certificate authority follows the CNAME to find the token. Most clients have native support. 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, or use any client that supports the acme-dns protocol, including lego and Traefik.

Step 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 by solving the challenge itself, since 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

Step 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. Once every client has an account, set disable_registration = true in the [api] section and restart. The endpoint then disappears entirely, while existing accounts continue to work.

  • Pin accounts to source addresses. The registration endpoint accepts an allowfrom list of CIDRs, and the account will then only accept updates 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 for certificate authorities to validate, but port 443 should be restricted to the hosts that run your ACME clients, and port 22 to your administration network.

  • 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. Check for new acme-dns releases at the upstream project and replace /usr/local/bin/acme-dns to upgrade.

  • Back up the datastore. Your accounts live in /var/lib/acme-dns/acme-dns.db. Take regular copies; losing it means re-enrolling every client and updating 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

Step 12: Support and Licensing

acme-dns is developed by Joona Hoikkala and distributed under the MIT License. This cloudimg image bundles the unmodified official upstream release binary. cloudimg 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.

cloudimg 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 cloudimg. Deploy from the Portal or the Azure CLI as shown above.

Need Help?

Email support@cloudimg.co.uk for deployment help, configuration questions, or licensing enquiries.