Self-Hosting acme-dns on a Hetzner VPS with Docker

Self-Hosting acme-dns on a Hetzner VPS with Docker

If you've ever tried to issue a wildcard TLS certificate — the kind that covers *.yourdomain.com — you've run into a hard requirement: the DNS-01 ACME challenge. And if you want to automate that challenge without handing your entire DNS zone over to a script, acme-dns is the cleanest solution I've found.

This post covers what acme-dns is, why it exists, and a full walkthrough of how I set it up on a Hetzner VPS using Docker — including the twist of running the REST API on port 8181 instead of 443 because Traefik already owned that port.


The problem: wildcard certs require DNS-01

Let's Encrypt issues certificates using the ACME protocol. There are a few challenge types, but for wildcard certificates (*.yourdomain.com), only DNS-01 works. The way it works: Let's Encrypt asks you to prove domain ownership by creating a specific _acme-challenge TXT record in your DNS zone.

That's straightforward for a one-time cert. But for automated renewal, your ACME client needs write access to your DNS zone — indefinitely. Most DNS providers give you an API token for this, which is convenient but concerning: if that token leaks, an attacker can rewrite your entire DNS zone.


The solution: acme-dns

acme-dns by Joona Hoikkala solves this with a clever indirection. Instead of giving your ACME client access to your real DNS zone, you:

  1. Stand up a tiny, purpose-built DNS server (acme-dns) on a subdomain — say, acmedns.yourdomain.com.
  2. Delegate that subdomain to acme-dns via an NS record, making it authoritative for anything under that subdomain.
  3. Add a one-time CNAME in your real DNS: _acme-challenge.yourdomain.com → <uuid>.acmedns.yourdomain.com.
  4. Your ACME client only ever calls the acme-dns REST API to update _acme-challenge TXT records on that narrow subdomain.

From that point on, your ACME client never touches your real DNS zone again. It has credentials for acme-dns only, and acme-dns can only manage records within its own delegated subdomain. The blast radius of a leaked credential is essentially zero.


Requirements

Before you start, you'll need:

  • A VPS with a public IP. I used Hetzner Cloud (CX22 — 2 vCPU, 4 GB RAM, more than enough). Any provider works: DigitalOcean, Vultr, Linode, etc.
  • A domain with access to your DNS provider's control panel or API.
  • Docker and Docker Compose installed on the VPS.
  • Ports open on your server firewall: 53/tcp, 53/udp, and whichever port you use for the REST API (443 or a custom port like 8181).
  • A subdomain to dedicate to acme-dns (e.g., acmedns.yourdomain.com).

Step 1: DNS records at your provider

Log into your DNS provider and create two records for your acme-dns subdomain.

General (any provider)

Type Name Value TTL
A acmedns.yourdomain.com YOUR_VPS_PUBLIC_IP 300
NS acmedns.yourdomain.com acmedns.yourdomain.com 300

The A record points the subdomain at your VPS. The NS record delegates authority for that subdomain to itself — meaning your acme-dns server becomes the authoritative nameserver for *.acmedns.yourdomain.com.

Cloudflare-specific notes

If you use Cloudflare, there are two things to watch out for:

Set the A record to DNS only (grey cloud). Cloudflare's proxy (orange cloud) only supports HTTP/HTTPS traffic on a limited set of ports. DNS traffic on port 53 cannot be proxied, and attempting to do so will break resolution. Click the orange cloud to make it grey.

NS records at the subdomain level work fine in Cloudflare — you don't need to do anything special beyond adding the record as shown above.


Step 2: Server setup

SSH into your VPS.

Install Docker (Ubuntu)

Docker provides an official APT repository for Ubuntu. This is the recommended installation method and makes future updates easier to manage.

For the latest installation instructions, refer to the official Docker documentation:

Docker Engine installation guide for Ubuntu

Install Docker using the official repository:

Remove any old Docker packages

for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do
  sudo apt-get remove -y $pkg
