Deployment guide

acme-dns 2.0.2

Overview

acme-dns is an open source, deliberately limited DNS server with a small REST API, written 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 guide covers the acme-dns 2.0.2 on Ubuntu 24.04 AMI from SecureAnyCloud on Amazon EC2, one software version of the SecureAnyCloud identity, secrets and PKI listing on AWS Marketplace.

DNS-01 is the only ACME challenge that can issue wildcard certificates, and the only one that works for hosts with no public web server. Automating it usually means giving every certificate client an API key for your whole DNS zone. With acme-dns, you delegate one subdomain to this server and point a CNAME at it. Each client gets its own account that can update only its own TXT record, so a compromised client cannot touch the rest of your DNS.

Upstream acme-dns serves its API over plain HTTP and lets anyone on the internet create accounts. The SecureAnyCloud image makes it safe to run on a public address. You choose this version when you subscribe and launch.

What you get:

  • HTTPS from the first boot. nginx serves the API on port 443 with a certificate created on your instance's first boot. The certificate names the zone, the instance's DNS name and its IP addresses. The acme-dns API itself listens on 127.0.0.1:8080 only, and there is no port 80.
  • Registration closed to the internet. You can create new accounts from the instance itself, or from another host that presents this instance's registration token. The token is generated at first boot, readable by root only, and easy to rotate or switch off. Anyone else gets 403, and registration is rate limited.
  • An account and a zone unique to your instance. First boot enrols one API account for you through the acme-dns registration endpoint. It then proves the whole path: a TXT record written through the API is read back over DNS, and a wrong key is refused. The details go to /root/acme-dns-credentials.txt (root only). No account, key, datastore or configuration ships in the image.
  • Authoritative only, never an open resolver. acme-dns answers for its own zone and nothing else. A query for any other name gets no answer and no recursion.
  • The real client address. acme-dns can pin an account to source addresses (allowfrom). nginx overwrites X-Real-IP and X-Forwarded-For with the address of the connection, so the check always sees the real client, never an address the client made up.
  • Configured names, not guessed ones. The zone comes from your configuration, or from a temporary zone until you set yours. The zone's address record comes from the instance's EC2 metadata at first boot, and nginx answers only for the instance's own names.
  • Metadata service blocked. acme-dns, nginx and the first-boot service cannot reach the instance metadata service, and the AMI requires IMDSv2.
  • The official release, verified against its published checksum, with a start guard that refuses to run upstream's example configuration. The base is fully patched Ubuntu 24.04 LTS at build time, with unattended security upgrades switched on.

What is on the image: acme-dns 2.0.2 (the official release binary /usr/local/bin/acme-dns, with its licence in /usr/share/doc/acme-dns), nginx, dig, Ubuntu 24.04 LTS, cloud-init and the AWS Systems Manager agent.

Default user: ubuntu. Architecture: x86_64. Recommended instance type: m5.large (acme-dns is very light, so any size works).

Prerequisites

  • An AWS account subscribed to the listing, an EC2 key pair and a VPC subnet with a route to the internet.
  • A security group that allows TCP 22 (SSH) from your own networks only.
  • The same security group allowing UDP 53 and TCP 53 (DNS) from anywhere (0.0.0.0/0) once you use the server for real issuance. Certificate authorities check DNS-01 records from many addresses that they do not publish.
  • The same security group allowing TCP 443 (the API) from the hosts that run your certificate clients only.
  • A domain you control, where you can add NS and A records, so you can delegate a subdomain to this server (Step 9). An Elastic IP is recommended, because the delegation points at the instance's address.
1

Launch the instance

In the AWS Marketplace listing, choose Continue to Subscribe, then Continue to Configuration. Pick acme-dns 2.0.2 on Ubuntu 24.04 as the software version, select your Region, and choose Launch through EC2. Then choose your instance type, key pair, subnet and a security group as described above.

To launch with the AWS CLI instead, replace the values in angle brackets, create the security group and launch the instance:

