Caddy TLS Configuration: Automatic HTTPS and Custom Certificate Management

Caddy is the only major web server that enables HTTPS by default. When you give it a domain name, it automatically obtains and renews a Let’s Encrypt certificate without any additional configuration. This makes it operationally simple, but there are important configuration options for production deployments: wildcard certificates, custom CA integration, internal hostnames, and multi-server deployments that need centralized certificate storage.

How Caddy handles TLS automatically

When Caddy sees a site address with a domain name (like example.com), it:

  1. Starts an HTTPS listener on port 443.
  2. Starts an HTTP listener on port 80 that redirects to HTTPS.
  3. Requests a certificate from Let’s Encrypt using the ACME HTTP-01 or TLS-ALPN-01 challenge.
  4. Stores the certificate in its data directory.
  5. Renews the certificate automatically before it expires.

No configuration is required for this to work — just provide a domain name.

Basic Caddyfile for a web application

example.com {
    reverse_proxy localhost:8080
}

That is the complete configuration for an HTTPS reverse proxy. Caddy handles certificate issuance, renewal, HTTP-to-HTTPS redirect, and HTTP/2 automatically.

Multiple domains with different backends

api.example.com {
    reverse_proxy localhost:3000
}

app.example.com {
    reverse_proxy localhost:8080
}

static.example.com {
    root * /var/www/static
    file_server
}

Each domain gets its own certificate automatically.

Wildcard certificates with DNS challenge

Wildcard certificates (*.example.com) require the DNS-01 challenge. Install the DNS provider plugin for Caddy:

# Install Caddy with the Cloudflare plugin
# Using xcaddy to build a custom Caddy binary:
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest
xcaddy build --with github.com/caddy-dns/cloudflare

# Or install from the official package with DNS plugins

Configure the Caddyfile:

*.example.com {
    tls {
        dns cloudflare {env.CF_API_TOKEN}
    }

    @api host api.example.com
    handle @api {
        reverse_proxy localhost:3000
    }

    @app host app.example.com
    handle @app {
        reverse_proxy localhost:8080
    }

    handle {
        respond "Unknown subdomain" 404
    }
}

Set the Cloudflare API token:

export CF_API_TOKEN=your-cloudflare-api-token

Or in a systemd service:

[Service]
Environment=CF_API_TOKEN=your-cloudflare-api-token

Using a custom certificate (internal CA or commercial)

When you have your own certificate from an internal CA or commercial provider:

example.com {
    tls /etc/caddy/certs/example.com.crt /etc/caddy/certs/example.com.key
    reverse_proxy localhost:8080
}

The certificate file should contain the full chain (leaf + intermediates). The key file should be the matching private key.

Caddy does not automatically renew custom certificates — you are responsible for updating the files and reloading Caddy.

Using Caddy’s ZeroSSL integration

Caddy supports ZeroSSL as an alternative to Let’s Encrypt:

{
    acme_ca https://acme.zerossl.com/v2/DV90
    acme_eab {
        key_id     your-eab-kid
        mac_key    your-eab-hmac-key
    }
    email admin@example.com
}

example.com {
    reverse_proxy localhost:8080
}

Configuring Caddy for internal/private domains

For internal domains that are not publicly reachable, Caddy can act as its own CA:

{
    # Use Caddy's internal CA for development
    local_certs
}

internal.example.local {
    reverse_proxy localhost:8080
}

With local_certs, Caddy issues certificates signed by a locally generated CA. Install the CA root in browsers/devices:

# Find the Caddy root CA
ls $(caddy environ | grep XDG_DATA_HOME | cut -d= -f2)/caddy/pki/authorities/local/
# Files: root.crt root.key

# Install in the system trust store (Debian/Ubuntu)
sudo cp root.crt /usr/local/share/ca-certificates/caddy-local-ca.crt
sudo update-ca-certificates

Setting minimum TLS version and cipher suites

example.com {
    tls {
        protocols tls1.2 tls1.3
        ciphers TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256 TLS_AES_128_GCM_SHA256 TLS_AES_256_GCM_SHA384
    }
    reverse_proxy localhost:8080
}

Note: Caddy’s defaults (TLS 1.2+ with modern ciphers) already pass most security scanner requirements. Only restrict cipher suites if your compliance policy explicitly requires it — Caddy’s defaults are good.

Adding security headers

example.com {
    header {
        Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
        X-Frame-Options "SAMEORIGIN"
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
        -Server  # Remove Server header
    }
    reverse_proxy localhost:8080
}

HTTPS with mutual TLS (client certificate authentication)

