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.
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
-kanywhere. - 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
shortlivedprofile 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.devIP: 46.224.186.195IP: 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
h3viaAlt-Svcheader so you get HTTP/3/QUIC for free (assuming UDP/443 isn't blocked)
1) Provision the server (Hetzner)
- Started server up in Hetzner.
Optional but nice: set reverse DNS (PTR) for both IPs to your hostname:
46.224.186.195→ip-demo.aimoda.dev2a01:4f8:1c1f:91e2::1→ip-demo.aimoda.dev
2) DNS: A + AAAA in Cloudflare
- Set
2a01:4f8:1c1f:91e2::1AAAA record in Cloudflare forip-demo.aimoda.dev - Set
46.224.186.195A record in Cloudflare forip-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:
- Let's Encrypt's IP certs require ACME profile selection (
shortlived); acme.sh supports this via--certificate-profile - acme.sh can issue a single cert with multiple SANs (hostname + IPs) in one go; Caddy's native ACME issues separate certs per subject
- 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:
- IP certificates must use the
shortlivedprofile (~160 hour validity) - HTTP-01 uses port 80 for validation (works for both DNS names and IP addresses per RFC 8738)
- Let's Encrypt recommends renewing short-lived certs every 2–3 days
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
--standaloneissuance; HTTP-01 requires inbound :80. If you have a webserver running, either stop it or use--webrootmode instead. --days 3tells 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-v6if 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.devshould present a valid chainhttps://46.224.186.195should 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.