Caddy, nginx and Apache httpd can all serve HTTPS. In their current releases, all three can also obtain their own certificates over ACME, the protocol for requesting and renewing certificates automatically. The three clients are packaged differently. Caddy's is compiled into the binary and runs by default. nginx's is a dynamic module, developed in its own repository and loaded with a directive. Apache's is mod_md, part of the server since 2.4.30 and off until loaded.
The versions are Caddy v2.11.4, nginx 1.31.3 mainline and Apache httpd 2.4.68, on the official caddy:alpine, nginx:alpine and httpd:alpine images. In these images nginx and Apache use OpenSSL 3.5.7 as their TLS library; Caddy is a Go program and does not use OpenSSL.
With no TLS configuration
With no TLS directives at all, Caddy serves HTTPS and the other two do not.
Caddy turns HTTPS on by itself. When the configuration names a hostname or IP address, automatic HTTPS activates. Caddy obtains and renews a certificate and redirects port 80 to port 443. Where the certificate comes from depends on the name:
- A publicly resolvable hostname gets a certificate over ACME.
- An IP address,
localhost, or a name under.localhost,.local,.internalor.home.arpagets a certificate signed by Caddy's own certificate authority, with no ACME involved. - A site address without a host, or one prefixed with
http://, is served over plain HTTP.
A Caddyfile whose whole site definition is localhost:8443 produces a working HTTPS listener at start:
{"level":"info","ts":1787137719.5661998,"logger":"http","msg":"enabling automatic TLS certificate management","domains":["localhost"]}
{"level":"info","ts":1787137719.5689738,"logger":"tls.obtain","msg":"obtaining certificate","identifier":"localhost"}
{"level":"info","ts":1787137719.5907664,"logger":"tls.obtain","msg":"certificate obtained successfully","identifier":"localhost","issuer":"local"}
The served certificate comes from Caddy Local Authority - ECC Intermediate and is valid for 12 hours.
nginx serves no TLS until it is configured. An HTTPS listener needs the ssl parameter on listen plus ssl_certificate and ssl_certificate_key; there is no implicit HTTPS. In a source build the TLS code itself is optional: ngx_http_ssl_module is compiled in only with --with-http_ssl_module, and the nginx.conf in the source archive has the whole HTTPS server block commented out. The official packages and Docker images include the module, but their configuration files define no HTTPS server either.
Apache serves no TLS by default either, and needs one thing more than nginx. mod_ssl has to be loaded, SSLEngine defaults to off, and the httpd.conf included in the source keeps Include conf/extra/httpd-ssl.conf commented out. The default configuration in the official image loads 25 modules, and mod_ssl is not among them.
Getting a certificate from a public certificate authority
All three run the ACME protocol. What differs is how much configuration a certificate requires.
For Caddy, naming the domain is the entire ACME configuration:
example.com {
root * /var/www/html
file_server
}
With this Caddyfile, Caddy requests a certificate for example.com from Let's Encrypt at startup. It uses the HTTP or the TLS-ALPN challenge, both enabled by default.
The documentation lists ZeroSSL as a second default authority, tried when issuance from Let's Encrypt fails. In v2.11.4 that second issuer depends on one setting. ZeroSSL requires credentials derived from an email address, so Caddy adds it only when the configuration provides an email, through the email global option or the tls directive. Without one, Let's Encrypt is the only default issuer.
When an attempt fails, Caddy retries once, then switches to the next enabled challenge type, then to the next issuer. After that it keeps retrying at exponentially increasing intervals, up to one day apart, for up to 30 days.
Each new certificate gets a newly generated private key unless reuse_private_keys is set. Certificates and keys are stored in the data directory: $XDG_DATA_HOME/caddy, or $HOME/.local/share/caddy on Linux (the Docker image sets XDG_DATA_HOME=/data). Instances that share this storage share certificates and coordinate certificate management as a cluster.
The nginx binary contains no ACME client. Issuance requires ngx_http_acme_module, a dynamic module distributed as the separate nginx-module-acme package since nginx 1.29.1. The official Docker image includes it as modules/ngx_http_acme_module.so, version 0.4.1, but its default nginx.conf does not load it. A working setup:
load_module modules/ngx_http_acme_module.so;
events {}
http {
resolver 127.0.0.53;
acme_issuer letsencrypt {
uri https://acme-v02.api.letsencrypt.org/directory;
contact admin@example.com;
state_path /var/cache/nginx/acme-letsencrypt;
accept_terms_of_service;
}
acme_shared_zone zone=ngx_acme_shared:1M;
server {
listen 443 ssl;
server_name example.com;
acme_certificate letsencrypt;
ssl_certificate $acme_certificate;
ssl_certificate_key $acme_certificate_key;
ssl_certificate_cache max=2;
}
server {
# answers the http-01 challenge
listen 80;
}
}
Directive by directive:
-
uri: the address of the certificate authority. There is no default, so even Let's Encrypt has to be written out. -
resolver: the module requires one. Leaving it out is a config error:acme_issuer: "resolver" is not configured. -
$acme_certificateand$acme_certificate_key: the module places the issued certificate into these variables, andssl_certificatereads them on every handshake.ssl_certificate_cache(nginx 1.27.4) keeps the parsed files cached. -
state_path: where the account key, certificates and private keys are kept, so they survive a restart. The default is a directory namedacme_. After issuance it holds the account key plus a certificate and key file for each name. -
Challenges: http-01, answered on the port-80 listener, is the default. tls-alpn-01 is the only alternative (module version 0.2.0 and later). dns-01 does not exist here;
challenge dns-01is a config error,unsupported challenge: dns-01. - Wildcards: not possible. Wildcard names are not accepted as certificate identifiers.
-
Keys:
ecdsa:256unless changed.
Apache's client is mod_md. It has been in the server since 2.4.30, its documentation still marks it Experimental, and it needs mod_watchdog loaded to run. The managed domain is declared at server level; the virtual host keeps SSLEngine on and names no certificate files:
LoadModule watchdog_module modules/mod_watchdog.so
LoadModule md_module modules/mod_md.so
LoadModule ssl_module modules/mod_ssl.so
MDomain example.com
MDContactEmail admin@example.com
MDCertificateAgreement accepted
<VirtualHost *:443>
ServerName example.com
SSLEngine on
VirtualHost>
The full file also loads the process model (mod_mpm_event), mod_unixd and mod_authz_core, and listens on ports 80 and 443. What mod_md does with this, by default:
-
Certificate authority: Let's Encrypt.
MDCertificateAuthoritychanges it. -
Challenges: mod_md looks at the ports httpd listens on. Port 80 makes http-01 possible and port 443 makes tls-alpn-01 possible; when both are available, tls-alpn-01 is tried first (the default order is tls-alpn-01, http-01, dns-01). tls-alpn-01 works only after
acme-tls/1is added to theProtocolsdirective. -
Wildcards: only through dns-01, because Let's Encrypt allows nothing else for them. dns-01 means writing an external program that mod_md calls, named by
MDChallengeDns01. Caddy has the same restriction; its DNS challenge comes as a provider plugin from the caddy-dns repositories. -
Keys: RSA 2048 (
MDPrivateKeys). -
Storage: everything about a managed domain is kept under
MDStoreDir, by default a directory namedmdunder the server root.
A new managed domain is not served immediately. Until the certificate exists, httpd answers requests for that domain with 503, under a self-signed certificate with the name Apache Managed Domain Fallback. The finished certificate is passed to mod_ssl on the next restart. Both stages appear in the error log:
[Wed Aug 19 15:27:08.178776 2026] [ssl:warn] [pid 1:tid 1] AH10085: Init: example.com:443 will respond with '503 Service Unavailable' for now. There are no SSL certificates configured and no other module contributed any.
[Wed Aug 19 15:27:14.775100 2026] [md:notice] [pid 8:tid 10] AH10059: The Managed Domain example.com has been setup and changes will be activated on next (graceful) server restart.
After apachectl -k graceful, the virtual host serves the issued certificate.
Renewal
Renewal is automatic in all three once issuance works, and the windows are similar.
-
Caddy: renews when a third of the certificate lifetime remains (
renewal_window_ratiodefault 0.3333), rescans loaded certificates every 10 minutes (renew_interval), and follows the ACME Renewal Information (ARI) extension, which lets the certificate authority suggest when to renew. - nginx: renewal is handled by the ACME module, which implements ARI since version 0.4.0. The module documents renewal timing only for certificates that are valid for less than 10 days: those are renewed halfway through their validity, so a 6-day certificate is renewed after 3 days. For anything longer there is no documented timing and no directive to set one. A renewed certificate replaces the served one without a reload.
-
Apache:
MDRenewWindowdefaults to 33% of the certificate lifetime. The check runs everyMDCheckInterval, default 12 hours with a random variation of up to half the interval, so 6 to 18 hours between runs (2.4.60 and later). ARI-triggered renewal is on by default since 2.4.66. A renewed certificate is served only after the next graceful restart, the same sequence as for the first one.
Names that cannot get a public certificate
A public certificate authority cannot validate localhost, an internal-only hostname or a private IP address. For those names the three behave differently.
Caddy issues these certificates itself. Its internal certificate authority is created on first use, under pki/authorities/local in the data directory, and tls internal selects it explicitly for any site. The generated root certificate is valid for 3600 days, the intermediate for 7 days, and the certificate for the site itself for 12 hours. Caddy also tries to install the root certificate into the system trust stores on first use, prompting for a password when it needs one. caddy trust repeats that installation as a privileged user, and caddy untrust removes it. Browsers on other machines do not trust these certificates until the root is installed there too.
nginx and Apache have no local issuer; a self-signed certificate from openssl req -x509 fills the same role. One flag matters for Apache: without a basicConstraints extension stating that the certificate is not a certificate authority, Apache warns at startup with AH01906: server certificate is a CA certificate. Adding -addext "basicConstraints=critical,CA:FALSE" to the openssl command removes the warning.
Issuance during the handshake
A certificate can also be produced later than config load. Caddy's on-demand TLS waits for the first TLS handshake that names an unknown hostname and issues then. The feature must be restricted before use. The primary restriction is the ask endpoint of the on_demand_tls global option: Caddy sends a request with ?domain= and treats a 2xx response as permission to issue. The certificate is obtained during that first handshake, and the same connection receives it.
nginx issues nothing during the handshake but can choose the certificate then: ssl_certificate accepts variables, so which existing file is served can depend on the request. Apache has no handshake-time issuance.
Protocol versions, ciphers, sessions and stapling
The documented defaults for the TLS protocol version look different for each server:
- Caddy: minimum TLS 1.2, maximum TLS 1.3.
-
nginx:
ssl_protocolsdefaults toTLSv1.2 TLSv1.3since 1.27.3. From 1.23.4 to 1.27.2 the default also allowed TLS 1.0 and 1.1. -
Apache:
SSLProtocoldefaults toall -SSLv3, which the documentation defines as TLS 1.0 through TLS 1.3.
So the documentation says Apache still allows TLS 1.0 and 1.1. The running server does not. A client limited to one protocol version at a time gets the same answers from all three:
| Version | Caddy | nginx | Apache |
|---|---|---|---|
| TLS 1.0 | refused | refused | refused |
| TLS 1.1 | refused | refused | refused |
| TLS 1.2 | negotiated | negotiated | negotiated |
| TLS 1.3 | negotiated | negotiated | negotiated |
Apache does not make this decision alone. SSLProtocol says which versions Apache would accept, but the handshake itself is done by OpenSSL, and current OpenSSL refuses SSLv3, TLS 1.0 and TLS 1.1 unless that protection is explicitly lowered. The same configuration files on older builds show it. nginx 1.18 and httpd 2.4.41, both on OpenSSL 1.1.1, accept TLS 1.0 and 1.1 with the identical configs, and httpd 2.4.68 accepts them again when the OpenSSL security level is lowered (SSLCipherSuite ALL@SECLEVEL=0). The TLS 1.2 minimum in the table comes from the library versions, not from the servers.
The cipher lists come from different places. Caddy has its own fixed list: it cannot be changed for TLS 1.3, and it leaves out some TLS 1.2 suites. nginx and Apache both take theirs from OpenSSL, nginx as ssl_ciphers HIGH:!aNULL:!MD5 and Apache as SSLCipherSuite DEFAULT, and both let the client's preference order win by default. The difference shows at TLS 1.2: nginx and Apache accept the CBC suite AES128-SHA, while Caddy refuses the CBC suites (ECDHE-ECDSA-AES128-SHA, ECDHE-ECDSA-AES256-SHA) and accepts the GCM and ChaCha20 ones. Apache's extra/httpd-ssl.conf, where its Include is enabled, replaces those defaults with HIGH:MEDIUM:!MD5:!RC4:!3DES and the server's preference order.
Session resumption lets a returning client skip the full handshake. It needs no setup on any of the three: session tickets are on by default everywhere, and a ticket from one connection resumes the session on the next, at TLS 1.2 and at TLS 1.3. The difference is control over the tickets. nginx has the most settings: ssl_session_cache (default none), ssl_session_tickets (on), ssl_session_timeout (5m), and since 1.23.2 a configured shared cache also generates and rotates the ticket keys. Apache has SSLSessionTickets (on since 2.4.11) and SSLSessionCache, none by default, with a startup warning until one is set (AH01873: Init: Session Cache is not configured); the extra/httpd-ssl.conf file sets a shared-memory one (shmcb). Caddy has no session settings in the Caddyfile at all; tickets and their key rotation run on their own, and the session_tickets object in the JSON configuration is the only control.
OCSP stapling means the server itself fetches proof that its certificate is not revoked and sends that proof to clients. Only Caddy does it by default; ocsp_stapling off turns it off, and fetched proofs are stored in the data directory, so one is still available when the CA's status service is down. In nginx, ssl_stapling is off, and turning it on also needs a resolver and the issuer certificate. In Apache, both implementations are off: mod_ssl's SSLUseStapling needs an SSLStaplingCache configured, and mod_md's MDStapling (2.4.42 and later) fetches proofs at server start and after two thirds of their lifetime and keeps them on disk.
Conclusion
Four facts separate the three.
- Only Caddy issues certificates for local and internal names without an external certificate authority.
- The nginx module cannot obtain wildcard certificates. Caddy and Apache can, and with Let's Encrypt both need the DNS challenge, a provider plugin for Caddy and an external program for Apache.
- Apache is the only one that needs a graceful restart for every new certificate.
- Only Caddy can obtain a certificate during the first TLS handshake for an unknown hostname.
With a certificate installed, the versions compared behave nearly the same.
Top comments (0)