api.example.com {
    tls {
        client_auth {
            mode require_and_verify
            trusted_ca_cert_file /etc/caddy/certs/client-ca.crt
        }
    }
    reverse_proxy localhost:3000
}

Clients must present a certificate signed by the CA in client-ca.crt. Connections without a valid certificate are rejected at the TLS level.

Centralizing ACME storage for clustered deployments

When running multiple Caddy instances behind a load balancer, all instances need access to the same TLS certificates. Use a centralized storage backend:

Redis-based certificate storage

Install the Redis storage module:

xcaddy build --with github.com/gamalan/caddy-tlsredis
{
    storage redis {
        host     redis.example.com
        port     6380
        password "redis-password"
        db       0
        tls      true
        tls_insecure false
    }
}

example.com {
    reverse_proxy localhost:8080
}

Shared filesystem (NFS/EFS)

{
    storage file_system {
        root /mnt/shared/caddy
    }
}

Whichever instance in the cluster performs the ACME challenge writes the certificate to shared storage. All other instances read from the same location.

Caddy JSON configuration (alternative to Caddyfile)

For programmatic configuration or CI/CD deployments:

{
  "apps": {
    "tls": {
      "automation": {
        "policies": [
          {
            "subjects": ["example.com"],
            "issuers": [
              {
                "module": "acme",
                "email": "admin@example.com",
                "ca": "https://acme-v02.api.letsencrypt.org/directory"
              }
            ]
          }
        ]
      }
    },
    "http": {
      "servers": {
        "myserver": {
          "listen": [":443"],
          "routes": [
            {
              "match": [{"host": ["example.com"]}],
              "handle": [
                {
                  "handler": "reverse_proxy",
                  "upstreams": [{"dial": "localhost:8080"}]
                }
              ]
            }
          ],
          "tls_connection_policies": [{}]
        }
      }
    }
  }
}

Apply the JSON config via the API:

curl -X POST -H "Content-Type: application/json" \
  http://localhost:2019/load \
  -d @caddy.json

Reloading configuration without restart

Caddy supports hot configuration reload:

# Via systemd
sudo systemctl reload caddy

# Or via the admin API
caddy reload --config /etc/caddy/Caddyfile

When certificates in custom tls blocks are renewed externally, update the files and reload Caddy. Let’s Encrypt certificates managed by Caddy never need manual reload — Caddy handles renewal internally.

Let’s Encrypt staging for testing

Use the staging server to avoid rate limits during testing:

{
    acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}

example.com {
    reverse_proxy localhost:8080
}

Staging certificates are not trusted by browsers. Switch to the production URL when you are ready.

Troubleshooting

ProblemCauseFix
Obtaining certificate... stuckPort 80 blocked or domain DNS not pointing to serverCheck firewall; verify DNS; use DNS challenge
no certificate available for 'example.com'Domain name mismatch or certificate not yet issuedCheck Caddy logs with journalctl -u caddy -n 100
Rate limit hitToo many certificate requestsUse staging server; wait for rate limit window
Custom cert not updating after renewalCaddy not reloaded after file updateRun systemctl reload caddy in renewal hook
bind: permission denied on port 443Caddy running without cap_net_bind_serviceOn Linux: setcap cap_net_bind_service=+ep /usr/bin/caddy
Cluster instances getting different certsNo shared storage configuredUse Redis or NFS storage module

Certificate renewal hook for custom certificates

sudo nano /etc/letsencrypt/renewal-hooks/deploy/caddy.sh
#!/bin/bash
DOMAIN=example.com
CADDY_CERT_DIR=/etc/caddy/certs

cp /etc/letsencrypt/live/${DOMAIN}/fullchain.pem ${CADDY_CERT_DIR}/${DOMAIN}.crt
cp /etc/letsencrypt/live/${DOMAIN}/privkey.pem   ${CADDY_CERT_DIR}/${DOMAIN}.key
chown caddy:caddy ${CADDY_CERT_DIR}/${DOMAIN}.crt ${CADDY_CERT_DIR}/${DOMAIN}.key
chmod 640 ${CADDY_CERT_DIR}/${DOMAIN}.key

systemctl reload caddy
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/caddy.sh

Note: This hook is only needed for custom certificates managed outside Caddy. Certificates obtained by Caddy through ACME do not need a renewal hook.

Summary

Caddy’s automatic HTTPS requires no TLS configuration for public domains — add a domain name to the Caddyfile and Caddy handles everything. For wildcard certificates, add the DNS challenge provider and set the API credentials. For internal domains, use local_certs to have Caddy act as a private CA. For custom certificates from an external CA, provide the tls cert key paths and reload Caddy after renewal. For clustered deployments, configure shared certificate storage via Redis or a shared filesystem.

Scroll to Top