aws ec2 create-security-group --region <region> --vpc-id <your-vpc> \
  --group-name acme-dns --description "acme-dns: SSH from admins, DNS from anywhere, API from ACME clients"
aws ec2 authorize-security-group-ingress --region <region> --group-id <sg-id> --ip-permissions \
  'IpProtocol=tcp,FromPort=22,ToPort=22,IpRanges=[{CidrIp=<your-admin-cidr>}]' \
  'IpProtocol=udp,FromPort=53,ToPort=53,IpRanges=[{CidrIp=0.0.0.0/0}]' \
  'IpProtocol=tcp,FromPort=53,ToPort=53,IpRanges=[{CidrIp=0.0.0.0/0}]' \
  'IpProtocol=tcp,FromPort=443,ToPort=443,IpRanges=[{CidrIp=<your-acme-client-cidr>}]'
aws ec2 run-instances --region <region> --image-id <ami-id-from-the-listing> \
  --instance-type m5.large --key-name <your-key-pair> \
  --security-group-ids <sg-id> --subnet-id <your-subnet> \
  --metadata-options HttpTokens=required,HttpEndpoint=enabled \
  --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=acme-dns}]'

To serve your own zone from the first boot, add user data that writes the zone before first boot runs, for example #!/bin/sh followed by mkdir -p /etc/sac && echo auth.<your-domain> > /etc/sac/acme-dns-domain. Without it, the instance starts with a temporary zone that you replace in Step 9.

2

Connect to your instance

Connect over SSH as the ubuntu user, the default user for acme-dns 2.0.2 on Ubuntu 24.04:

ssh -i <your-key.pem> ubuntu@<public-ip>
3

Check the services

First boot generates the zone, the certificate, the registration token and your API account, and then acme-dns and nginx start. Confirm that both services are active and that first boot finished:

systemctl is-active acme-dns nginx
sudo test -f /var/lib/sac/data/acme-dns-firstboot.done && echo FIRSTBOOT_DONE
active active FIRSTBOOT_DONE

acme-dns owns port 53 (UDP and TCP) on every interface, nginx serves the API on 443, and the acme-dns API listens on loopback only. Nothing else listens on the network, because chrony and the DHCP client are local. Check the listeners:

sudo ss -lntup | awk 'NR>1 {print $1, $5, $7}' | sed -E 's/pid=[0-9]+,fd=[0-9]+/.../g' | sort
tcp *:53 users:(("acme-dns",...)) tcp 0.0.0.0:22 users:(("sshd",...),("systemd",...)) tcp 0.0.0.0:443 users:(("nginx",...),("nginx",...),("nginx",...)) tcp 127.0.0.1:8080 users:(("acme-dns",...)) tcp [::]:22 users:(("sshd",...),("systemd",...)) tcp [::]:443 users:(("nginx",...),("nginx",...),("nginx",...)) udp *:53 users:(("acme-dns",...)) udp 127.0.0.1:323 users:(("chronyd",...)) udp 172.31.93.26%ens5:68 users:(("systemd-network",...)) udp [::1]:323 users:(("chronyd",...))

The systemd-resolved stub listener is switched off so that acme-dns can own port 53. The operating system still resolves names through the VPC resolver:

ls -l /etc/resolv.conf | awk '{print $9, $10, $11}' getent hosts github.com >/dev/null && echo "OS name resolution OK"
/etc/resolv.conf -> /run/systemd/resolve/resolv.conf OS name resolution OK
4

Read your account details

The credentials file is readable by root only. Print it with the API key and the registration token hidden. To show them, run sudo cat /root/acme-dns-credentials.txt instead:

