IP + Domain, One Cert: Let's Encrypt Short-Lived Certificates

Learn how to issue one short-lived Let's Encrypt cert that covers your domain and IPs, then load it in Caddy via acme.sh for HTTP/3-ready TLS.

IP + Domain, One Cert: Let's Encrypt Short-Lived Certificates

Sometimes you want a server to be "real-TLS-secure" even when you're hitting it by raw IP:

  • DNS is broken / propagating / temporarily misconfigured and you still need a clean TLS path.
  • You're bootstrapping a fresh cloud VM and don't want to serve anything over :80 "just for testing".
  • You're doing ephemeral infra (short-lived admin endpoints, backends talking to backends) and don't want -k anywhere.
  • You want a clean "fallback" access path that doesn't depend on a hostname.

As of late 2025, Let's Encrypt can issue certificates that cover IP address identifiers (IPv4 and IPv6), but only via the short-lived ACME profile, and only with http-01 or tls-alpn-01 (not dns-01). The easiest way I've found to deploy that in a "normal" web stack is: use acme.sh to issue the cert, then have Caddy load it from disk and serve it for both the hostname and the IPs.

This post is exactly that, using:

  • Hostname: ip-demo.aimoda.dev
  • IPv4: 46.224.186.195
  • IPv6: 2a01:4f8:1c1f:91e2::1
  • Caddy in JSON mode, with HTTP/3 (QUIC) enabled.

Background: what Let's Encrypt changed (and why shortlived matters)

Let's Encrypt has been rolling out ACME profile selection (draft-ietf-acme-profiles) and short-lived (~160-hour) certificates through 2025. Their certificate profiles documentation explicitly shows the shortlived profile is ~160 hours and supports Identifier Types: DNS, IP.

Timeline

Date Milestone
January 9, 2025 Announced ACME profile selection
January 16, 2025 Announced 6-day and IP address certificate support coming
February 20, 2025 Issued first six-day certificate
July 1, 2025 Issued first IP address certificate
December 2025 GA opt-in for short-lived + IP certs via Generation Y hierarchy

Key constraints from RFC 8738 and Let's Encrypt policy:

  • IP address certs must be short-lived (~160 hours / ~6.66 days)
  • you must request the shortlived profile via ACME profile selection
  • you can't validate IP control with DNS-01; only http-01 (port 80) and tls-alpn-01 (port 443) work
  • only public IP addresses are supported (no RFC 1918 / private ranges)
  • no CAA record checking for IPs (CAA is DNS-based)

Short-lived certs have no OCSP/CRL URLs embedded; they expire before revocation would matter. This is per CA/Browser Forum Ballot SC-063 which defines short-lived as ≤10 days (reducing to ≤7 days after March 15, 2026).

Rate limits for IP certs (docs)

Scope Limit
IPv4 50 certs per exact IP per 7 days
IPv6 50 certs per /64 range per 7 days
Identifiers per cert 100 (DNS + IP combined)

What you're building

A single X.509 cert with SANs for:

  • DNS: ip-demo.aimoda.dev
  • IP: 46.224.186.195
  • IP: 2a01:4f8:1c1f:91e2::1

Then Caddy:

  • listens on both the public IPv4 and IPv6 on :443
  • uses that one certificate as the default selection (important for IP access where SNI may be empty or an IP literal; RFC 6066 doesn't consider IP addresses valid SNI)
  • reverse proxies to a local app
  • advertises h3 via Alt-Svc header so you get HTTP/3/QUIC for free (assuming UDP/443 isn't blocked)

1) Provision the server (Hetzner)

  1. Started server up in Hetzner.

Optional but nice: set reverse DNS (PTR) for both IPs to your hostname:

  • 46.224.186.195ip-demo.aimoda.dev
  • 2a01:4f8:1c1f:91e2::1ip-demo.aimoda.dev

2) DNS: A + AAAA in Cloudflare

  1. Set 2a01:4f8:1c1f:91e2::1 AAAA record in Cloudflare for ip-demo.aimoda.dev
  2. Set 46.224.186.195 A record in Cloudflare for ip-demo.aimoda.dev

