Files
dns-wildcard-cert/README.md
T
christianandClaude Fable 5.1 28864708c8
Build Container / docker (push) Successful in 20s
docs: restructure DNS setup section in README
Split DNS setup into one-time delegation (NS + A) and per-name CNAME
records, with a table showing which _acme-challenge record each -d
entry needs. Nested wildcards like *.ai.example.com need their own
record, which was not obvious before. Add a note on wildcard TXT
records shadowing missing challenge names, plus dig commands to verify
before running certbot. Drop the obsolete plugin parameter table and
redundant run examples.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lyu3TWGffosqSY6kDpXCd1
2026-09-11 13:30:03 +02:00

181 lines
6.5 KiB
Markdown

# DNS Wildcard Certificate Generator
Docker container that obtains wildcard certificates from Let's Encrypt. It uses the
`certbot-dns-standalone` plugin, which runs its own tiny DNS server to answer the
ACME DNS-01 challenge. No API credentials for your DNS provider are needed.
## How It Works
1. You delegate one subdomain (e.g. `acme.example.com`) to the machine running this container.
2. For every domain you want a certificate for, you point its `_acme-challenge` record at that subdomain via CNAME.
3. When certbot runs, Let's Encrypt looks up `_acme-challenge.<domain>`, follows the CNAME,
and lands on the container's DNS server, which answers with the challenge token.
The container only needs to be running (with port 53 reachable) while a certificate is
being requested or renewed.
## DNS Setup
### Step 1: Delegate a subdomain to the container (once per zone)
Pick a subdomain, e.g. `acme.example.com`. Add these two records to the `example.com` zone:
| Name | Type | Value |
|------------------------|------|-------------------------|
| `ns.acme.example.com` | A | `1.2.3.4` |
| `acme.example.com` | NS | `ns.acme.example.com.` |
`1.2.3.4` is the public IP of the machine running this container. Port 53 (TCP and UDP)
must be reachable from the internet.
### Step 2: Add a CNAME for every name you request (once per name)
Let's Encrypt validates each name in the certificate separately. For each name it queries
`_acme-challenge.<name>`. So you need one CNAME per **distinct name**, all pointing at the
subdomain from Step 1.
Rule of thumb: take every `-d` entry, drop the leading `*.` if present, and prefix the
result with `_acme-challenge.`. Duplicates collapse into one record.
| You request (`-d`) | Record needed |
|--------------------------------|--------------------------------------------------------|
| `example.com` | `_acme-challenge.example.com CNAME acme.example.com.` |
| `*.example.com` | same record as above (already covered) |
| `*.ai.example.com` | `_acme-challenge.ai.example.com CNAME acme.example.com.` |
| `*.dev.ai.example.com` | `_acme-challenge.dev.ai.example.com CNAME acme.example.com.` |
| `other-domain.org` | `_acme-challenge.other-domain.org CNAME acme.example.com.` |
Notes:
- `example.com` and `*.example.com` share one record. A wildcard one level deeper
(`*.ai.example.com`) is a **different name** and needs its own record.
- Other domains you own (`other-domain.org`) can reuse the same `acme.example.com`
delegation. Only Step 2 is needed for them.
- The CNAME target can be anything under the delegated subdomain. The container answers
every TXT query it receives, regardless of name. `acme.example.com.` is the simplest choice.
### Watch out: wildcard TXT records
If your zone has a wildcard record like `*.example.com TXT "v=spf1 ..."`, then
`_acme-challenge.ai.example.com` silently resolves to that SPF string instead of failing.
Let's Encrypt then reports an *incorrect TXT record*. An explicit CNAME for
`_acme-challenge.ai.example.com` overrides the wildcard, so adding the record from Step 2
fixes this.
### Verify before running certbot
Ask the authoritative name server directly (no cache):
```bash
dig +norecurse @<your-dns-provider-ns> _acme-challenge.ai.example.com TXT
```
You want to see a `CNAME acme.example.com.` line in the answer. If you see an SPF string
or nothing, the record is missing or the wildcard is winning.
Then check the delegation works end to end while the container is running:
```bash
dig @1.1.1.1 _acme-challenge.example.com TXT
```
## Usage
### Quick Start
```bash
docker run -it --rm \
-v "/etc/letsencrypt:/etc/letsencrypt" \
-v "/var/lib/letsencrypt:/var/lib/letsencrypt" \
-p 53:53/tcp -p 53:53/udp \
-e EMAIL="youremail@example.com" \
-e DOMAINS="-d example.com -d *.example.com -d *.ai.example.com" \
-e STAGING="true" \
dns-wildcard-cert
```
Run with `STAGING="true"` first. Once it succeeds, run again without it to get a real
certificate. Let's Encrypt rate-limits failed attempts against production.
### Build Locally
```bash
docker build -t dns-wildcard-cert .
```
### Environment Variables
| Variable | Required | Default | Description |
|--------------------|----------|-----------|----------------------------------------------------------|
| `EMAIL` | Yes | - | Email for Let's Encrypt registration |
| `DOMAINS` | Yes | - | Domain flags, e.g. `-d example.com -d *.example.com` |
| `DNS_ADDRESS` | No | `0.0.0.0` | IPv4 address to bind the DNS server to |
| `DNS_IPV6_ADDRESS` | No | `::` | IPv6 address to bind the DNS server to |
| `DNS_PORT` | No | `53` | Port for the DNS server (needs forwarding if not 53) |
| `STAGING` | No | `false` | Use the Let's Encrypt staging server (for testing) |
| `DRY_RUN` | No | `false` | Perform a dry run without saving certificates |
### Non-standard port
If port 53 is taken on the host, run the container on another port and have your existing
DNS server forward `_acme-challenge` queries there:
```bash
docker run -it --rm \
-v "/etc/letsencrypt:/etc/letsencrypt" \
-v "/var/lib/letsencrypt:/var/lib/letsencrypt" \
-p 5555:5555/tcp -p 5555:5555/udp \
-e EMAIL="youremail@example.com" \
-e DOMAINS="-d example.com -d *.example.com" \
-e DNS_PORT="5555" \
dns-wildcard-cert
```
## Certificate Renewal
Run the same container again periodically, or use certbot's renew command:
```bash
docker run -it --rm \
-v "/etc/letsencrypt:/etc/letsencrypt" \
-v "/var/lib/letsencrypt:/var/lib/letsencrypt" \
-p 53:53/tcp -p 53:53/udp \
--entrypoint certbot \
dns-wildcard-cert renew
```
## Certificate Location
Certificates are stored in the `/etc/letsencrypt` volume:
- Certificate: `/etc/letsencrypt/live/<domain>/fullchain.pem`
- Private key: `/etc/letsencrypt/live/<domain>/privkey.pem`
## Docker Compose
```yaml
version: '3.8'
services:
certbot:
build: .
ports:
- "53:53/tcp"
- "53:53/udp"
environment:
- EMAIL=youremail@example.com
- DOMAINS=-d example.com -d *.example.com
- STAGING=false
volumes:
- letsencrypt:/etc/letsencrypt
- letsencrypt-lib:/var/lib/letsencrypt
volumes:
letsencrypt:
letsencrypt-lib:
```
## License
MIT