done

Update package index and install prerequisites

sudo apt-get update
sudo apt-get install -y ca-certificates curl

Add Docker's official GPG key

sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
  -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

Add the Docker repository

echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
  https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

Install Docker Engine and Docker Compose

sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io \
  docker-buildx-plugin docker-compose-plugin

Verify the installation:

sudo docker run hello-world

If you'd like to run Docker without sudo, add your user to the Docker group and log out and back in:

sudo usermod -aG docker $USER

Open the required ports. On Hetzner, do this both in the Hetzner Cloud Console (Firewalls section) and on the server itself:

# UFW
ufw allow 53/tcp
ufw allow 53/udp
ufw allow 8181/tcp   # or 443 if that port is free
ufw reload

Note for Hetzner users: Hetzner has both a network-level firewall (in the console) and the OS-level firewall (UFW). You need to open ports in both places.


Step 3: Project files

Clone or create the following project structure on your server:

acme-dns/
├── Dockerfile
├── docker-compose.yml
├── config/
│   └── config.cfg
└── data/              ← mounted volume; this is where the SQLite database will live

Dockerfile

This builds acme-dns from source using a two-stage Go build, then packages the binary into a minimal Alpine image:

FROM golang:alpine AS builder
LABEL maintainer="joona@kuori.org"

RUN apk add --update git

ENV GOPATH /tmp/buildcache
RUN git clone https://github.com/joohoi/acme-dns /tmp/acme-dns
WORKDIR /tmp/acme-dns
RUN CGO_ENABLED=0 go build

FROM alpine:latest

WORKDIR /root/
COPY --from=builder /tmp/acme-dns .
RUN mkdir -p /etc/acme-dns
RUN mkdir -p /var/lib/acme-dns
RUN rm -rf ./config.cfg
RUN apk --no-cache add ca-certificates && update-ca-certificates

VOLUME ["/etc/acme-dns", "/var/lib/acme-dns"]
ENTRYPOINT ["./acme-dns"]
EXPOSE 53 8181
EXPOSE 53/udp

docker-compose.yml

Notice that 443 is commented out — on my setup, Traefik already owns that port, so I use 8181 for the acme-dns REST API instead:

services:
  acmedns:
    build:
      context: .
      dockerfile: Dockerfile
    image: yourusername/acme-dns:latest
    ports:
      # - "443:443"   # Uncomment if 443 is free; otherwise use 8181
      - "53:53"
      - "53:53/udp"
      - "8181:8181"
      # - "80:80"
    volumes:
      - ./config:/etc/acme-dns:ro
      - ./data:/var/lib/acme-dns
    restart: unless-stopped

config/config.cfg

This is the main configuration file. Replace the placeholders with your own values:

[general]
listen = "0.0.0.0:53"
protocol = "both"
domain = "acmedns.yourdomain.com"
nsname = "acmedns.yourdomain.com"
nsadmin = "admin.yourdomain.com"   # your email, @ replaced with .
records = [
    "acmedns.yourdomain.com. A YOUR_SERVER_PUBLIC_IP",
    "acmedns.yourdomain.com. NS acmedns.yourdomain.com.",
]
debug = false

[database]
engine = "sqlite"
connection = "/var/lib/acme-dns/acme-dns.db"

[api]
ip = "0.0.0.0"
disable_registration = false
# Using 8181 instead of 443 — Traefik already uses 443 on this host
port = "8181"
tls = "none"
corsorigins = ["*"]
use_header = false
header_name = "X-Forwarded-For"

[logconfig]
loglevel = "info"
logtype = "stdout"
logformat = "json"

The connection path is critical — get this right before you register anything.

The default value in many acme-dns examples and the upstream source is "acme-dns.db" — a relative path. When acme-dns runs inside Docker, that relative path resolves to a location inside the container that is not in your mounted volume. The result: everything appears to work perfectly on first setup, but the moment the container is recreated (after a host reboot, a docker compose pull, or a stack update), the entire SQLite database is silently wiped. Every registered domain credential is gone, and every service relying on acme-dns will fail its next certificate renewal with a 403 forbidden error.

