How to Install and Configure Caddy Web Server on CentOS Stream 9: Automated SSL/TLS and Performance Optimization

CentOS tutorial - IT technology blog
CentOS tutorial - IT technology blog

Midnight Incident: The SSL Renewal Nightmare and the Burden of Manual Configs

Six months ago, our payment gateway ran into an unforgettable outage: the SSL certificate for our API subdomain suddenly expired at 11:00 PM on a Saturday. Transactions ground to a halt, and customer error reports poured in. The entire on-call team pulled an all-nighter simply because the certbot renew command had hung due to a cron job permission issue.

At the time, our team had just provisioned a new node cluster running CentOS Stream 9 to handle around 15 Golang and Node.js microservices. As our infrastructure scaled, the familiar Nginx + Certbot stack began exposing critical operational bottlenecks:

  • Bloated Nginx configuration files. Just handling HTTP-to-HTTPS redirects, specifying certificates and keys, and tuning TLS versions chewed up 30 to 40 lines per domain.
  • Certbot ran via an independent systemd timer. Every time a certificate renewed, Nginx had to be reloaded—often causing silent failures if the reload process got stuck.
  • Enabling HTTP/3 (QUIC) on Nginx under CentOS Stream 9 was an uphill battle. The default repository packages lacked complete support, forcing us to compile from source and manually manage dependencies.

The Underlying Issue: Why Do Nginx and Apache Demand So Much Operational Overhead?

Nginx and Apache are by no means bad software. They remain industry benchmarks for raw performance. However, their underlying architecture dates back to an era when HTTPS was an optional add-on rather than the default standard it is today.

This traditional model exposes three major pain points:

  1. Decoupled Certificate Management: The web server handles routing, while certificate issuance and renewal are offloaded to third-party tools like Certbot or acme.sh. These processes communicate solely via hook scripts or static files on disk. A single file permission error can break the entire chain.
  2. Complex Security Hardening: Achieving an A+ rating on Qualys SSL Labs with Nginx requires manually researching cipher suites, enabling HSTS, and configuring OCSP Stapling from scratch.
  3. Verbose Reverse Proxy Configurations: To proxy traffic to a backend on port 3000 or 8080, Nginx requires boilerplate declarations for a barrage of headers: X-Real-IP, X-Forwarded-For, X-Forwarded-Proto, Upgrade, and Connection.

Three Approaches to Web Server Automation

Faced with this challenge on CentOS Stream 9, I weighed three potential solutions:

  • Approach 1: Writing custom Ansible playbooks to automate Nginx + acme.sh. This keeps our familiar Nginx setup, but maintenance scripts become increasingly bloated as microservices multiply.
  • Approach 2: Placing the entire infrastructure behind Cloudflare SSL. Quick to set up and offloads server burden. However, traffic from Cloudflare back to the origin server still requires encryption under Full (Strict) mode, meaning origin certificate management cannot be avoided.
  • Approach 3: Migrating to Caddy Web Server (Caddy 2). Written in Go, Caddy embeds an ACME engine directly into its core process. The server provisions and manages HTTPS, HTTP/2, and HTTP/3 out of the box without requiring external modules.

Deploying Caddy Web Server on CentOS Stream 9

After running Caddy in production for six months handling roughly 800,000 requests per day, the system has remained rock-solid without a single SSL hiccup. Here is the step-by-step production deployment guide.

Step 1: Install Caddy from the COPR Repository

On CentOS Stream 9, the Caddy community maintains official RPM packages via Fedora COPR. Installation via dnf is straightforward:

# Install the dnf-plugins-core package
sudo dnf install -y 'dnf-command(copr)'

# Enable the official Caddy COPR repository
sudo dnf copr enable -y @caddy/caddy

# Install Caddy
sudo dnf install -y caddy

# Verify installed version
caddy version

Step 2: Configure Firewall Ports

Caddy requires port 80 (HTTP) open for ACME challenge validation and port 443 (HTTPS) for encrypted traffic. Because Caddy supports HTTP/3 out of the box over UDP, you must open both TCP and UDP for port 443:

sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --permanent --add-port=443/udp
sudo firewall-cmd --reload

Step 3: Write a Lean Caddyfile

Open the primary configuration file at /etc/caddy/Caddyfile:

sudo nano /etc/caddy/Caddyfile

Replace its default contents with the following reverse proxy configuration:

api.itfromzero.vn {
    # Enable data compression to reduce page payload sizes
    encode gzip zstd

    # Forward incoming requests to the backend on port 3000
    # Caddy automatically populates X-Forwarded-For, X-Forwarded-Proto, and Host
    reverse_proxy 127.0.0.1:3000

    # Write access logs in JSON format for ingestion into Grafana Loki or ELK
    log {
        output file /var/log/caddy/api_access.log {
            roll_size 50mb
            roll_keep 5
        }
        format json
    }
}

The biggest advantage: you only need an A-record pointing api.itfromzero.vn to your server IP. When Caddy boots up, it automatically requests a certificate from Let’s Encrypt or ZeroSSL via HTTP-01 or TLS-ALPN-01 challenges. Certificates are issued automatically and renewed when 30 days remain. You never have to configure certificate paths or maintain external cron jobs again.

Step 4: Configure SELinux for Reverse Proxying

CentOS Stream 9 runs SELinux in Enforcing mode by default. Unless explicitly permitted, SELinux prevents the web server from connecting to local application ports, resulting in 502 Bad Gateway errors. Resolve this with a single command:

# Allow web server processes to connect to internal network sockets and backend ports
sudo setsebool -P httpd_can_network_connect 1

Step 5: Start and Verify the Service

The RPM package includes a preconfigured systemd unit. Before launching, validate your configuration file syntax:

# Validate Caddyfile syntax
caddy validate --config /etc/caddy/Caddyfile

# Start Caddy and enable it on system boot
sudo systemctl enable --now caddy

# Check process status
sudo systemctl status caddy

When modifying domains or adding new routes, avoid running restart, as it drops active connections. Caddy supports zero-downtime configuration reloads seamlessly:

sudo systemctl reload caddy

Real-World Production Assessment After 6 Months

Resource-wise, Caddy consumes slightly more memory than Nginx. While idling, Caddy hovers around 32MB of RAM, compared to roughly 15MB for Nginx. Under a peak load of 2,500 requests per second, Caddy consumed approximately 68MB of RAM and 12% of a single vCPU on a 2-core / 4GB RAM VPS. This resource overhead is negligible for modern hardware.

In return, our team gained substantial operational benefits:

  • Zero SSL Incidents: Issuance and renewal occur seamlessly in the background without permission mismatches or hung cron jobs.
  • Faster Deployments: Configuring a new microservice now takes under 3 minutes instead of 20 minutes spent wrestling with verbose directives.
  • Lower Client Latency: Thanks to out-of-the-box HTTP/3 support, mobile clients on unstable cellular networks experienced 120–180ms faster handshake times.

If you are provisioning a new server on CentOS Stream 9 or any RHEL-derivative distribution, Caddy is a compelling, production-ready alternative to traditional Nginx setups.

Share: