Ad
Security AWS

acme-dns 2.0.2 on AWS User Guide

| Product: acme-dns 2.0.2 on Ubuntu 24.04 on AWS

Overview

This guide covers launching and using the acme-dns 2.0.2 on Ubuntu 24.04 AMI from cloudimg on Amazon EC2. It is one version of the cloudimg identity, secrets and PKI listing on AWS Marketplace; you pick it as the software version when you subscribe and launch.

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 check that you control a domain. 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, but 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 cloudimg image makes it safe to run on a public address:

  • HTTPS from the first boot. nginx serves the API on port 443 with a certificate generated on your instance's first boot, naming the zone, the instance's DNS name and its IP addresses. acme-dns's own API listens on 127.0.0.1:8080 only. There is no port 80.
  • Registration is not open to the internet. New accounts can be created from the instance itself, or from another host that presents this instance's registration token (generated at first boot, root only, easy to rotate or switch off). Anyone else gets 403. Registration is also rate limited.
  • An account and a zone unique to your instance. First boot enrols one API account for you through acme-dns's own registration endpoint, proves the whole path (a TXT record written through the API is read back over DNS, and a wrong key is refused), and writes the details 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 that check sees the real client, never an address a client made up.
  • Configured, not guessed, names. The zone comes from your configuration (or a placeholder until you set it), the zone's address record 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 are blocked from the instance metadata service, and the AMI requires IMDSv2.
  • The official release, verified against its published checksum, a start guard that refuses to run upstream's example configuration, and 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, 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; 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;
  • 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;
  • 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, to delegate a subdomain to this server (Step 9). An Elastic IP is recommended, because the delegation points at the instance's address.

Step 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 and your Region, then Launch through EC2. Choose your instance type, key pair, subnet and a security group as above.

With the AWS CLI (replace the values in angle brackets), create the security group and launch:

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 it before first boot runs, for example #!/bin/sh followed by mkdir -p /etc/cloudimg && echo auth.<your-domain> > /etc/cloudimg/acme-dns-domain. Without it the instance starts with a placeholder zone that you replace in Step 9.

Step 2: Connect to your instance

Version SSH user
acme-dns 2.0.2 on Ubuntu 24.04 ubuntu
ssh -i <your-key.pem> ubuntu@<public-ip>

Step 3: Check the services

First boot generates the zone, the certificate, the registration token and your API account, then acme-dns and nginx start:

systemctl is-active acme-dns nginx
sudo test -f /var/lib/cloudimg/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 acme-dns's own API listens on loopback only. Nothing else listens on the network (chrony and the DHCP client are local):

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

Step 4: Read your account details

The credentials file is readable by root only. This prints it with the API key and the registration token hidden; sudo cat /root/acme-dns-credentials.txt shows them:

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://www.cloudimg.co.uk/guides/acme-dns-2-0-2-on-ubuntu-24-04-aws/
  • 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 (Step 9) it is a placeholder under .invalid, a top level domain reserved so that it can never resolve on the public internet: a fresh instance can never appear to work for a domain you do not own.
  • The values above come from one test instance; yours are different.

Step 5: Query the challenge zone

acme-dns answers authoritatively (the aa flag) for its zone. Ask 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.

Step 6: Write a TXT record through the API and read it back

The API has three endpoints: /health, /update (sets the TXT record of the calling account, authenticated with the X-Api-User and X-Api-Key headers) and /register (Step 8). Clients verify the instance's own certificate, /etc/nginx/tls/acme-dns.crt (the certificate is public; its key is readable by root only). Copy it 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 = cloudimg
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, so one name can 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 here is the one first boot wrote to prove the path.

The same request with a wrong key is refused:

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

Step 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), so it cannot be used to resolve or amplify traffic for other domains:

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

Step 8: Register more clients

Give every certificate client its own account, so each can only update 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. This is how clients that register themselves on first use (for example lego) enrol.

Anyone else gets 403. Each successful call returns a new username, password (the key; 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 (nginx replaces any X-Real-IP or X-Forwarded-For header a client sends), so it 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"
  ]
}

Keep each response: the key is shown only once and is stored only as a hash.

Step 9: Delegate a zone you own

This 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 the next command, so 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; their fulldomain becomes <subdomain>.auth.<your-domain>. Check the delegation from any host on the internet once the parent records have propagated, and make sure the security group allows UDP and TCP 53 from anywhere:

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

Step 10: Point a certificate client at the server

For each certificate name, add one CNAME in your real zone. It 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, using 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 through its acme-dns provider (it can register itself with the token from Step 8), and Traefik, Caddy and cert-manager have acme-dns support too. Every client must trust the instance's certificate (~/acme-dns-ca.crt, or check the acmedns.cert.sha256 fingerprint), or you can install your own certificate for the API host name in /etc/nginx/tls/acme-dns.crt and /etc/nginx/tls/acme-dns.key and run sudo systemctl reload nginx (acme-dns-set-domain replaces them with a new self-signed pair, so run it first).

Step 11: Operate and harden

Rotate or switch off the registration token. The old token stops working 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 (registration then works only from the instance itself) with sudo acme-dns-rotate-register-token --disable.

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

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: never upstream's example zone, never an unfilled template value, and never an API reachable from the network while it trusts the client address header:

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). Losing it means enrolling every client again and changing 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 (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; check the upstream releases and replace the binary (then sudo systemctl restart acme-dns) to upgrade.

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:

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

cloudimg provides the packaging, the first boot automation, the registration gate, the HTTPS front end and the hardening, with 24/7 support by email and live chat at support@cloudimg.co.uk.

All product names are trademarks of their respective owners. cloudimg 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.