U2 / Networking and remote access
Local HTTPS without a public CA: Caddy internal vs step-ca
Tested on Caddy 2.11.7
Test result
Caddy's internal CA issued a certificate for a .internal name in a median of 0.75 s with one extra line (tls internal) in the Caddyfile; step-ca over ACME took a median of 1.58 s once running, and for a .internal name it needed an explicit issuer block in the site config because a global acme_ca was ignored. Either way curl fails with exit 60 until the root is trusted, and passes with exit 0 afterwards.

Which one to pick
For a single box serving a handful of LAN names, Caddy’s built-in CA is the short path: one site block, no second service. step-ca is worth its extra container when more than one machine or tool needs certificates from the same authority, because it speaks ACME to any client. This test measured both on the same names, in a throwaway VM, with no public CA involved.
| Setup | Start to first TLS handshake, s, median (range) of 3 | Leaf certificate lifetime | Leaf issuer |
|---|---|---|---|
| Caddy 2.11.7, internal CA (tls internal) | 0.75 (0.68 to 2.04) | 12.0 h | CN = Caddy Local Authority - ECC Intermediate |
| step-ca 0.30.2 over ACME, Caddy 2.11.7 as client, CA already running | 1.58 (1.54 to 1.59), empty Caddy data | 24.0 h | O = Lab CA, CN = Lab CA Intermediate CA |
| step-ca 0.30.2 over ACME, Caddy started right after the CA is healthy | 1.58 (1.52 to 1.71) for Caddy; 2.65 (2.57 to 2.67) for the CA to become healthy | 24.0 h | O = Lab CA, CN = Lab CA Intermediate CA |
Time is polled with curl -k every 0.1 s from docker compose up to the first completed handshake, so it includes container start. 3 cold runs per row, each with empty data volumes. VM 4 vCPU, 3 GB. Lifetimes are the notAfter minus notBefore of the certificate served; step-ca backdates notBefore slightly.
Three cold runs per row, each starting from empty data volumes. The Caddy internal figure includes one slower first run (2.04 s against 0.75 s and 0.68 s for the others); the test does not explain it.
Certificate lifetimes and chains
| CA | Root validity | Intermediate validity | Leaf validity |
|---|---|---|---|
| Caddy internal CA | 9.9 years | 7 days | 12.0 h |
| step-ca | 10.0 years | 10.0 years | 24.0 h |
Read from the PEM files copied out of the containers and from openssl s_client output (raw/*.info.txt, raw/*.leaf.txt).
The Caddy leaf was valid for 12.0 hours and the step-ca leaf for 24.0 hours, as served. Caddy’s automatic HTTPS page says its intermediate is short-lived and renewed automatically but gives no leaf figure, so the 12 h here is the measured value on Caddy 2.11.7, not a documented guarantee. Short leaves are a reason to keep the renewal path working: this test issued certificates but did not wait for a renewal.
Trust is the part that bites
A valid certificate from a private CA is only valid for clients that trust its root. The same curl call, three ways:
| CA | curl, no trust configured | curl --cacert root.crt | Root added to the system store | Root removed from the system store again |
|---|---|---|---|---|
| Caddy internal CA | exit 60 (certificate problem: unable to get local issuer certificate) | exit 0 | exit 0 | exit 60 (certificate problem: unable to get local issuer certificate) |
| step-ca | exit 60 (certificate problem: unable to get local issuer certificate) | exit 0 | exit 0 | exit 60 (certificate problem: unable to get local issuer certificate) |
Exit 0 means curl verified the chain and got the page. The system store is the Ubuntu 24.04 store in the VM (update-ca-certificates). Names resolved with curl --resolve, no DNS server involved.
With no trust configured curl stops with exit 60. Passing the root with --cacert works, and so does adding the root to the Ubuntu system store. Removing it from the store brought the failure back, which shows the earlier success came from the store and not from a cached answer.
Getting the root out of each container, as tested:
# Caddy internal CA
docker compose cp caddy-internal:/data/caddy/pki/authorities/local/root.crt root.crt
# step-ca
docker compose cp step-ca:/home/step/certs/root_ca.crt root.crt
curl --resolve svc.lab.internal:443:127.0.0.1 --cacert root.crt https://svc.lab.internal/
sudo cp root.crt /usr/local/share/ca-certificates/lab.crt && sudo update-ca-certificates
Caddy also tries to install its root itself. Its log inside the container says “certificate installed properly in linux trusts”, which describes the container’s own store, not the VM or any other machine. The Caddy docs say the automatic install may not work in containers or unprivileged services and that administrators are responsible for trust in internal PKI setups. Every client device needs the root some other way.
The first ACME attempt did not use step-ca
The first Caddyfile put step-ca in Caddy’s global options (acme_ca and acme_ca_root). Caddy started, served svc.lab.internal, and the certificate it served was issued by its own local CA. step-ca’s log shows no ACME requests at all.
| Check | Result |
|---|---|
| Issuer of the certificate Caddy served | CN = Caddy Local Authority - ECC Intermediate |
| step-ca log lines mentioning /acme/ | 0 |
The same site block with the issuer set inside the site block (tls { issuer acme { ... } }) produced a step-ca certificate, see the main table.
The Caddy docs say names ending in .internal do not qualify for publicly-trusted certificates and that Caddy signs non-public sites with its own CA; that is consistent with what was seen, but the docs do not say a global acme_ca is ignored for such names, so that part is inference. The test did not try a global acme_ca with a public-style name. The fix that worked was to name the issuer inside the site block. Both files are in the test folder; the second one is the one the timings and the step-ca certificate come from.
Show the full file
svc.lab.internal {
tls {
issuer acme {
dir https://step-ca:9000/acme/acme/directory
trusted_roots /stepca/certs/root_ca.crt
}
}
respond "hello from step-ca via ACME\n"
}The compose file for both profiles:
This file uses the public test value lab-only-not-a-secret. Replace every instance with your own secret before you run it.
Show the full file
# Two ways to get valid HTTPS for a LAN-only name. Run one profile at a time (both publish 443).
services:
caddy-internal:
profiles: [internal]
image: caddy:2.11.7
ports: ["127.0.0.1:443:443"]
volumes:
- ./Caddyfile.internal:/etc/caddy/Caddyfile:ro
- caddy_internal_data:/data
step-ca:
profiles: [acme]
image: smallstep/step-ca:0.30.2
environment:
DOCKER_STEPCA_INIT_NAME: Lab CA
DOCKER_STEPCA_INIT_DNS_NAMES: step-ca,localhost
DOCKER_STEPCA_INIT_PROVISIONER_NAME: admin
DOCKER_STEPCA_INIT_PASSWORD: lab-only-not-a-secret
DOCKER_STEPCA_INIT_ACME: "true"
volumes:
- step:/home/step
healthcheck:
test: ["CMD", "step", "ca", "health"]
interval: 2s
timeout: 3s
retries: 60
caddy-acme:
profiles: [acme]
image: caddy:2.11.7
depends_on:
step-ca: { condition: service_healthy }
ports: ["127.0.0.1:443:443"]
volumes:
- ./${ACME_CADDYFILE:-Caddyfile.acme}:/etc/caddy/Caddyfile:ro
- step:/stepca:ro
- caddy_acme_data:/data
networks: { default: { aliases: [svc.lab.internal] } }
volumes:
caddy_internal_data:
caddy_acme_data:
step:step-ca’s own setup came from the DOCKER_STEPCA_INIT_* variables in that file, with DOCKER_STEPCA_INIT_ACME enabled. The ACME client container carries svc.lab.internal as a network alias so step-ca can resolve and reach it; the test did not try without the alias. In the Caddy log, the challenge that was answered was tls-alpn-01.
Where DNS-01 fits (not tested)
Both challenges above happen over the Docker network, with step-ca connecting back to the client. On a real LAN the same works as long as the CA can resolve and reach the client’s name on ports 80 or 443. When it cannot, the DNS challenge removes that need: per the Caddy docs it requires DNS provider credentials to write TXT records and opens no ports, and the same page says Let’s Encrypt requires it for wildcard certificates. The step-ca provisioner docs list dns-01 as a supported ACME challenge. This test used neither, so no timing, setup effort or failure mode for DNS-01 is reported here.
What this test did not cover
- Renewal: no certificate was renewed. Lifetimes are as issued.
- Real clients: only curl 8.5.0 on Ubuntu 24.04 was used. Browsers, phones, Java and Windows trust stores were not tested.
- DNS: names were forced with
curl --resolve. No DNS server, split-horizon setup or mDNS was involved. - DNS-01 and any public CA. No Let’s Encrypt, no real domain.
- step-ca hardening: the CA password is the lab placeholder, the provisioner is the default, and keys sit on a Docker volume. Root key protection, revocation and monitoring were not looked at.
- Load: one name, one request at a time.
Sources and how this was made
Show the full file
#!/usr/bin/env bash
# local-https: valid HTTPS for a LAN-only name from Caddy's internal CA and from step-ca over ACME. No public CA is contacted.
TEST_ID=local-https; VM_NAME=b-local-https
source "$(dirname "$0")/../access-common.sh"
HERE="$ROOT/lab/tests/$TEST_ID"
rm -rf "$RAW"; mkdir -p "$RAW"
vm_start
capture_env
vm ssh 'rm -rf ~/lh; mkdir -p ~/lh/raw'
vm scp-to "$HERE/files/." lh/
vm scp-to "$HERE/lh.sh" lh/lh.sh
vm ssh 'cd ~/lh && nohup ./lh.sh > raw/lh.log 2>&1 &'
for _ in $(seq 1 120); do
vm ssh 'test -f ~/lh/raw/DONE' 2>/dev/null && break
sleep 10
done
vm scp-from lh/raw/. "$RAW/"
python3 -I "$HERE/summarize.py" "$RAW" "$OUT"How this page was made: the commands above were run by an AI agent on the test box on November 1, 2026, and the same agent drafted this text from the saved output. The site's owner reviewed the site before publication. Test method and editorial policy explain how results are checked, and what is not. Spotted an error? Tell us.