sudo sed -E 's/^(acmedns.api.key=|acmedns.register.token=).*/\1<hidden>/' /root/acme-dns-credentials.txt
# acme-dns 2.0.2 - generated on first boot by acme-dns-firstboot.service. Unique to this instance; keep it secret.
# acme-dns has no administrator account: an account is one ACME client's enrolment (its own subdomain, nothing else). # The account below was enrolled for you on first boot. acmedns.api.url=https://ec2-52-90-91-208.compute-1.amazonaws.com/ acmedns.api.user=576f2597-872f-4e23-8ed2-1687a5219f79 acmedns.api.key=<hidden> acmedns.register.token=<hidden> acmedns.subdomain=732762ae-dbe5-4def-883a-7974b59a9141 acmedns.fulldomain=732762ae-dbe5-4def-883a-7974b59a9141.auth.82217966.acme-dns.invalid acmedns.domain=auth.82217966.acme-dns.invalid acmedns.public.ip=52.90.91.208 acmedns.private.ip=172.31.93.26 acmedns.ca.cert=/etc/nginx/tls/acme-dns.crt acmedns.cert.sha256=D8:B1:DD:C5:C4:CE:B3:05:00:9D:E7:4B:FE:CC:23:0D:C8:8D:BE:1E:3D:AE:05:6E:C8:A0:DB:F2:4F:08:67:3A # The API uses a self-signed certificate made for this instance: give clients /etc/nginx/tls/acme-dns.crt as the CA # (curl --cacert), or check the SHA-256 fingerprint above. # Register more clients: from this instance curl --cacert /etc/nginx/tls/acme-dns.crt -X POST https://127.0.0.1/register # or remotely with the header X-Registration-Token: <acmedns.register.token> # New registration token:
sudo acme-dns-rotate-register-token Remote registration off: ... --disable
# Serve your own delegated zone:
sudo acme-dns-set-domain auth.example.com
# Until you do, the zone auth.82217966.acme-dns.invalid is temporary: .invalid never resolves publicly.
# User guide: https://secureanycloud.com/stacks/acme-dns-2-0-2/
  • acmedns.api.user, acmedns.api.key and acmedns.subdomain are the account a certificate client uses. acmedns.fulldomain is the name you point a CNAME at.
  • acmedns.domain is the zone this server answers for. Until you set your own in Step 9, it is a temporary name under .invalid, a top-level domain reserved so that it can never resolve on the public internet. A fresh instance therefore never appears to work for a domain you do not own.
  • The values above come from one example instance. Yours are different.
5

Query the challenge zone

acme-dns answers authoritatively (the aa flag) for its zone. Query it on the instance's private address, which is what the internet reaches through the public one:

ZONE=$(sudo sed -n 's/^acmedns.domain=//p' /root/acme-dns-credentials.txt)
PRIV=$(sudo sed -n 's/^acmedns.private.ip=//p' /root/acme-dns-credentials.txt)
echo "zone $ZONE on $PRIV" dig @"$PRIV" "$ZONE" SOA +noall +answer +comments | grep -E 'flags|IN' dig @"$PRIV" "$ZONE" NS +short dig @"$PRIV" "$ZONE" A +short
zone auth.82217966.acme-dns.invalid on 172.31.93.26 ;; flags: qr aa rd; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1 auth.82217966.acme-dns.invalid. 3600 IN SOA auth.82217966.acme-dns.invalid. hostmaster.auth.82217966.acme-dns.invalid. 2026102002 28800 7200 604800 86400 auth.82217966.acme-dns.invalid. 52.90.91.208

The zone's A record is the instance's public IPv4 address, read from the EC2 metadata at first boot.

6

Write a TXT record through the API and read it back

The API has three endpoints: /health, /update and /register (Step 8). The update endpoint sets the TXT record of the calling account, authenticated with the X-Api-User and X-Api-Key headers.

Clients verify the instance's own certificate, /etc/nginx/tls/acme-dns.crt. The certificate is public, and its key is readable by root only. Copy the certificate to every host that runs a certificate client, as here into your home directory, and check that it names this instance:

