Automatic SSL Certificates for Self-Hosted Services on a Private Network with ACME DNS Challenge

Automatic SSL Certificates for Self-Hosted Services on a Private Network with ACME DNS Challenge

We earn commissions when you shop through the links on this page, at no additional cost to you. Learn more.

Getting a trusted SSL certificate for a service that never touches the public internet is one of those problems that trips up almost every homelab beginner. Port 80 is blocked, the server has a private IP, and yet your browser screams at you every time you open Vaultwarden or Immich over HTTPS. The fix is the ACME DNS-01 challenge — it proves domain ownership through a DNS TXT record instead of an HTTP request, so your server never needs to be reachable from outside your network.

I've used this approach across a bunch of services — Jellyfin on a local NUC, Nextcloud on a LAN-only VM, even an internal Gitea instance — and once it's configured, certificates renew silently every 60 days without me touching anything. In this tutorial I'll walk through the full setup using both Caddy (my preferred choice) and Certbot, with Cloudflare as the DNS provider, because it has the best API support and a generous free tier.

Why DNS-01 Instead of HTTP-01?

The standard ACME HTTP-01 challenge requires your server to respond on port 80 from a publicly routable IP. That's fine for a VPS, but on a home network with a private IP (192.168.x.x, 10.x.x.x) it simply doesn't work — your ISP won't route inbound connections, and most consumer routers make port forwarding fragile at best.

DNS-01 works differently: your ACME client creates a temporary TXT record at _acme-challenge.yourdomain.com, Let's Encrypt (or another CA) checks that DNS record, and the certificate is issued. Your server never has to accept a single inbound connection from the internet. The only requirement is that you own a public domain and your DNS provider has an API that your ACME client can drive automatically.

Tip: You can point your public domain at a private IP. For example, jellyfin.home.yourdomain.com can resolve to 192.168.1.50 — browsers only care that the certificate is valid for that hostname, not where the IP points. This pattern is sometimes called "split-horizon DNS."

Prerequisites

To create a Cloudflare API token, go to My Profile → API Tokens → Create Token, use the "Edit zone DNS" template, and restrict it to only the specific zone (domain) you'll be using. Store this token somewhere safe — it's the only secret this whole setup needs.

Option 1: Caddy with the Cloudflare DNS Module

I prefer Caddy for this because the Caddyfile syntax is genuinely readable and automatic HTTPS is baked in. The only catch is that the official Caddy Docker image doesn't include DNS provider plugins — you need a custom build. The easiest way is to use the community image that includes the Cloudflare module.

# docker-compose.yml for Caddy with Cloudflare DNS challenge

version: "3.9"

services:
  caddy:
    image: slothcroissant/caddy-cloudflaredns:latest
    container_name: caddy
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"   # HTTP/3
    environment:
      - CLOUDFLARE_API_TOKEN=${CLOUDFLARE_API_TOKEN}
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config

volumes:
  caddy_data:
  caddy_config:

Create a .env file in the same directory with your token:

CLOUDFLARE_API_TOKEN=your_cloudflare_api_token_here

Now write your Caddyfile. The key is the tls block with dns cloudflare — this tells Caddy to use DNS-01 for every certificate in this file:

# Caddyfile

{
  # Global options
  email [email protected]
}

jellyfin.home.yourdomain.com {
  tls {
    dns cloudflare {env.CLOUDFLARE_API_TOKEN}
  }
  reverse_proxy 192.168.1.50:8096
}

nextcloud.home.yourdomain.com {
  tls {
    dns cloudflare {env.CLOUDFLARE_API_TOKEN}
  }
  reverse_proxy 192.168.1.51:80
  
  # Nextcloud requires these headers
  header {
    Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
  }
  redir /.well-known/carddav /remote.php/dav 301
  redir /.well-known/caldav /remote.php/dav 301
}

vaultwarden.home.yourdomain.com {
  tls {
    dns cloudflare {env.CLOUDFLARE_API_TOKEN}
  }
  reverse_proxy 192.168.1.52:8080
}

Start it up with docker compose up -d and watch the logs with docker compose logs -f caddy. Within about 30 seconds you should see Caddy negotiating certificates via DNS. First-time issuance takes 10–30 seconds depending on DNS propagation.

