Homelab
Proof

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.

Line drawing: a wax-seal stamp press beside a small stack of blank certificate cards and a closed padlock.

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.

Time to a valid certificate for a LAN-only name, and what came out
SetupStart to first TLS handshake, s, median (range) of 3Leaf certificate lifetimeLeaf issuer
Caddy 2.11.7, internal CA (tls internal)0.75 (0.68 to 2.04)12.0 hCN = Caddy Local Authority - ECC Intermediate
step-ca 0.30.2 over ACME, Caddy 2.11.7 as client, CA already running1.58 (1.54 to 1.59), empty Caddy data24.0 hO = Lab CA, CN = Lab CA Intermediate CA
step-ca 0.30.2 over ACME, Caddy started right after the CA is healthy1.58 (1.52 to 1.71) for Caddy; 2.65 (2.57 to 2.67) for the CA to become healthy24.0 hO = 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

Certificate chain details as served and as stored
CARoot validityIntermediate validityLeaf validity
Caddy internal CA9.9 years7 days12.0 h
step-ca10.0 years10.0 years24.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:

curl against the same HTTPS name, with and without trusting the CA root
CAcurl, no trust configuredcurl --cacert root.crtRoot added to the system storeRoot removed from the system store again
Caddy internal CAexit 60 (certificate problem: unable to get local issuer certificate)exit 0exit 0exit 60 (certificate problem: unable to get local issuer certificate)
step-caexit 60 (certificate problem: unable to get local issuer certificate)exit 0exit 0exit 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.

First ACME attempt: step-ca set only as Caddy's global acme_ca option
CheckResult
Issuer of the certificate Caddy servedCN = 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.

lab/tests/local-https/files/Caddyfile.acme
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:

lab/tests/local-https/files/compose.yml

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

lab/tests/local-https/run.sh
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.