Using the absolute path /var/lib/acme-dns/acme-dns.db ensures the database lands inside the ./data volume mount and survives container restarts and recreations. After starting the container for the first time, confirm the file exists on your host:

ls -la ./data/
# You should see acme-dns.db appear here

If the directory is empty, the path is wrong and you're storing data in the container layer only.

A few other things to note:

  • tls = "none" — since acme-dns is on a separate subdomain that only your ACME client calls, and the API call only updates TXT records (no sensitive data), this is acceptable. If you want TLS, set tls = "letsencrypt" or point to your own cert files.
  • disable_registration = false — leave this open until you've registered all your clients. Then set it to true to prevent unauthorized registrations.
  • Port 8181 — if 443 is free on your server, you can use that instead (change both here and in docker-compose.yml).

Step 4: Build and run

cd acme-dns
docker compose up -d --build

Check that it's running:

docker compose logs -f

You should see acme-dns start up and begin listening on port 53.

Verify DNS is working from your local machine:

dig acmedns.yourdomain.com A
dig acmedns.yourdomain.com NS

Both should return answers pointing to your VPS IP.

Verify the database is persisting to disk:

ls -la ./data/

You should see acme-dns.db present. If the directory is empty, stop here and fix the connection path in config.cfg before proceeding — registering clients against an unpersisted database means you'll have to do it all again after the next container restart.


Step 5: Register a client

With the server running, register a client to get credentials:

curl -s -X POST http://YOUR_VPS_IP:8181/register | jq .

You'll get back something like:

{
  "username": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
  "password": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "fulldomain": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.acmedns.yourdomain.com",
  "subdomain": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
  "allowfrom": []
}

Save this output. These are the credentials your ACME client will use. If the database isn't persisted correctly and the container restarts, these credentials will be gone and you'll need to re-register.

Now, go back to your DNS provider and add a CNAME record:

Type Name Value
CNAME _acme-challenge.yourdomain.com <fulldomain from above>

For a wildcard cert that also covers the root, you may need a second CNAME:

Type Name Value
CNAME _acme-challenge.yourdomain.com <fulldomain>

This is the only change you ever make to your main DNS zone. Everything after this is handled automatically.


Step 6: Configure your ACME client

Certbot

Install the acme-dns authenticator plugin:

pip install certbot-dns-acmedns

Create a credentials file (e.g., /etc/letsencrypt/acmedns.json, chmod 600):

{
  "yourdomain.com": {
    "username": "...",
    "password": "...",
    "fulldomain": "...",
    "subdomain": "..."
  }
}

Set the ACMEDNS_URL environment variable and request a cert:

export ACMEDNS_URL=http://YOUR_VPS_IP:8181
certbot certonly \
  --authenticator dns-acmedns \
  --dns-acmedns-credentials /etc/letsencrypt/acmedns.json \
  -d yourdomain.com \
  -d "*.yourdomain.com"

Traefik

In your Traefik static config (traefik.yml):

certificatesResolvers:
  myresolver:
    acme:
      email: you@yourdomain.com
      storage: /acme.json
      dnsChallenge:
        provider: acme-dns

Set these environment variables (in your Traefik container or compose file):

ACME_DNS_API_BASE=http://YOUR_VPS_IP:8181
ACME_DNS_STORAGE_PATH=/path/to/acmedns-store.json

acme.sh

export ACMEDNS_BASE_URL="http://YOUR_VPS_IP:8181"
acme.sh --issue \
  --dns dns_acmedns \
  -d yourdomain.com \
  -d "*.yourdomain.com"

cert-manager (Kubernetes)

Use the cert-manager-webhook-acme-dns or the built-in acmedns issuer type. Point it at your server IP and port, and supply the credentials from the registration step.


