The short answer
Caddy takes a purchased certificate through one line: tls /path/cert.pem /path/key.pem inside the site block. The documentation calls these the paths to the certificate and private key PEM files and warns that specifying just one is invalid. Two things follow that most guides skip. The certificate file must hold your leaf and every intermediate, appended below it, because Caddy passes the file straight to Go's key-pair parser and serves whatever chain it finds — there is no separate chain option. And that same line quietly removes those hostnames from Caddy's certificate automation, so no ACME renewal will ever run for them again, while the HTTP-to-HTTPS redirect and the listener on port 443 stay exactly where they were.
On this page
- The tls directive does two jobs, and the second one is the surprise
- What the CA sends, and the one file you have to build
- Installing the certificate, step by step
- What stays switched on after you supply your own certificate
- Caddy in Docker, and the paths that break
- Renewal: why caddy reload can quietly do nothing
- Proving it from outside Caddy
- Symptoms, and what each one actually means
- FAQ
The tls directive does two jobs, and the second one is the surprise
Caddy is the only mainstream web server where installing a certificate is partly an act of subtraction. Everywhere else you add a certificate to a server that had none. Here the server already had one — it went and got it from Let's Encrypt while you were reading the documentation — so your certificate has to displace something, and the tls directive is what does the displacing. Understanding that is worth more than any command in this guide, because it explains almost every unexpected behaviour that follows.
Caddy calls its default behaviour Automatic HTTPS, and the documentation is precise about what the phrase covers. When it is active, two things happen: certificates are obtained and renewed for all qualifying domain names, and HTTP on port 80 is redirected to HTTPS on port 443. Those are separate features that happen to share a name. The list of situations that stop Automatic HTTPS, either in whole or in part, includes manually loading certificates — which is exactly what the tls directive with two file paths does.
The half that stops is certificate management, and only for the names your certificate covers. Caddy checks whether it already holds a certificate for a hostname before adding that hostname to its automation list, and skips the ones that are already covered. The half that does not stop is the redirect. Those routes are built from the hostnames in your site blocks without reference to where each certificate came from, which is why people who load their own certificate and then wonder why port 80 is still answering are looking at working software.
The four global switches, and why you probably need none
{
# auto_https off # no certificates AND no redirects
# auto_https disable_certs # no certificate automation, redirects stay
# auto_https disable_redirects # redirects go, automation stays
# auto_https ignore_loaded_certs # automate even names you loaded yourself
}Every one of these is a global option, not a per-site one. Reach for them only when the default is genuinely wrong for the whole instance, because a single tls line has already stopped automation for the names it covers.
That last value is the interesting one in reverse. If you ever want Caddy to keep managing a certificate for a name you have also loaded by hand — a staging cutover, say, where you want the automated one waiting in the wings — auto_https ignore_loaded_certs is the documented way to say so. Without it, loading a certificate is a commitment: that name is yours to renew now.
What the CA sends, and the one file you have to build
A certificate order gives you three things: your leaf certificate, one or more intermediate certificates that link it back to a trusted root, and the private key you generated locally when you made the certificate signing request. Caddy wants two files, not three, and the mismatch is where the work is. The key stays on its own. The leaf and the intermediates have to be concatenated into a single PEM file, leaf first, with the root left out because every client already holds it.
The reason this is a hard requirement rather than a stylistic preference is worth knowing, because it also tells you what to expect when you get it wrong. Caddy reads both files off disk and hands the bytes to Go's X509KeyPair, which walks the PEM blocks and appends every certificate it finds to the chain it will present, leaf first. Go's own documentation spells out the intent: the certificate file may contain intermediate certificates following the leaf certificate to form a certificate chain. There is no chain field in the Caddyfile to forget, because the concatenation is the field. Whatever is missing from that file is missing from the handshake.
Certificates from any publicly trusted CA arrive in this shape, whether you buy a DV, OV or EV SSL certificate or pull one from an ACME client by hand — the file names differ, the PEM blocks do not. If your CA sent a single bundle file that already contains the leaf followed by the intermediates, you can point Caddy at it as-is and skip the concatenation step. Open it first and confirm the order: some CAs ship the chain in reverse, and a bundle that starts with the intermediate will be rejected because the key does not match the first certificate in the file.
Build the two files, then check them before Caddy does
sudo mkdir -p /etc/caddy/certs
# Leaf first, then the intermediate(s). No root.
cat example.com.crt intermediate.crt | sudo tee /etc/caddy/certs/example.com.pem > /dev/null
sudo cp example.com.key /etc/caddy/certs/example.com.key
# The Caddy process runs as the caddy user and must be able to read both.
sudo chown root:caddy /etc/caddy/certs/example.com.pem /etc/caddy/certs/example.com.key
sudo chmod 640 /etc/caddy/certs/example.com.key
sudo chmod 644 /etc/caddy/certs/example.com.pem
# Two certificates in, two out — this should print 2, not 1.
grep -c "BEGIN CERTIFICATE" /etc/caddy/certs/example.com.pem
# The key must belong to the leaf: these two hashes must match.
openssl x509 -noout -modulus -in /etc/caddy/certs/example.com.pem | openssl md5
openssl rsa -noout -modulus -in /etc/caddy/certs/example.com.key | openssl md5Those last two commands take ten seconds and remove the single most common cause of a failed start-up. If the moduli differ, the key does not belong to the certificate — usually because a reissue produced a new key that never made it onto the server, or because two orders got mixed up in a downloads folder. Caddy will refuse the pair rather than serve something broken, but it will do so at load time, which is a worse moment to find out than now. For an ECDSA key, swap openssl rsa for openssl ec.
Installing the certificate, step by step
With the two files in place the Caddyfile change is one line per site. Open /etc/caddy/Caddyfile, put a tls directive inside the site block, validate, then reload. The whole edit is short enough that the risk lies entirely in the details around it: the paths must resolve for the caddy user, and the site address must contain the hostnames your certificate actually covers, because Caddy matches connections by the server name the client sends.
/etc/caddy/Caddyfile
example.com, www.example.com {
tls /etc/caddy/certs/example.com.pem /etc/caddy/certs/example.com.key
reverse_proxy localhost:8080
# or: root * /var/www/example.com
# file_server
}
# A second site with its own certificate is just another block.
api.example.com {
tls /etc/caddy/certs/api.example.com.pem /etc/caddy/certs/api.example.com.key
reverse_proxy localhost:9000
}Both names sit in one site address here, separated by a comma, because one certificate covers both. That is the arrangement Caddy expects, and the documentation states the requirement from the other direction: the certificate should have subject alternative names that match the site address. A name in the address that is not in the certificate is the setup that fails later, under a specific hostname, in a way that looks intermittent from the outside.
Validate, then reload — never restart
# Formats the file in place; catches brace and indentation mistakes.
sudo caddy fmt --overwrite /etc/caddy/Caddyfile
# Loads and provisions every module without starting the config.
# A bad certificate path or an unreadable key fails here, not in production.
sudo caddy validate --config /etc/caddy/Caddyfile
# Applies the change with no dropped connections.
sudo systemctl reload caddy
# Confirm the process is healthy and see what it loaded.
sudo systemctl status caddy
sudo journalctl -u caddy --since "2 minutes ago" --no-pagercaddy validate earns its place in that sequence. The documentation describes it as deserialising the config and then loading and provisioning all of its modules as if to start the config, without actually starting it — which means a certificate file the caddy user cannot read, or a key that does not match its certificate, surfaces as an error in your terminal instead of a failed reload on a live site. The reload itself is the documented way to change a running configuration, and the Caddy documentation is direct about the alternative: do not stop the service to change the configuration, because stopping the server incurs downtime.
What stays switched on after you supply your own certificate
Loading a certificate changes one thing and leaves the rest of Caddy's behaviour intact. Port 443 still gets a listener. HTTP on port 80 still redirects to it. HTTP/2 and HTTP/3 still negotiate normally, and the TLS settings underneath — protocol versions, cipher suites, curve preferences — stay at Caddy's defaults, which the documentation asks you not to change without a specific reason. Nothing about bringing your own certificate downgrades the connection.
This trips people who read that manually loading certificates prevents Automatic HTTPS and reasonably conclude the site has dropped back to plain HTTP. It has not. The phrase in the documentation covers a feature set, and the list of things that prevent it says so carefully — those conditions apply either in whole or in part. Loading a certificate is a partial case: the certificate half stops for the names you covered, the redirect half continues for every hostname in the site block. If the redirect is genuinely unwanted, auto_https disable_redirects removes it globally and nothing else.
| Behaviour | After tls cert key | How to change it |
|---|---|---|
| ACME issuance and renewal | Off, for the names your certificate covers | auto_https ignore_loaded_certs to keep it on |
| HTTP to HTTPS redirect on port 80 | Still on | auto_https disable_redirects |
| Listener on port 443 | Still on | Prefix the site address with http:// |
| TLS versions, ciphers, ALPN | Caddy defaults, unchanged | Sub-directives inside a tls block |
Certificate storage in /var/lib/caddy | Untouched; your files are read from where they are | Nothing to do — Caddy does not copy them |
The last row matters for backups. Caddy keeps the certificates it manages under its data directory, which the documentation places at /var/lib/caddy/.local/share/caddy for the packaged service. Your files are not copied there. They live wherever you put them, which means your backup and your renewal tooling both need to know about a second location that Caddy itself will never manage.
Caddy in Docker, and the paths that break
In a container the directive is identical and the mistakes are all about paths and identity. The tls line names a path inside the container, so both files have to be mounted there, and the process inside the container has to be able to read them. A certificate that sits at /etc/caddy/certs/example.com.pem on the host and is not mounted is simply absent as far as Caddy is concerned, and the failure reads as a certificate problem rather than a volume problem.
compose.yaml — mount the Caddyfile and the certificates
services:
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./certs:/etc/caddy/certs:ro
- caddy_data:/data # keep this even with your own certificate
- caddy_config:/config
volumes:
caddy_data:
caddy_config:Keep the caddy_data volume even though your certificate is not managed. Caddy still writes state there, and a container that loses it on every recreate will repeat work it had already done. Port 80 also stays published: the redirect Caddy inserts for your hostnames needs somewhere to listen, and dropping the mapping produces a site that works only for people who type the scheme by hand.
Reloading is the one command that differs. There is no systemd unit inside the container, so the flag the packaged service adds for you has to be passed explicitly — and, as the next section explains, it is not optional after a renewal:
docker compose exec caddy \
caddy reload --config /etc/caddy/Caddyfile --forceRenewal: why caddy reload can quietly do nothing
Renewal is now entirely yours, and it has a trap in it that returns exit code zero. Caddy reads the certificate and key when it loads a configuration, so replacing the bytes on disk changes nothing on its own. That much is expected. What is not expected is that caddy reload compares the new configuration against the running one and skips the reload when they are identical — and after a renewal that overwrites the PEM files in place, the Caddyfile text is identical, because it only ever held a path.
The --force flag exists for exactly this case, and the packaged systemd unit already uses it: its ExecReload line runs caddy reload --config /etc/caddy/Caddyfile --force. So sudo systemctl reload caddy is safe, and a renewal hook that calls caddy reload directly, without the flag, is the version that fails silently. Sending SIGUSR1 to the process has the same forcing effect, which is useful in environments where you have a PID and no service manager.
A renewal that actually lands
#!/bin/sh
set -e
CERT=/etc/caddy/certs/example.com.pem
KEY=/etc/caddy/certs/example.com.key
# 1. Build the new pair beside the live one.
cat new-example.com.crt new-intermediate.crt > "$CERT.new"
cp new-example.com.key "$KEY.new"
# 2. Prove the pair matches before anything is swapped.
[ "$(openssl x509 -noout -modulus -in "$CERT.new" | openssl md5)" \
= "$(openssl rsa -noout -modulus -in "$KEY.new" | openssl md5)" ]
# 3. Swap atomically, keeping ownership and mode.
install -o root -g caddy -m 644 "$CERT.new" "$CERT"
install -o root -g caddy -m 640 "$KEY.new" "$KEY"
# 4. Force the reload. Without --force this can succeed and change nothing.
systemctl reload caddy
# 5. Verify the served certificate, not the file on disk.
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -datesStep 5 is the part people skip and the only one that proves anything. Checking the file on disk tells you the renewal ran; checking the handshake tells you Caddy is serving it. Those two statements come apart precisely when the reload was a no-op, which is the failure this whole section exists to prevent.
Frequency is about to stop being a matter of taste. Under CA/ Browser Forum ballot SC-081v3 the maximum lifetime of a public TLS certificate drops to 200 days from March 15, 2026, to 100 days from March 15, 2027, and to 47 days from March 15, 2029. A manual replacement three or four times a year is unpleasant; the same job every six weeks is a scheduled outage waiting for a holiday. If the sites behind one Caddy instance all sit under a single domain, a wildcard certificate collapses that work to one file pair and one date to remember — and if the sites are unrelated, this is the point at which letting Caddy manage the ordinary ones through ACME, and reserving your own files for the sites that need an organisation vetted certificate, becomes the smaller amount of work overall.
Proving it from outside Caddy
Verification means asking the server what it presents, not asking the filesystem what it holds. Two checks cover almost everything: one that confirms the chain reaches a trusted root, and one that confirms the right certificate answers for each hostname. Run both from a machine that is not the server, so a locally installed root cannot flatter the result.
Three commands, from somewhere else
# 1. The full chain as served. "Verify return code: 0 (ok)" is the line to find,
# and the certificate list should show your leaf followed by the intermediate.
echo | openssl s_client -connect example.com:443 -servername example.com -showcerts
# 2. A client with no cached intermediates. Success here means the chain is complete.
curl -vI https://example.com
# 3. Each hostname separately — a shared certificate can be right for one and wrong
# for the other, and browsers hide this by reusing connections.
echo | openssl s_client -connect example.com:443 -servername www.example.com 2>/dev/null \
| openssl x509 -noout -subject -ext subjectAltNameIf the first command reports a verify error about a local issuer while the browser looks fine, the intermediate is missing from your PEM file. That is the chain problem from earlier in one sentence, and it is worth confirming from a third location too — our free SSL checker reads the chain the way an outside client sees it, which is a useful second opinion when a laptop has been used to test the same server all week.
Symptoms, and what each one actually means
Caddy fails loudly, which is a mercy. Where some proxies substitute a self-signed certificate and leave you to work out why the padlock is wrong, Caddy declines the handshake and says so in the log. The table below maps the messages you are likely to meet onto their causes.
| What you see | What it means |
|---|---|
no certificate available for 'host' | Nothing loaded has that name as a SAN, or the client connected by IP and sent no server name at all. Check the certificate's SAN list against the site address. |
| Caddy refuses to start after the edit | The pair does not match, the key is encrypted, or the path is wrong. Run caddy validate and read the error — it names the file. |
permission denied on the key | The service runs as the caddy user, not as root. Group-read for caddy on the key file fixes it. |
| Browser fine, curl and phones fail | The intermediate is not in the certificate file. The browser cached it from another site; nothing else did. |
| Caddy still contacts an ACME CA in the logs | Some hostname in a site address is not covered by any certificate you loaded, so automation still owns it. |
| Old certificate served after a renewal | The reload was skipped because the config text was unchanged. Use systemctl reload caddy or add --force. |
| Port 80 still redirecting when you wanted it silent | Working as designed. The redirect is not part of what a loaded certificate switches off. |
The row that deserves a second look is the fifth. A stray ACME attempt after you thought you had turned automation off is almost never Caddy ignoring your certificate — it is a hostname you forgot about, often a bare domain next to a www that the certificate never covered, or a site block for something internal that was fine while automation was doing the work. Reading the log line for the name it names usually ends the investigation in under a minute.