If you're automating, see Cloudflare's guide on creating DNS records. Since we're using HTTP-01 validation, make sure the origin is reachable; if you're proxying through Cloudflare, check the proxy status docs (you'll want DNS-only / grey-cloud mode for the validation to hit your origin directly).


3) Install Caddy

From the official Caddy install docs (Debian/Ubuntu):

sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg
chmod o+r /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install caddy

4) Run Caddy using a JSON config (systemd override)

Caddy ships with a systemd unit that assumes a Caddyfile, but you can override the ExecStart/ExecReload to run a JSON config instead.

Edit the service:

sudo systemctl edit caddy

Put this in the override:

[Service]
ExecStart=
ExecStart=/usr/bin/caddy run --environ --config /etc/caddy/caddy.json
ExecReload=
ExecReload=/usr/bin/caddy reload --config /etc/caddy/caddy.json --force

Stop Caddy:

sudo systemctl stop caddy
rm /etc/caddy/Caddyfile

nano /etc/caddy/caddy.json

5) Caddy JSON config

{
  "apps": {
    "http": {
      "servers": {
        "proxy": {
          "listen": [
            "46.224.186.195:443",
            "[2a01:4f8:1c1f:91e2::1]:443"
          ],
          "protocols": [ "h1", "h2", "h3" ],
          "automatic_https": {
            "disable": true
          },
          "tls_connection_policies": [
            {
              "certificate_selection": {
                "any_tag": [ "my_default_cert" ]
              }
            }
          ],
          "routes": [
            {
              "match": [
                {
                  "not": [
                    {
                      "host": [ "ip-demo.aimoda.dev" ]
                    }
                  ]
                }
              ],
              "handle": [
                {
                  "handler": "headers",
                  "response": {
                    "set": {
                      "Link": [ "<https://ip-demo.aimoda.dev{http.request.uri}>; rel=\"canonical\"" ]
                    }
                  }
                }
              ]
            },
            {
              "handle": [
                {
                  "handler": "headers",
                  "response": {
                    "deferred": true,
                    "delete": [ "Server", "Via" ],
                    "set": {
                      "X-Robots-Tag": [ "noindex, nofollow, notranslate, noarchive, noimageindex, nosnippet, nositelinkssearchbox, nocache, nopagereadaloud" ],
                      "Strict-Transport-Security": [ "max-age=31536000; includeSubDomains; preload" ]
                    }
                  }
                },
                {
                  "handler": "reverse_proxy",
                  "upstreams": [
                    { "dial": "127.0.0.1:8081" }
                  ]
                }
              ],
              "terminal": true
            }
          ]
        }
      }
    },
    "tls": {
      "certificates": {
        "load_files": [
          {
            "certificate": "/etc/caddy/certs/fullchain.pem",
            "key": "/etc/caddy/certs/key.pem",
            "tags": [ "my_default_cert" ]
          }
        ]
      }
    }
  }
}

Why these parts matter

Config Purpose Docs
"automatic_https": { "disable": true } We're feeding Caddy an external cert, not asking it to obtain/renew via ACME. Keeps behavior deterministic. automatic_https
"tls": { "certificates": { "load_files": ... } } Load PEM cert+key from disk. Tags allow selection. load_files
"certificate_selection": { "any_tag": [...] } Forces this cert for all connections; critical when accessed by IP (empty/IP SNI). certificate_selection
"protocols": [ "h1", "h2", "h3" ] Enable HTTP/1.1, HTTP/2, and HTTP/3 (QUIC over UDP:443). Caddy auto-adds Alt-Svc: h3=":443"; ma=2592000. protocols

Note on HTTP/3: Caddy has had HTTP/3 enabled by default since v2.6. It requires UDP:443 open in your firewall. The Alt-Svc header is sent automatically; browsers upgrade on subsequent requests.


6) Install acme.sh (and socat)

Why acme.sh? Three reasons:

  1. Let's Encrypt's IP certs require ACME profile selection (shortlived); acme.sh supports this via --certificate-profile
  2. acme.sh can issue a single cert with multiple SANs (hostname + IPs) in one go; Caddy's native ACME issues separate certs per subject
  3. Caddy's native shortlived profile support is still working through edge cases; acme.sh is more battle-tested for IP certs as of late 2025