Watch out: The Cloudflare DNS propagation check happens on Cloudflare's authoritative servers, not your local resolver. This means certificates are usually issued faster than you'd expect — but if Let's Encrypt hits a rate limit (5 failures per domain per hour), you'll be locked out for a bit. Use Let's Encrypt's staging environment (acme_ca https://acme-staging-v02.api.letsencrypt.org/directory in the global Caddy block) while you're testing.

Option 2: Certbot with the Cloudflare Plugin

If you're not using a reverse proxy and just need a certificate for a standalone service, Certbot with the certbot-dns-cloudflare plugin is the most straightforward approach. Here's how to run it entirely in Docker so you don't pollute your host Python environment:

# Create the Cloudflare credentials file first
mkdir -p /opt/certbot/cloudflare
cat > /opt/certbot/cloudflare/credentials.ini << 'EOF'
dns_cloudflare_api_token = your_cloudflare_api_token_here
EOF
chmod 600 /opt/certbot/cloudflare/credentials.ini

# Issue a wildcard certificate for *.home.yourdomain.com
docker run --rm \
  -v /opt/certbot/cloudflare:/cloudflare:ro \
  -v /etc/letsencrypt:/etc/letsencrypt \
  -v /var/lib/letsencrypt:/var/lib/letsencrypt \
  certbot/dns-cloudflare:latest certonly \
    --dns-cloudflare \
    --dns-cloudflare-credentials /cloudflare/credentials.ini \
    --dns-cloudflare-propagation-seconds 30 \
    -d "*.home.yourdomain.com" \
    -d "home.yourdomain.com" \
    --email [email protected] \
    --agree-tos \
    --non-interactive

# Certificates land at:
# /etc/letsencrypt/live/home.yourdomain.com/fullchain.pem
# /etc/letsencrypt/live/home.yourdomain.com/privkey.pem

A wildcard certificate (*.home.yourdomain.com) is especially convenient — you get a single cert that covers every subdomain under that prefix, so adding a new service doesn't require a new certificate issuance. To automate renewal, add a cron job on your host:

# Add to crontab with: crontab -e
# Runs renewal check twice daily, reloads nginx/caddy if certs change
0 3,15 * * * docker run --rm \
  -v /opt/certbot/cloudflare:/cloudflare:ro \
  -v /etc/letsencrypt:/etc/letsencrypt \
  -v /var/lib/letsencrypt:/var/lib/letsencrypt \
  certbot/dns-cloudflare:latest renew \
    --dns-cloudflare \
    --dns-cloudflare-credentials /cloudflare/credentials.ini \
    --quiet \
  && docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null || true

Other DNS Providers

Cloudflare is what I use, but the same approach works with many other providers. Caddy has DNS modules for Route53, DigitalOcean, Hetzner, Namecheap, Porkbun, and more. Certbot has a corresponding certbot-dns-* plugin for most major providers. If you're already running a DigitalOcean Droplet and using DigitalOcean DNS, the certbot/dns-digitalocean Docker image and a DO personal access token will get you there in the same way — just swap dns_cloudflare_api_token for dns_digitalocean_token in the credentials file.

DNS Records to Create

Don't forget to actually create the A records pointing your subdomains at your server's LAN IP. In Cloudflare, make sure the orange cloud (proxying) is turned off — set them to DNS-only (grey cloud). Cloudflare can't proxy traffic to a private IP, and leaving it on will cause confusing errors.

For a wildcard setup, one record covers everything: an A record for *.home.yourdomain.com pointing to your server's local IP (e.g., 192.168.1.50). Devices on your LAN will resolve these names correctly; devices outside your network will see the private IP and be unable to connect, which is exactly what you want for internal services.

Tip: If you want your internal hostnames to resolve without relying on Cloudflare's DNS for every lookup, set up a local DNS override in AdGuard Home or Pi-hole. Create a custom rewrite rule for *.home.yourdomain.com pointing to your server IP. Local clients get faster resolution and you're not sending internal hostnames to an external DNS resolver for every request.

Troubleshooting Common Issues

Certificate issued but browser still shows insecure: Check that your reverse proxy is actually serving the new certificate file. If you're using Nginx outside Docker, run nginx -t && systemctl reload nginx after Certbot renews. Caddy handles this automatically.

DNS propagation timeout: Increase --dns-cloudflare-propagation-seconds to 60. On some Cloudflare plans, TXT records propagate almost instantly, but the default of 10 seconds can be too short if Cloudflare is under load.

Rate limit errors from Let's Encrypt: You hit 5 failed validation attempts per domain per hour. Use the staging CA while testing — staging certs aren't browser-trusted but the rate limits are much more forgiving. Switch back to production once you've confirmed the flow works.

"No such container" error in the cron reload command: Make sure your Caddy container is named consistently. Check with docker ps --format '{{.Names}}' and update the cron job to match.

Wrapping Up

Once this is in place, your private services get the same green padlock as any public website — no browser warnings, no self-signed certificate exceptions, and no need to open any ports to the internet. Caddy's DNS challenge setup is genuinely set-and-forget; I haven't manually touched certificates on my homelab in over a year.

Your next steps: if you haven't already set up AdGuard Home for local DNS resolution to make those hostnames resolve without leaving your network, that pairs perfectly with this setup. And if you want to expose some services to the outside world selectively, check out our Cloudflare Tunnel tutorial for a zero-port-forward approach that complements what you've built here.

DigitalOcean

Discussion