Caddy as Intermediate Certificate Authority; Not sending root cert

1. The problem I’m having:

I am trying to use Caddy as an intermediate certification authority.
I have a root key on an airgapped system and created an intermediate certificate for Caddy to use. I want Caddy to use this intermediate certificate as its “root”. It should create it’s own weekly intermediate certificate out of it and sign my website’s certs with that. Essentially do the same as using internal TLS, just using my own provided cert as root.

Now this all actually already works. I put the intermediate certificate I created on my root as Caddy’s root. It then just created its own intermediate from that and signs my things with it.
The problem is that, as the actual root which clients have installed is one layer lower. There is one certificate missing in the chain. Caddy doesn’t serve the root certificate it uses.

I’m wondering if there is some way to achieve, what I’m trying to do. To essentially serve the root cert as well. If there’s some other way to do it, I’d love to know about it as well of course.

2. Error messages and/or full log output:

I am trying to access my website locally, the DNS points to the local Caddy server.
curl -vL crispy-caesus.eu:

* Host crispy-caesus.eu:80 was resolved.
* IPv6: (none)
* IPv4: 192.168.42.1
*   Trying 192.168.42.1:80...
* Established connection to crispy-caesus.eu (192.168.42.1 port 80) from 192.168.42.69 port 55472 
* using HTTP/1.x
> GET / HTTP/1.1
> Host: crispy-caesus.eu
> User-Agent: curl/8.18.0
> Accept: */*
> 
* Request completely sent off
< HTTP/1.1 308 Permanent Redirect
< Connection: close
< Location: https://crispy-caesus.eu/
< Server: Caddy
< Date: Thu, 22 Jan 2026 19:04:15 GMT
< Content-Length: 0
< 
* shutting down connection #0
* Clear auth, redirects to port from 80 to 443
* Issue another request to this URL: 'https://crispy-caesus.eu/'
* Host crispy-caesus.eu:443 was resolved.
* IPv6: (none)
* IPv4: 192.168.42.1
*   Trying 192.168.42.1:443...
* ALPN: curl offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* SSL Trust Anchors:
*   CAfile: /etc/ssl/certs/ca-certificates.crt
*   CApath: /etc/ssl/certs
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_128_GCM_SHA256 / X25519MLKEM768 / id-ecPublicKey
* ALPN: server accepted h2
* Server certificate:
*   subject: 
*   start date: Jan 22 16:58:34 2026 GMT
*   expire date: Jan 23 04:58:34 2026 GMT
*   issuer: CN=Caddy Local Authority - ECC Intermediate
*   Certificate level 0: Public key type EC/prime256v1 (256/128 Bits/secBits), signed using ecdsa-with-SHA256
*   Certificate level 1: Public key type EC/prime256v1 (256/128 Bits/secBits), signed using ED25519
*   subjectAltName: "crispy-caesus.eu" matches cert's "crispy-caesus.eu"
* SSL certificate OpenSSL verify result: unable to get local issuer certificate (20)
* closing connection #1
curl: (60) SSL certificate OpenSSL verify result: unable to get local issuer certificate (20)
More details here: https://curl.se/docs/sslcerts.html

curl failed to verify the legitimacy of the server and therefore could not
establish a secure connection to it. To learn more about this situation and
how to fix it, please visit the webpage mentioned above.

openssl s_client -connect crispy-caesus.eu:443:

Connecting to 192.168.42.1
CONNECTED(00000003)
depth=1 CN=Caddy Local Authority - ECC Intermediate
verify error:num=20:unable to get local issuer certificate
verify return:1
depth=0 
verify return:1
---
Certificate chain
 0 s:
   i:CN=Caddy Local Authority - ECC Intermediate
   a:PKEY: EC, (prime256v1); sigalg: ecdsa-with-SHA256
   v:NotBefore: Jan 22 16:58:34 2026 GMT; NotAfter: Jan 23 04:58:34 2026 GMT
 1 s:CN=Caddy Local Authority - ECC Intermediate
   i:CN=heinrich.crispy-caesus.eu
   a:PKEY: EC, (prime256v1); sigalg: ED25519
   v:NotBefore: Jan 21 23:20:11 2026 GMT; NotAfter: Jan 28 23:20:11 2026 GMT
---
Server certificate
-----BEGIN CERTIFICATE-----
MIIBxjCCAWugAwIBAgIRAOolbQ4Veu/Fg+mncYLpZBcwCgYIKoZIzj0EAwIwMzEx
MC8GA1UEAxMoQ2FkZHkgTG9jYWwgQXV0aG9yaXR5IC0gRUNDIEludGVybWVkaWF0
ZTAeFw0yNjAxMjIxNjU4MzRaFw0yNjAxMjMwNDU4MzRaMAAwWTATBgcqhkjOPQIB
BggqhkjOPQMBBwNCAARBQvULkoO3qT9mlFHhqJa1GIQjvPb+6k5r9yhUqVdJvbHz
pwvdIYQ99epVJfkGYdy+hWn8qRXu706QIdWoaxyQo4GSMIGPMA4GA1UdDwEB/wQE
AwIHgDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwHQYDVR0OBBYEFDVa
e1XdJOwMVJUOzW20BdspUzO0MB8GA1UdIwQYMBaAFGqhNZtfIrN+RxHjH0DysXGp
4BDPMB4GA1UdEQEB/wQUMBKCEGNyaXNweS1jYWVzdXMuZXUwCgYIKoZIzj0EAwID
SQAwRgIhAK+5mzUWM0NvJ9Ut5jGay7KWTV3Bxh9ArXJrRApHfJPAAiEA9zepCzMs
jNTjTNBFM4bfUt3rEMJ3UrPHKCrWMOAi+CA=
-----END CERTIFICATE-----
subject=
issuer=CN=Caddy Local Authority - ECC Intermediate
---
No client certificate CA names sent
Peer signing digest: SHA256
Peer signature type: ecdsa_secp256r1_sha256
Negotiated TLS1.3 group: X25519MLKEM768
---
SSL handshake has read 2335 bytes and written 1613 bytes
Verification error: unable to get local issuer certificate
---
New, TLSv1.3, Cipher is TLS_AES_128_GCM_SHA256
Protocol: TLSv1.3
Server public key is 256 bit
This TLS version forbids renegotiation.
No ALPN negotiated
Early data was not sent
Verify return code: 20 (unable to get local issuer certificate)
---
---
Post-Handshake New Session Ticket arrived:
SSL-Session:
    Protocol  : TLSv1.3
    Cipher    : TLS_AES_128_GCM_SHA256
    Session-ID: 010F84C7593DBD6A02699328C8F5A8F66F3B6CAC25872BE15DD3351D4D5DB36B
    Session-ID-ctx: 
    Resumption PSK: 4058F18FB204322405538B68787F09AA183FC8EA7F5CC9AE49D1C14BBAEFEC32
    PSK identity: None
    PSK identity hint: None
    SRP username: None
    TLS session ticket lifetime hint: 604800 (seconds)
    TLS session ticket:
    0000 - 40 95 d8 0a 3f ad 6a 49-0a a3 22 5c dc e5 cc ba   @...?.jI.."\....
    0010 - db bc a8 0d f3 0e 66 57-8d 81 dd 1e 83 c4 97 3e   ......fW.......>
    0020 - 20 7e 46 77 41 24 79 0d-2c 55 bc ce d7 47 26 0a    ~FwA$y.,U...G&.
    0030 - 9d 24 a1 f0 29 a4 20 09-86 ce 5c a0 52 42 ab 44   .$..). ...\.RB.D
    0040 - fd 07 0f 1f 2c f5 c9 4a-d4 1c 2e 8c ee bd 05 52   ....,..J.......R
    0050 - a5 ee e2 a1 47 f6 7c 43-53 86 2d 50 9d 7d 93 2d   ....G.|CS.-P.}.-
    0060 - 20 64 d2 8c 9e 8d cf 11-67                         d......g

    Start Time: 1769109210
    Timeout   : 7200 (sec)
    Verify return code: 20 (unable to get local issuer certificate)
    Extended master secret: no
    Max Early Data: 0
---

3. Caddy version:

v2.10.0

4. How I installed and ran Caddy:

a. System environment:

OS: Alpine Linux v3.22
Arch: aarch64
Device: Raspberry Pi 5 Model B Rev 1.1
Repositories:

http://ftp.halifax.rwth-aachen.de/alpine/v3.22/main
http://ftp.halifax.rwth-aachen.de/alpine/v3.22/community

b. Command:

doas service caddy restart

c. Service/unit/compose file:

openrc-script (official from the package)

#!/sbin/openrc-run
supervisor=supervise-daemon

name="Caddy web server"
description="Fast, multi-platform web server with automatic HTTPS"
description_checkconfig="Check configuration"
description_reload="Reload configuration without downtime"

: ${caddy_opts:="--config /etc/caddy/Caddyfile --adapter caddyfile"}

command=/usr/sbin/caddy
command_args="run $caddy_opts"
command_user=caddy:caddy
extra_commands="checkconfig"
extra_started_commands="reload"
capabilities="^cap_net_bind_service"

depend() {
	need net localmount
	after firewall
}

checkconfig() {
	ebegin "Checking configuration for $name"
	su ${command_user%:*} -s /bin/sh -c "$command validate $caddy_opts"
	eend $?
}

reload() {
	ebegin "Reloading $name"
	su ${command_user%:*} -s /bin/sh -c "$command reload --force $caddy_opts"
	eend $?
}

stop_pre() {
	if [ "$RC_CMD" = restart ]; then
		checkconfig || return $?
	fi
}

(the conf.d/caddy doesn’t exist)

d. My complete Caddy config:

{
	pki {
		ca local {
			intermediate_cn "Caddy Intermediate heinrich.crispy-caesus.eu"
			root {
				cert /var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt
				key /var/lib/caddy/.local/share/caddy/pki/authorities/local/root.key
			}
		}
	}
}


grafana.crispy-caesus.eu {
        reverse_proxy otto:3000
	tls internal
	log
}

navidrome.crispy-caesus.eu {
        reverse_proxy horst:4533
	tls internal
	log
}

lastcloud.crispy-caesus.eu {
        reverse_proxy sabine:80
	tls internal 
	log
}

synapse.crispy-caesus.eu {
        reverse_proxy /_matrix/* eren:8008
        reverse_proxy /_synapse/client/* eren:8008

	tls internal 
	log
}

immich.crispy-caesus.eu, photos.crispy-caesus.eu {
	reverse_proxy agathe:2283
	tls internal 
	log
}

proxmox.crispy-caesus.eu {
	reverse_proxy pve:8006
	tls internal 
	log
}

prometheus.crispy-caesus.eu {
	reverse_proxy otto:9090
	tls internal 
	log
}

crispy-caesus.eu {
        reverse_proxy frank:8080

        header /.well-known/matrix/* Content-Type application/json
        header /.well-known/matrix/* Access-Control-Allow-Origin *
        respond /.well-known/matrix/server `{"m.server": "synapse.crispy-caesus.eu:443"}`
        respond /.well-known/matrix/client `{"m.homeserver":{"base_url":"https://synapse.crispy-caesus.eu"}}`

	tls internal
	log
}

Thanks for taking the time to read through this

Usually sending the root cert is just wasted bytes on the wire, since the relying party (the client) needs to already trust the root. A server sending its own root is almost always nonsensical, since the client should only be trusting roots in its trust store, not whatever the server sends it.

Yes the actual root (created and existing on another system) is on the host’s trust store. However the file caddy sees as the root cert is actually just an intermediate of that actual root cert.

My idea was that the actual root would not be on the hosting server, in case it got compromised.

Ah. So maybe it is related to this issue?

If so, it is soon to be released. You can try the current beta.

Hmmm, perhaps it could be constructed to serve it correctly. Though as far as I understand, I would have to then always create my own certificates for all subdomains.

My idea was to only give Caddy one intermediate cert, and for it to then automatically generate everything for me from it.

This topic was automatically closed 30 days after the last reply. New replies are no longer allowed.