Commands:

apt-get install socat
curl https://get.acme.sh | sh -s email=hello@ipblog.testing.email.ai.moda
source ~/.bashrc

socat is used by acme.sh's standalone mode to bind to port 80 for HTTP-01 challenges.


7) Issue ONE cert for hostname + IPv4 + IPv6 (Let's Encrypt shortlived profile)

Key constraints from Let's Encrypt and acme.sh docs:

Commands:

# Create the directory for Caddy's certs
mkdir -p /etc/caddy/certs
chown caddy:caddy /etc/caddy/certs
chmod 700 /etc/caddy/certs

# Issue the certificate (HTTP-01 standalone; port 80 must be free)
# Use the domain as the "main" name to avoid awkward filesystem paths for IP/IPv6.
acme.sh --issue \
  -d ip-demo.aimoda.dev \
  -d 46.224.186.195 \
  -d "2a01:4f8:1c1f:91e2::1" \
  --standalone \
  --server letsencrypt \
  --certificate-profile shortlived \
  --days 3

# Install to Caddy-readable location (must use the same "main" -d as above)
acme.sh --install-cert \
  -d ip-demo.aimoda.dev \
  --cert-file /etc/caddy/certs/cert.pem \
  --key-file /etc/caddy/certs/key.pem \
  --fullchain-file /etc/caddy/certs/fullchain.pem \
  --reloadcmd "systemctl reload caddy"

chown caddy:caddy -R /etc/caddy/certs
sudo systemctl start caddy

Notes that save debugging time

  • Port 80 must be reachable from the public Internet during --standalone issuance; HTTP-01 requires inbound :80. If you have a webserver running, either stop it or use --webroot mode instead.
  • --days 3 tells acme.sh to renew when the cert is 3 days old. With ~6.66 day validity, this leaves ~3.66 days buffer. The acme.sh profile selection wiki recommends this explicitly.
  • IPv6 validation: If Let's Encrypt connects via IPv6 (it prefers AAAA if present), ensure your firewall allows inbound TCP:80 on the IPv6 address too. You can force IPv6 listening with --listen-v6 if needed.
  • Never use certs directly from ~/.acme.sh/; the folder structure may change. Always use --install-cert (source).

8) Start a tiny upstream (Hello World) behind Caddy

mkdir -p /tmp/hello-world
echo "Hello, world!" > /tmp/hello-world/index.html
python3 -m http.server 8081 -b 127.0.0.1 -d /tmp/hello-world/

Important: your Caddy JSON is reverse proxying to 127.0.0.1:8081. Make sure whatever you run locally is actually listening there, or adjust accordingly.


What you should see

Once everything's up:

  • https://ip-demo.aimoda.dev should present a valid chain
  • https://46.224.186.195 should present the same certificate (valid because the IPv4 is in SAN)
  • https://[2a01:4f8:1c1f:91e2::1] should present the same certificate (valid because the IPv6 is in SAN)

You can verify the certificate contents with:

openssl s_client -connect 46.224.186.195:443 </dev/null 2>/dev/null | openssl x509 -noout -text | grep -A1 "Subject Alternative Name"

The canonical Link header behavior in your config is a nice touch: when accessed via anything other than ip-demo.aimoda.dev (e.g., raw IP), responses advertise ip-demo.aimoda.dev as canonical; good for SEO if search engines somehow index the IP.


Renewal: don't forget short-lived reality

Short-lived certificates are awesome precisely because they assume automation. Let's Encrypt explicitly recommends in their first six-day cert post:

  • run your ACME client at least daily (acme.sh's default cron does this)
  • renew short-lived certs every 2–3 days

acme.sh automatically installs a cron job at install time that runs daily. With --days 3, acme.sh will automatically renew when the cert is 3 days old. Verify the cron exists:

crontab -l | grep acme

The --reloadcmd "systemctl reload caddy" you set during --install-cert is saved and automatically re-run on each successful renewal.


Further reading