sudo install -m 0644 /etc/nginx/tls/acme-dns.crt ~/acme-dns-ca.crt openssl x509 -in ~/acme-dns-ca.crt -noout -subject -ext subjectAltName curl -s --cacert ~/acme-dns-ca.crt -o /dev/null -w 'health over verified HTTPS: HTTP %{http_code}\n' https://127.0.0.1/health
subject=CN = auth.82217966.acme-dns.invalid, O = sac X509v3 Subject Alternative Name: DNS:auth.82217966.acme-dns.invalid, DNS:ec2-52-90-91-208.compute-1.amazonaws.com, IP Address:52.90.91.208, IP Address:172.31.93.26, IP Address:127.0.0.1, DNS:localhost health over verified HTTPS: HTTP 200

Now do what a certificate client does during a DNS-01 challenge. Write a 43-character token (the length of an ACME validation value) with your account, then read it back over DNS. The headers go to curl on standard input (-K -), so the key never appears on a command line:

C=/root/acme-dns-credentials.txt
U=$(sudo sed -n 's/^acmedns.api.user=//p' $C);
K=$(sudo sed -n 's/^acmedns.api.key=//p' $C)
SUB=$(sudo sed -n 's/^acmedns.subdomain=//p' $C);
FULL=$(sudo sed -n 's/^acmedns.fulldomain=//p' $C)
PRIV=$(sudo sed -n 's/^acmedns.private.ip=//p' $C)
TOKEN=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=')
echo "token (${#TOKEN} characters): $TOKEN" printf 'header = "X-Api-User: %s"\nheader = "X-Api-Key: %s"\n' "$U" "$K" | \
  curl -s --cacert ~/acme-dns-ca.crt -K - -X POST \
  -d "{\"subdomain\": \"$SUB\", \"txt\": \"$TOKEN\"}" "https://$PRIV/update"; echo
ANSWER=$(dig @"$PRIV" TXT "$FULL" +short); echo "$ANSWER" grep -qxF "\"$TOKEN\"" <<<"$ANSWER" && echo "OK: DNS serves the token just written"
token (43 characters): LmDonr_4bjU8827rdfEtm9s5KbhZRbrl4iqJn9U-mgA {"txt": "LmDonr_4bjU8827rdfEtm9s5KbhZRbrl4iqJn9U-mgA"} "mLSYditUTYyH-zIWUiKz_48KRkIWrknUea2Win-T3rs" "LmDonr_4bjU8827rdfEtm9s5KbhZRbrl4iqJn9U-mgA" OK: DNS serves the token just written

acme-dns keeps the two most recent values for each account and serves both. One name can therefore answer the challenges for example.com and *.example.com in the same order. The certificate authority only needs its own token to be among the answers. The other value in the output is the one first boot wrote to prove the path.

Now send the same request with a wrong key. The server refuses it:

C=/root/acme-dns-credentials.txt
U=$(sudo sed -n 's/^acmedns.api.user=//p' $C);
SUB=$(sudo sed -n 's/^acmedns.subdomain=//p' $C)
PRIV=$(sudo sed -n 's/^acmedns.private.ip=//p' $C) printf 'header = "X-Api-User: %s"\nheader = "X-Api-Key: %s"\n' "$U" "$(openssl rand -hex 20)" | \
  curl -s --cacert ~/acme-dns-ca.crt -K - -o /dev/null -w 'wrong key: HTTP %{http_code}\n' -X POST \
  -d "{\"subdomain\": \"$SUB\", \"txt\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}" "https://$PRIV/update"
wrong key: HTTP 401
7

Confirm it is not an open resolver

acme-dns answers only for its own zone. A query for any other name gets no answer, and the server does not offer recursion (no ra flag). It therefore cannot be used to resolve or amplify traffic for other domains. Query a name outside the zone:

PRIV=$(sudo sed -n 's/^acmedns.private.ip=//p' /root/acme-dns-credentials.txt) dig @"$PRIV" amazon.com A +noall +comments | grep -E 'status|flags|WARNING'
;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 63562 ;; flags: qr rd; QUERY: 1, ANSWER: 0, AUTHORITY: 0, ADDITIONAL: 1 ;; WARNING: recursion requested but not available ; EDNS: version: 0, flags:; udp: 512
8

