Build Container / docker (push) Successful in 20s
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
181 lines
6.5 KiB
Markdown
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
|