Skip to main content

    How to Install an SSL Certificate on Caddy

    Caddy's tls directive loads your certificate and drops those names from certificate automation, but the HTTPS redirect stays on. Where each file goes.

    MS
    My-SSL Team
    ·
    14 min read
    ·
    Published September 6, 2026
    ·
    Last updated September 6, 2026

    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.

    The two effects of Caddy's tls directive when it is given a certificate and key fileA diagram beginning with one line of Caddyfile configuration: tls followed by a certificate path and a key path, inside a site block for example.com. Two arrows lead from it to two panels. The left panel is the effect people expect: Caddy loads the pair and presents that certificate for the names it covers, selecting it per connection by the server name in the handshake. The right panel, highlighted in gold, is the effect people miss: those names are dropped from Caddy's certificate automation, so no ACME account is created, no challenge is attempted and no renewal is scheduled for them. A footer panel lists what is not affected and stays switched on regardless: the redirect from HTTP on port 80 to HTTPS on port 443, the listener on port 443, HTTP/2 and HTTP/3, and Caddy's secure TLS defaults. It closes with the rule the diagram exists to make visible: supplying a certificate turns off certificate management for those names and nothing else.One line. Two effects. Only one of them is obvious.example.com {tls /etc/caddy/certs/example.com.pem …keyEFFECT 1 — THE ONE YOU CAME FORCaddy loads the certificate and keyand presents them for the namesin the certificate's SAN list.Chosen per connection by the servername the client sends (SNI).EFFECT 2 — THE ONE NOBODY READSThose names leave certificateautomation.No ACME account. No challenge.No renewal scheduled. Ever.Renewal is yours now — see below.Untouched, and still running: everything else Caddy does for you:80 → :443 redirectlistener on :443HTTP/2 and HTTP/3secure TLS defaultsSupplying a certificate switches off certificate management for those names. It does notswitch off HTTPS, and it does not put the site back on plain HTTP.
    Nearly every confused Caddy thread starts here: the reader expected a directive that adds a certificate, and got one that also cancels a subscription. Both halves are intended.

    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.

    Decision tree: whether Caddy will try to obtain a certificate for a given hostnameA decision tree that follows one hostname taken from a site address in the Caddyfile. The first decision asks whether the site address is prefixed with http, or is listening only on the HTTP port. If it is, the path ends with no certificate obtained, because the site was never a candidate for HTTPS. Otherwise a second decision asks whether a manually loaded certificate already covers this name. If it does, the path ends in the gold-highlighted outcome: Caddy skips certificate management for that name and serves the certificate you supplied, which is the situation this guide creates on purpose. A side note records the escape hatch, the global option auto_https ignore_loaded_certs, which makes Caddy automate certificates even for names that appear on manually loaded certificates. If no loaded certificate covers the name, the path ends with Caddy obtaining and renewing a certificate through an ACME certificate authority as usual. A footer explains that the three other global values behave differently: auto_https off disables both certificate automation and the HTTP to HTTPS redirects, disable_certs disables only certificate automation, and disable_redirects disables only the redirects.Which hostnames does Caddy still try to get a certificate for?A hostname in a site addressexample.com {}Prefixed http:// or bound onlyto the HTTP port?YESNo certificate at allplain HTTP by requestNODoes a manually loaded certificatealready cover this name?YESManagement skippedYour certificate is served.This guide's outcome.NOCaddy manages itACME issue and renewthe default behaviourOverride, if you want bothauto_https ignore_loaded_certsautomates names that are already loadedThe blunter switches live in the global options block: auto_https off stops certificates and redirects,disable_certs stops only certificates, and disable_redirects stops only the redirect.
    Reading this tree once is usually enough to stop guessing at global options. In the ordinary case you never need one — the certificate you loaded has already answered the question.

    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.

    How the files a certificate authority sends become the single PEM file Caddy readsThree stages read left to right. The first stage is what arrives from the certificate authority: a leaf certificate for your domain, one or more intermediate certificates, sometimes bundled as a separate chain file, and the private key you generated with the certificate signing request. The second stage, highlighted in gold, is the one file you build: the leaf certificate first, then each intermediate in issuing order, concatenated into a single PEM file, with the root left out because clients already hold it. The private key stays in its own separate file. The third stage is what Caddy does with that file: it passes the bytes to Go's X509KeyPair, which appends every certificate block it finds to the chain, leaf first, and that chain is what the server sends to clients during the handshake. A footer states the consequence: Caddy has no separate option for the chain, so whatever is missing from the certificate file is missing from the handshake, which is why a site can look correct in one desktop browser and fail on a phone or in a monitoring check.There is no chain setting. The chain is whatever is inside the file.WHAT THE CA SENDSexample.com.crtyour leaf certificateintermediate.crtone or more, or a bundleexample.com.keyfrom your CSR — never sentA root may also arrive.It does not go in the file.THE FILE YOU BUILD-----BEGIN CERTIFICATE----1. your leaf-----BEGIN CERTIFICATE----2. intermediate(s)example.com.pemOrder matters: leaf first.Root omitted deliberately.Key stays a separate file.WHAT CADDY DOEStls.X509KeyPair(certBytes, keyBytes)the whole file, as bytesEvery CERTIFICATE blockjoins the chain, leaf first.That chain is what is served.Two blocks in, two blocks out.Anything missing from the file is missing from the handshake.This is the failure that costs the most time, because it is invisible where you test it. Adesktop browser that already cached the intermediate from another site fills the gap andshows a clean padlock, while curl, phones and uptime checks reject the same server.
    Concatenation is not a convention here, it is the mechanism. Once you know the file goes to a key-pair parser untouched, the missing chain error stops being mysterious.

    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 md5

    Those 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-pager

    caddy 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.

    BehaviourAfter tls cert keyHow to change it
    ACME issuance and renewalOff, for the names your certificate coversauto_https ignore_loaded_certs to keep it on
    HTTP to HTTPS redirect on port 80Still onauto_https disable_redirects
    Listener on port 443Still onPrefix the site address with http://
    TLS versions, ciphers, ALPNCaddy defaults, unchangedSub-directives inside a tls block
    Certificate storage in /var/lib/caddyUntouched; your files are read from where they areNothing 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 --force

    Renewal: 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.

    Why an unforced caddy reload leaves the old certificate in place after a renewalTwo lanes compare what happens after a renewed certificate is written over the existing PEM files. Both lanes start from the same event: the renewal replaces the certificate file on disk while the Caddyfile itself is unchanged, because nothing in the configuration text refers to the certificate contents. The upper lane runs caddy reload with a config flag but no force flag. Caddy compares the new configuration to the running one, finds it identical, skips the reload as an optimisation, and exits with a success code, so the process keeps serving the certificate it loaded at start-up and the automation reports success. The lower lane, highlighted in gold, runs sudo systemctl reload caddy, whose ExecReload line in the official unit file adds the force flag. The reload runs even though the configuration text is identical, the certificate files are read from disk again, and the renewed certificate is served. A footer notes that sending the SIGUSR1 signal has the same forcing effect, and that this is the reason a renewal hook should call the service manager rather than a bare caddy reload.The renewal wrote a new file. Whether Caddy reads it depends on one flag.Renewal completesnew bytes in example.com.pemCaddyfile: unchangedit only holds a pathcaddy reload --config /etc/caddy/CaddyfileConfig text is byte-identical → reload skipped as an optimisationCommand exits 0. Logs look normal. Your script reports success.Still serving the certificate loaded at start-up.sudo systemctl reload caddyExecReload in the packaged unit adds --forceReload runs anyway → certificate and key are read from disk againRenewed certificate is served, with no dropped connections.A SIGUSR1 signal forces a reload the same way. The practical rule for a renewal hook:call the service manager, or pass --force yourself. A bare reload can succeed and change nothing.
    The dangerous part is the exit code. A silent no-op that returns success is the kind of bug that only shows up as an expiry outage months later.

    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 -dates

    Step 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 subjectAltName

    If 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 seeWhat 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 editThe 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 keyThe service runs as the caddy user, not as root. Group-read for caddy on the key file fixes it.
    Browser fine, curl and phones failThe 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 logsSome hostname in a site address is not covered by any certificate you loaded, so automation still owns it.
    Old certificate served after a renewalThe reload was skipped because the config text was unchanged. Use systemctl reload caddy or add --force.
    Port 80 still redirecting when you wanted it silentWorking 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.

    FAQ

    Frequently Asked Questions

    Get instant answers to common questions about SSL certificates and our services.

    Still Have Questions?

    Our SSL experts are available 24/7 to help with any questions about certificates, installation, or technical issues.