DEV Community

Edgaras
Edgaras

Posted on

Comparing Caddy, nginx and Apache Configuration

Caddy, nginx and Apache httpd each serve static files, terminate TLS and reverse-proxy to a backend. They differ most in the configuration language, and in how much of the configuration has to be written at all.

The versions used are Caddy v2.11.4, nginx 1.31.3 mainline and Apache httpd 2.4.68. Every configuration below ran on the official caddy:alpine, nginx:alpine and httpd:alpine images, with localhost and non-privileged ports in place of example.com, port 80 and port 443, and a self-signed certificate for nginx and Apache.

The three projects

Caddy nginx Apache httpd
License Apache 2.0 2-clause BSD Apache 2.0
Published by ZeroSSL, an HID Global company F5, Inc. The Apache Software Foundation
Current release v2.11.4, 2026-06-03 mainline 1.31.3, stable 1.30.4 2.4.68, 2026-06-08
Release lines One line Mainline (odd middle number), updated every one to two months; stable (even), about once a year 2.4.x
Official binaries Static binaries for Linux, macOS, Windows and FreeBSD, plus Debian and Fedora/RHEL packages Linux packages for RHEL, Debian, Ubuntu, SLES, Alpine and Amazon Linux, and a Windows zip Source only; Windows binaries come from third-party vendors

What the configuration is written in

Caddy's native configuration format is JSON. Any other format is converted to JSON by a config adapter, and the Caddyfile is the adapter in the standard build. caddy adapt prints the conversion, so this Caddyfile:

example.com {
    root /var/www/html
    file_server
}
Enter fullscreen mode Exit fullscreen mode

produces this:

$ caddy adapt --config Caddyfile --pretty
{
    "apps": {
        "http": {
            "servers": {
                "srv0": {
                    "listen": [
                        ":443"
                    ],
                    "routes": [
                        {
                            "match": [
                                {
                                    "host": [
                                        "example.com"
                                    ]
                                }
                            ],
                            "handle": [
                                {
                                    "handler": "subroute",
                                    "routes": [
                                        {
                                            "handle": [
                                                {
                                                    "handler": "vars",
                                                    "root": "/var/www/html"
                                                },
                                                {
                                                    "handler": "file_server",
                                                    "hide": [
                                                        "./Caddyfile"
                                                    ]
                                                }
                                            ]
                                        }
                                    ]
                                }
                            ],
                            "terminal": true
                        }
                    ]
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The JSON is what the server runs. The Caddyfile is one way to produce it.

nginx configuration is a directive file. Simple directives end in a semicolon, and block directives use braces. Blocks that can contain other directives are called contexts: events, http, server, location. Everything outside a block is in the main context. A directive is inherited from the enclosing level only when the same directive is absent at the current level.

Apache configuration is also a directive file, scoped by container sections: <VirtualHost>, <Directory>, <DirectoryMatch>, <Files>, <FilesMatch>, <Location>, <LocationMatch>, <Proxy> and <If>. The sections merge in a fixed group order: <Directory> together with .htaccess, then <DirectoryMatch>, then <Files> and <FilesMatch>, then <Location> and <LocationMatch>, and <If> last.

Position in the file matters differently in each of the three.

  • Caddy: file order is mostly ignored. The adapter sorts HTTP handler directives into a fixed built-in order, and directives of the same name by how specific their matchers are. A route block keeps the written order for everything inside it. Plugin directives are not in the built-in order, so each needs a place set by the order global option or a route block.
  • nginx: file order decides which regular expression location is tried first. nginx checks the prefix locations first and keeps the longest match. It then tries the regular expression locations in the order they appear and takes the first that matches. If none match, the kept prefix is used. ^~ on the longest prefix skips the regular expression step. = is an exact match and stops the search.
  • Apache: file order decides within a group, not across groups. A <Location> block always applies after a <Directory> block, because of the group order above. <Directory> is the exception inside its own group: shortest path first, so <Directory "/var/web/dir"> before <Directory "/var/web/dir/subdir">, with file order deciding only between sections naming the same directory.

When more than one block could match a request, each server picks exactly one.

  • Caddy: the site block with the most specific matching address handles the request. No other site block applies.
  • nginx: server_name is matched in a fixed order. An exact name first, then the longest wildcard beginning with an asterisk, then the longest wildcard ending with one, then the first matching regular expression. A request matching none of these goes to the default server for that address and port.
  • Apache: exactly one <VirtualHost> is selected. Directives from the other matching virtual hosts are never merged in.

The same site, three times

The site serves static files from a directory, reverse-proxies /api/ to 127.0.0.1:8080 with the client's Host preserved and X-Forwarded-For and X-Forwarded-Proto set, terminates HTTPS, redirects HTTP to HTTPS, compresses text responses and writes an access log.

Caddy:

example.com {
    root /var/www/html
    encode
    reverse_proxy /api/* 127.0.0.1:8080
    file_server
    log {
        output file /var/log/caddy/access.log
    }
}
Enter fullscreen mode Exit fullscreen mode

nginx:

worker_processes  auto;

events {
    worker_connections  1024;
}

http {
    include       mime.types;
    default_type  application/octet-stream;

    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" "$http_user_agent"';
    access_log  /var/log/nginx/access.log  main;

    gzip             on;
    gzip_min_length  512;
    gzip_vary        on;
    gzip_types       text/plain text/css text/xml application/json
                     application/javascript application/xml image/svg+xml;

    server {
        listen       80;
        server_name  example.com;
        return       301 https://$host$request_uri;
    }

    server {
        listen       443 ssl;
        http2        on;
        server_name  example.com;

        ssl_certificate      /etc/nginx/ssl/example.com.crt;
        ssl_certificate_key  /etc/nginx/ssl/example.com.key;

        root /var/www/html;

        location /api/ {
            proxy_pass http://127.0.0.1:8080;

            proxy_set_header Host              $host;
            proxy_set_header X-Real-IP         $remote_addr;
            proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Apache:

ServerRoot "/usr/local/apache2"

Listen 80
Listen 443

LoadModule mpm_event_module   modules/mod_mpm_event.so
LoadModule unixd_module       modules/mod_unixd.so
LoadModule authz_core_module  modules/mod_authz_core.so
LoadModule mime_module        modules/mod_mime.so
LoadModule dir_module         modules/mod_dir.so
LoadModule alias_module       modules/mod_alias.so
LoadModule log_config_module  modules/mod_log_config.so
LoadModule filter_module      modules/mod_filter.so
LoadModule deflate_module     modules/mod_deflate.so
LoadModule headers_module     modules/mod_headers.so
LoadModule ssl_module         modules/mod_ssl.so
LoadModule proxy_module       modules/mod_proxy.so
LoadModule proxy_http_module  modules/mod_proxy_http.so

User  daemon
Group daemon

ServerName example.com
TypesConfig conf/mime.types
DirectoryIndex index.html

ErrorLog logs/error_log
LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-agent}i\"" combined
CustomLog logs/access_log combined

<Directory "/">
    Require all denied
</Directory>

<VirtualHost *:80>
    ServerName example.com
    Redirect permanent "/" "https://example.com/"
</VirtualHost>

<VirtualHost *:443>
    ServerName    example.com
    DocumentRoot  "/var/www/html"

    SSLEngine on
    SSLCertificateFile    "conf/example.com.crt"
    SSLCertificateKeyFile "conf/example.com.key"

    <Directory "/var/www/html">
        Require all granted
    </Directory>

    AddOutputFilterByType DEFLATE text/html text/plain text/css text/javascript \
                          application/javascript application/json application/xml image/svg+xml

    ProxyRequests     Off
    ProxyPreserveHost On
    RequestHeader set X-Forwarded-Proto "https"
    ProxyPass        "/api/" "http://127.0.0.1:8080/api/"
    ProxyPassReverse "/api/" "http://127.0.0.1:8080/api/"
</VirtualHost>
Enter fullscreen mode Exit fullscreen mode

CustomLog is at server level rather than inside the :443 virtual host. Inside the virtual host it logs HTTPS requests only, and the port-80 redirects are not logged at all. The Caddy and nginx configurations log both.

Caddy nginx Apache
Non-blank lines 9 36 48
Bytes 144 1303 1839
LoadModule lines 0 0 13

The ordering is the same for the smallest job, serving one static directory over plain HTTP:

Caddy nginx Apache
Non-blank lines 4 8 15
Bytes 41 113 477
LoadModule lines 0 0 5

Why the Caddyfile is shorter

The difference is mostly defaults, not a more compact syntax. The Caddyfile names the site address, and Caddy derives the rest from it.

  • HTTPS: a site address with a hostname turns on automatic HTTPS, so the Caddyfile needs no certificate directive. nginx needs ssl_certificate and ssl_certificate_key. Apache needs SSLEngine on, SSLCertificateFile and SSLCertificateKeyFile.
  • The HTTP to HTTPS redirect: Caddy adds it and answers with 308. nginx and Apache each need a second server block or virtual host, which return 301 above.
  • Directive order: the adapter sorts encode, reverse_proxy and file_server into the built-in order, so no route block is needed.
  • HTTP/2: Caddy serves it on the HTTPS listener with no directive. nginx needs http2 on;, added in 1.25.1 and off by default. Apache needs mod_http2 and a Protocols line, and the configuration above has neither.

Two more defaults differ, both between nginx and the other two.

  • nginx writes an access log with no configuration at all, to logs/access.log in its predefined combined format. Caddy and Apache write none until configured.
  • Caddy and Apache both send Vary: Accept-Encoding on compressed responses with no extra configuration. nginx needs gzip_vary on.

Headers sent to the proxied backend

Each server was configured to proxy everything to 127.0.0.1:8080, with no header directives of any kind, and the backend printed the headers it received. The request carried Host: site.test.

Header at the backend Caddy 2.11.4 nginx 1.31.3 httpd 2.4.68
Host site.test 127.0.0.1:8080 127.0.0.1:8080
X-Forwarded-For 127.0.0.1 absent 127.0.0.1
X-Forwarded-Proto http absent absent
X-Forwarded-Host site.test absent site.test
X-Forwarded-Server absent absent localhost
Via 1.1 Caddy absent absent

Caddy passes the client's Host through unchanged and sets the three X-Forwarded-* headers itself. It ignores any incoming values of those three unless trusted_proxies is configured.

nginx replaces Host with $proxy_host and adds nothing else, which is why the configuration above carries four proxy_set_header lines. $host holds the host without the port, so a request to example.com:8443 reaches the backend as Host: example.com.

Apache replaces Host with the ProxyPass target unless ProxyPreserveHost On is set. mod_proxy_http adds X-Forwarded-For, X-Forwarded-Host and X-Forwarded-Server by default, under ProxyAddHeaders, which has defaulted to on since 2.3.10. X-Forwarded-Proto is not in that set, so the Apache configuration adds it with RequestHeader.

Checking a configuration

Command What it does
Caddy caddy validate Deserializes, loads and provisions every module without starting the config
Caddy caddy adapt --validate Adapts a Caddyfile to JSON and then validates it
Caddy caddy fmt Formats a Caddyfile; exits 1 when the output differs from the input
nginx nginx -t Tests the syntax and tries to open the files the configuration references
nginx nginx -T The same, and dumps the whole configuration to stdout
Apache apachectl configtest Parses the configuration files and reports Syntax OK or the error
Apache httpd -S Shows the parsed virtual host settings

Neither nginx nor Apache documents an equivalent of caddy fmt.

Reloading

All three can replace the running configuration without dropping connections, by different mechanisms.

  • Caddy: caddy reload posts the configuration to the admin API's /load endpoint. The new configuration is provisioned and started before the old one is stopped, so both run briefly. The command fails if the admin endpoint is disabled. The grace period for old connections is eternal by default.
  • nginx: nginx -s reload signals the master process. The master checks the syntax, opens log files and new listen sockets, starts workers with the new configuration and tells the old workers to shut down. Old workers close their listen sockets, finish serving connected clients and exit. worker_shutdown_timeout caps that wait.
  • Apache: apachectl graceful sends USR1. Children finish their current request, the parent re-reads the configuration files and re-opens the log files, and each child is replaced from the new generation. apachectl restart sends HUP instead, which kills children immediately and zeroes the mod_status counters.

A configuration with an error is rejected by all three, and the running server keeps the previous configuration. Each was given a valid configuration, then a broken one, then asked to reload:

$ caddy reload --config /etc/caddy/Caddyfile
{"level":"info","ts":1786909867.4621506,"msg":"using config from file","file":"/etc/caddy/Caddyfile"}
Error: adapting config using caddyfile: /etc/caddy/Caddyfile:6: unrecognized directive: this_is_not_a_directive

$ nginx -s reload
2026/08/16 19:51:10 [emerg] 31#31: unknown directive "not_a_directive" in /etc/nginx/nginx.conf:6
nginx: [emerg] unknown directive "not_a_directive" in /etc/nginx/nginx.conf:6

$ apachectl -k graceful
AH00526: Syntax error on line 15 of /usr/local/apache2/conf/httpd.conf:
Invalid command 'NotARealDirective', perhaps misspelled or defined by a module not included in the server configuration
Enter fullscreen mode Exit fullscreen mode

In each case the server continued to answer with the configuration it had before the attempt, and a later reload of a corrected file applied the change.

Apache keeps its ports bound across a restart. A Listen change that conflicts with the existing binding therefore fails and terminates the server, and applying it needs a full stop and start.

nginx can replace its own executable without stopping. Sending USR2 starts a second master from the new binary, WINCH shuts down the old workers, and QUIT retires the old master. The old master keeps its listen sockets throughout, so it can restart its workers with HUP if the new binary misbehaves. Caddy's caddy upgrade replaces the binary on disk only and leaves the running process on the old one.

Caddy is the only one of the three whose open-source build exposes an HTTP API for the whole running configuration: the admin endpoint on localhost:2019, with POST /load, POST /stop, POST /adapt and the /config/ path supporting GET, POST, PUT, PATCH and DELETE, plus Etag and If-Match for optimistic concurrency. nginx documents ngx_http_api_module and the -l control REST API added in 1.29.8 as commercial-subscription features. Apache's documentation states that changes to the main configuration files are recognized only on start or restart.

Apache has one mechanism the others do not: .htaccess, read on every request, so changes take effect without a restart. AllowOverride defaults to None. Where it is enabled, httpd looks for an .htaccess file in the requested directory and in every higher-level directory that also has it enabled, on every request, whether or not the files exist.

Which modules have to be present

The configurations above do not fail in the same way. The Caddy one runs on any standard Caddy binary. The nginx one fails on a binary built without SSL or HTTP/2 support. The Apache one fails unless each of its 13 modules is loaded, and the error names the directive that stopped working, not the module that provides it. Invalid command 'SSLEngine' does not say that the LoadModule ssl_module line is missing. Removing each LoadModule line in turn and running httpd -t on 2.4.68 matches each error to its module:

Module removed httpd -t reports Provides
mpm_event_module AH00534: httpd: Configuration error: No MPM loaded. The process model. Exactly one MPM must be loaded
unixd_module Invalid command 'User' User and Group, the account the server runs as
authz_core_module Invalid command 'Require' Require, the access rules
mime_module Invalid command 'TypesConfig' Content-Type from the file extension
dir_module Invalid command 'DirectoryIndex' Serving index.html for directory requests
alias_module Invalid command 'Redirect' Redirect in the port-80 virtual host
log_config_module Invalid command 'LogFormat' LogFormat and CustomLog, the access log
filter_module Invalid command 'AddOutputFilterByType' Attaching the compression filter by content type
deflate_module Unknown filter provider DEFLATE The DEFLATE filter itself, gzip compression
headers_module Invalid command 'RequestHeader' RequestHeader, the X-Forwarded-Proto header
ssl_module Invalid command 'SSLEngine' HTTPS, SSLEngine and the certificate directives
proxy_module Cannot load modules/mod_proxy_http.so into server: ... ap_proxy_pre_http_request: symbol not found The proxy core and ProxyPass
proxy_http_module Syntax OK HTTP support for ProxyPass, the /api/ proxying

The last row is the exception. Without mod_proxy_http the configuration passes the syntax check and the server starts, static files are served normally, and only requests to /api/ fail. Those return status 500 and log AH01144: No protocol handler was valid for the URL /api/hello (scheme 'http').

None of this means recompiling. The official httpd:alpine image ships 130 module files, including all 13 the configuration needs, and its stock httpd.conf loads 25 of them. Adding the missing LoadModule lines is the whole job.

nginx has the equivalent constraint at build time rather than at config time. HTTPS and HTTP/2 support are compile-time options, --with-http_ssl_module and --with-http_v2_module, so whether the nginx configuration above works depends on how the binary was built, not on a directive. Prebuilt packages include them: nginx -V on the official nginx:alpine image reports both, plus --with-http_v3_module and --with-stream. A dynamic module has to match the running nginx exactly, because nginx refuses to load a module whose compiled-in version or module signature differs.

Caddy has no load step at all. caddy list-modules on v2.11.4 reports 132 standard modules in the binary, and every directive in the Caddyfile above comes from that set. Adding anything outside it means a new binary, built with xcaddy, downloaded from the project's download page, or fetched by caddy add-package, which the command-line reference marks experimental. That command replaces the binary on disk and leaves the running process on the old one until it restarts.

Conclusion

The three configuration languages are nothing alike. The length differences come from defaults rather than syntax. Much of what the nginx and Apache configurations write out, Caddy does by default. Enabling a feature also happens in a different place in each server. Apache does it in the config file, nginx at build time, Caddy in the choice of binary. None of this tells you how the three behave under load. That depends on each server's process and connection handling, and the configuration file shows none of it.

Top comments (0)