Handling the port conflict with Traefik

If you run Traefik (or any other reverse proxy) on the same host, port 443 is already taken. The simplest fix — which is what I did — is to run acme-dns's REST API on a different port (8181) and adjust the port setting in config.cfg and the port mapping in docker-compose.yml.

Your ACME clients can usually be pointed at a non-standard port without issues. For example, with Certbot:

export ACMEDNS_URL=http://YOUR_VPS_IP:8181

And with Traefik:

ACME_DNS_API_BASE=http://YOUR_VPS_IP:8181

Important: lock down the API port. Using a non-standard port does not provide security by obscurity — port scanners will find 8181 just as easily as 443. You should restrict access to this port to only the hosts that need it. A few ways to do this:

  • Firewall rules (recommended): On Hetzner, add an inbound rule in the Cloud Console Firewall that allows TCP on port 8181 only from your ACME client's IP address(es). Do the same at the OS level with UFW:

    ufw allow from YOUR_ACME_CLIENT_IP to any port 8181 proto tcp
    

    Replace the open rule (ufw allow 8181/tcp) you may have added earlier.

  • allowfrom in acme-dns: When registering a client, pass its IP in the allowfrom field. acme-dns will reject update requests from any other IP for that credential:

    curl -s -X POST http://YOUR_VPS_IP:8181/register \
      -H "Content-Type: application/json" \
      -d '{"allowfrom": ["YOUR_ACME_CLIENT_IP/32"]}' | jq .
    

    This is a good second layer, but it only protects the /update endpoint — the /register endpoint is still open unless you also set disable_registration = true after initial setup.

  • Private networking: If your cloud provider supports private networks (Hetzner does), you can bind the acme-dns API to a private interface instead of 0.0.0.0, so the port is never reachable from the public internet at all. Set ip = "10.0.0.x" (your private IP) in config.cfg.

The safest combination is firewall rules + disable_registration = true once you've registered all your clients. The allowfrom restriction adds a useful extra layer but shouldn't be your only control.

Alternatively, if you want to keep the standard port, you could:

  • Put acme-dns on a separate server entirely.
  • Route acmedns.yourdomain.com:443 through Traefik as a reverse proxy to the acme-dns container on an internal port. (This works but adds complexity — Traefik proxying the tool that issues Traefik's own certs requires some care to avoid a chicken-and-egg problem on first boot.)

For simplicity, the custom port approach with proper firewall rules is the most straightforward.


Once you've registered all your ACME clients, lock down the registration endpoint:

[api]
disable_registration = true

You can also restrict which IP addresses a given credential can update records from, by specifying allowfrom during registration:

curl -s -X POST http://YOUR_VPS_IP:8181/register \
  -H "Content-Type: application/json" \
  -d '{"allowfrom": ["YOUR_ACME_CLIENT_IP/32"]}' | jq .

And make sure the data/ directory (which holds the SQLite database) is never committed to version control — add it to .gitignore:

data/

Wrapping up

acme-dns is one of those tools that solves a real security problem elegantly. You get fully automated wildcard certificate renewals without ever giving an API token broad write access to your DNS zone. The DNS-01 flow stays completely automated, and the exposure surface is minimal.

The Docker setup makes it easy to run alongside whatever else you have on your VPS. Even with a port conflict, a single config change is all it takes to get everything working.

The one gotcha worth calling out again: always use an absolute path for the database connection string. The default relative path "acme-dns.db" is one of those silent failures that works perfectly until a container restart wipes everything — and debugging why your certificate renewals suddenly started returning forbidden two months later is not a fun afternoon.

The full project files (anonymized) are available on GitHub: github.com/jevellangelo/acme-dns

GitHub - jevellangelo/acme-dns: Self-Hosting acme-dns on a VPS with Docker
Self-Hosting acme-dns on a VPS with Docker. Contribute to jevellangelo/acme-dns development by creating an account on GitHub.