Register more clients

Give every certificate client its own account, so that each can update only its own record. There are two ways to call /register:

  • From the instance itself (over SSH), with no token.
  • From another host, with this instance's registration token in the X-Registration-Token header. Clients that register themselves on first use, such as lego, enrol this way.

Anyone else gets 403. Each successful call returns a new username, password (the key, which is not printed below), subdomain and fulldomain:

C=/root/acme-dns-credentials.txt
PRIV=$(sudo sed -n 's/^acmedns.private.ip=//p' $C)
curl -s --cacert ~/acme-dns-ca.crt -o /dev/null -w 'from the network without the token: HTTP %{http_code}\n' \
  -X POST "https://$PRIV/register"
RT=$(sudo sed -n 's/^acmedns.register.token=//p' $C) printf 'header = "X-Registration-Token: %s"\n' "$RT" | \
  curl -s --cacert ~/acme-dns-ca.crt -K - -X POST "https://$PRIV/register" | jq '{username, fulldomain, allowfrom}'
from the network without the token: HTTP 403 { "username": "4c0f2183-4fec-40e9-8b0d-3d21ede7d5f9", "fulldomain": "eddc4dc3-5250-4ce0-baeb-823b77e59128.auth.82217966.acme-dns.invalid", "allowfrom": [] }

You can also pin an account to the networks its client runs in. Register with an allowfrom list, and acme-dns refuses that account's updates from anywhere else. The address it checks is the real address of the connection, because nginx replaces any X-Real-IP or X-Forwarded-For header a client sends. The address cannot be faked:

curl -s --cacert ~/acme-dns-ca.crt -X POST -d '{"allowfrom": ["192.0.2.0/24"]}' \
  https://127.0.0.1/register | jq '{username, subdomain, allowfrom}'
{ "username": "7c9d3571-8906-417b-8445-59ef1ea86032", "subdomain": "0e06cbcd-4431-4dd8-8ad1-96c35f942046", "allowfrom": [ "192.0.2.0/24" ] }

Save each response. The key is shown only once and is stored only as a hash.

9

Delegate a zone you own

Delegating a zone makes the server usable for real certificates. Pick a subdomain of a domain you control, for example auth.example.com, and add two records to the parent zone at your DNS provider:

Name Type Value

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

auth.example.com A the instance's public IPv4 address (an Elastic IP)

The A record is the glue that tells resolvers where the delegated name server is. Associate the Elastic IP before you run the next command, so that the zone's own A record and the certificate name the right address.

Then point the instance at the zone. acme-dns-set-domain rewrites the acme-dns configuration, makes a new certificate for the new names, pins nginx to them, updates the credentials file and restarts the service:

sudo acme-dns-set-domain auth.<your-domain>

Existing accounts keep their subdomain, and their fulldomain becomes <subdomain>.auth.<your-domain>. Make sure the security group allows UDP and TCP 53 from anywhere. Once the parent records have propagated, check the delegation from any host on the internet:

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

Point a certificate client at the server

For each certificate name, add one CNAME in your real zone. This is the only change your production DNS ever needs:

Name Type Value

_acme-challenge.www.example.com CNAME the account's fulldomain

The certificate authority follows the CNAME to acme-dns, where the client wrote the token. Most ACME clients support acme-dns directly. With acme.sh, use an account from Step 8 and the instance's 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>"
# trust the public CAs (for the ACME server) and this instance's certificate (for the acme-dns API)
cat /etc/ssl/certs/ca-certificates.crt ~/acme-dns-ca.crt > ~/ca-plus-acme-dns.crt acme.sh --issue --dns dns_acmedns --ca-bundle ~/ca-plus-acme-dns.crt -d www.example.com -d '*.example.com'

