# ACME Wildcard DNS-01 Example **What this does:** Issues wildcard certificates (e.g., `*.example.com`) from Let's Encrypt using DNS-01 challenge validation. > **Operational notes** shared by every example (postgres password rotation trap, TLS provisioning, teardown semantics) live in [`../README.md`](../README.md). Read it first if you plan to change `DB_PASSWORD` after the initial `docker compose up` — the postgres volume binds the password on first boot only. This example is ideal for: - Issuing wildcard certificates (`*.example.com`) - Services behind NAT, firewalls, or non-public networks - Batch issuance of multiple domains in parallel - Internal PKI with public DNS names - Scenarios where you have programmatic access to your DNS provider's API ## TLS Security certctl is HTTPS-only as of v2.2. The demo compose stack provisions a self-signed certificate. When accessing `https://localhost:8443`, you can either: - Use `curl --cacert ./deploy/test/certs/ca.crt ...` to pin the CA certificate - Use `curl -k ...` for quick smoke tests (never in production) - Import the CA at `./deploy/test/certs/ca.crt` into your OS trust store for browser visits ## Prerequisites Before running this example, you need: 1. **A domain name** (e.g., `example.com`) that you control and can manage DNS records for 2. **DNS provider credentials:** - **Cloudflare** (example included): API token with DNS:write permission + Zone ID - **Route53 (AWS)**: AWS access key + secret key - **Azure DNS**: Azure subscription ID + credentials - **Other providers**: See "Adapting for Other DNS Providers" below 3. **Docker and Docker Compose** installed ## Quick Start (Cloudflare) ### Step 1: Get Cloudflare Credentials 1. Log in to [Cloudflare Dashboard](https://dash.cloudflare.com) 2. Select your domain (e.g., `example.com`) 3. In the sidebar, find **Zone ID** (copy this) 4. Go to **Account Settings > API Tokens** 5. Create a new token with these scopes: - **Zone > Zone:Read** (to list DNS records) - **Zone > DNS:Write** (to create/delete challenge records) 6. Copy the API token ### Step 2: Set Environment Variables Create a `.env` file in this directory: ```bash # .env CLOUDFLARE_API_TOKEN=your-api-token-here CLOUDFLARE_ZONE_ID=your-zone-id-here ACME_EMAIL=admin@example.com DB_PASSWORD=your-secure-db-password ``` Or export them in your shell: ```bash export CLOUDFLARE_API_TOKEN="your-api-token-here" export CLOUDFLARE_ZONE_ID="your-zone-id-here" export ACME_EMAIL="admin@example.com" export DB_PASSWORD="your-secure-db-password" ``` ### Step 3: Make DNS Scripts Executable ```bash chmod +x dns-hooks/*.sh ``` ### Step 4: Start the Stack ```bash docker compose up -d ``` This starts: - **certctl-server** (port 8443): Control plane and ACME orchestrator - **postgres**: Certificate metadata database - **certctl-agent**: Certificate deployment agent ### Step 5: Access the Dashboard Open your browser to `https://localhost:8443` ### Step 6: Create a Wildcard Certificate 1. Go to **Issuers** page 2. Verify the ACME issuer is registered 3. Go to **Certificates** > **New Certificate** 4. Fill in: - **Issuer:** ACME (Let's Encrypt) - **Common Name:** `*.example.com` - **Subject Alt Names:** `example.com` (to also cover the root domain) 5. Click **Request** The renewal job will: 1. Send a request to Let's Encrypt 2. Run `dns-hooks/cloudflare-present.sh` to create `_acme-challenge.example.com` TXT record 3. Wait for Let's Encrypt to verify the TXT record 4. Issue the certificate 5. Run `dns-hooks/cloudflare-cleanup.sh` to delete the temporary TXT record ### Step 7: Monitor the Job Go to **Jobs** page to see the renewal progress: - **AwaitingCSR**: Agent is generating the CSR - **Running**: ACME challenge in progress (DNS record being validated) - **Completed**: Certificate issued and stored - **Failed**: Check logs for errors (e.g., DNS provider API issues) ## How DNS-01 Works The DNS-01 challenge proves you own a domain by creating a DNS TXT record: ``` _acme-challenge.example.com TXT "acme-validation-token-xxxxx" ``` Let's Encrypt then queries this TXT record. Once verified, it issues the certificate and certctl cleans up the TXT record. **Why DNS-01 is better than HTTP-01 for wildcards:** - HTTP-01 requires a public web server; DNS-01 works anywhere - Wildcard certificates require DNS proof (not HTTP) - DNS challenges can be solved for multiple domains in parallel - No need for public IP or inbound port 80/443 ## Adapting for Other DNS Providers The example uses Cloudflare, but certctl supports **any DNS provider via pluggable shell scripts**. ### AWS Route53 Replace the `CERTCTL_ACME_DNS_PRESENT_SCRIPT` and `CERTCTL_ACME_DNS_CLEANUP_SCRIPT` in `docker-compose.yml` with: - `./dns-hooks/route53-present.sh` - `./dns-hooks/route53-cleanup.sh` Example script outline (using AWS CLI): ```bash #!/bin/bash DOMAIN="$1" VALIDATION_TOKEN="$2" # Get Route53 hosted zone ID for the domain ZONE_ID=$(aws route53 list-hosted-zones --query \ "HostedZones[?Name=='$DOMAIN.'].Id" --output text | cut -d'/' -f3) # Create TXT record aws route53 change-resource-record-sets \ --hosted-zone-id "$ZONE_ID" \ --change-batch "{ \"Changes\": [{ \"Action\": \"CREATE\", \"ResourceRecordSet\": { \"Name\": \"_acme-challenge.$DOMAIN\", \"Type\": \"TXT\", \"TTL\": 120, \"ResourceRecords\": [{\"Value\": \"\\\"$VALIDATION_TOKEN\\\"\"}] } }] }" ``` ### Azure DNS ```bash #!/bin/bash DOMAIN="$1" VALIDATION_TOKEN="$2" # Set Azure credentials via environment variables # AZURE_SUBSCRIPTION_ID, AZURE_RESOURCE_GROUP, AZURE_TENANT_ID, etc. az network dns record-set txt create \ --resource-group "$AZURE_RESOURCE_GROUP" \ --zone-name "$DOMAIN" \ --name "_acme-challenge" \ --ttl 120 \ --txt-value "$VALIDATION_TOKEN" ``` ### Generic DNS Provider (using dig + TSIG) If your DNS provider supports NSUPDATE (RFC 2136): ```bash #!/bin/bash DOMAIN="$1" VALIDATION_TOKEN="$2" nsupdate <