certbot works through the certbot-dns-acmedns or acme-dns-certbot hooks. lego works through its acme-dns provider and can register itself with the token from Step 8. Traefik, Caddy and cert-manager also support acme-dns.

Every client must trust the instance's certificate (~/acme-dns-ca.crt, or check the acmedns.cert.sha256 fingerprint). Alternatively, install your own certificate for the API host name in /etc/nginx/tls/acme-dns.crt and /etc/nginx/tls/acme-dns.key, then run sudo systemctl reload nginx. acme-dns-set-domain replaces these files with a new self-signed pair, so run it first.

11

Operate and harden

Rotate or switch off the registration token. Rotating the token invalidates the old one at once:

sudo acme-dns-rotate-register-token
acme-dns-rotate-register-token: new token recorded in /root/acme-dns-credentials.txt

Once every client is enrolled, you can switch remote registration off entirely with sudo acme-dns-rotate-register-token --disable. Registration then works only from the instance itself.

Metadata service. acme-dns, nginx and the first-boot service are blocked from the instance metadata service. Verify the block:

systemctl show -p IPAddressDeny acme-dns nginx acme-dns-firstboot
IPAddressDeny=169.254.169.254/32 168.63.129.16/32 fd00:ec2::254/128 IPAddressDeny=169.254.169.254/32 168.63.129.16/32 fd00:ec2::254/128 IPAddressDeny=169.254.169.254/32 168.63.129.16/32 fd00:ec2::254/128

Start guard. Before every start, acme-dns checks that its configuration is this instance's own. It refuses upstream's example zone, an unfilled template value, and an API that is reachable from the network while it trusts the client address header. Run the guard manually:

sudo /usr/local/sbin/acme-dns-config-guard
acme-dns-config-guard: OK - this instance's own configuration (zone auth.82217966.acme-dns.invalid) is in effect

Back up the accounts. They live in /var/lib/acme-dns/acme-dns.db (SQLite). If you lose this file, you must enrol every client again and change every CNAME:

sudo systemctl stop acme-dns
sudo install -d -m 0700 /var/backups/acme-dns
sudo cp /var/lib/acme-dns/acme-dns.db /var/backups/acme-dns/acme-dns-$(date +%F).db
sudo systemctl start acme-dns for i in $(seq 1 15); do curl -s --cacert ~/acme-dns-ca.crt -o /dev/null https://127.0.0.1/health && break; sleep 1; done
sudo ls /var/backups/acme-dns/; systemctl is-active acme-dns
acme-dns-2026-10-20.db active

Security group. Keep UDP and TCP 53 open to the internet, because certificate authorities validate from many addresses. Allow TCP 443 only from the hosts that run your certificate clients, and TCP 22 only from your administrators.

Updates. Unattended security upgrades stay on for Ubuntu and nginx. acme-dns is the official release binary in /usr/local/bin/acme-dns. To upgrade it, check the upstream releases, replace the binary, and run sudo systemctl restart acme-dns.

Support and licensing

acme-dns is developed by Joona Hoikkala and contributors and distributed under the MIT License (/usr/share/doc/acme-dns/LICENSE). The image ships the unmodified official release binary. Confirm the version and its source:

cat /usr/share/doc/acme-dns/VERSION
acme-dns 2.0.2 (official release github.com/acme-dns/acme-dns, MIT, archive sha256 ff7aa309fb916012fc08dc7bd992c329b3930705ecb04161e57a5d910e80e9f0)

SecureAnyCloud provides the packaging, the first-boot automation, the registration gate, the HTTPS front end and the hardening. Support is available 24/7 by email and live chat at sales@secureanycloud.com.

All product names are trademarks of their respective owners. SecureAnyCloud is not affiliated with or endorsed by the acme-dns project or its authors. Let's Encrypt is a trademark of the Internet Security Research Group.

Deploy on AWS

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

View on Marketplace

Need Help?

Our support team is available 24/7.

sales@secureanycloud.com

Deploy on AWS