The most boring thing about setting up a web server on a new cloud server for me is the initial configuration. From installing(opens new window) and configuring nginx(opens new window) to setting up Let's Encrypt with certbot(opens new window)[1]. There are so many steps involved and so much boilerplate code in the config[2].

This has changed now that Caddy exists. It is an open source web server written in Go that has built-in Let's Encrypt support. And it has the simplest config file format I've ever seen for a web server:

Before: nginx.conf

server {
  listen       80;
  listen       [::]:80;
  location ^~ /.well-known/acme-challenge/ {
    root /var/www/html;
  location / {
    return 301$request_uri;

# HTTPS server
server {
  listen       443 ssl http2;
  listen       [::]:443 ssl http2;
  root         /var/www/html;

  ssl_certificate          /etc/letsencrypt/live/;
  ssl_certificate_key      /etc/letsencrypt/live/;
  ssl_trusted_certificate  /etc/letsencrypt/live/;

  location /api {
    proxy_pass http://api:8080/api;
    proxy_http_version  1.1;
    proxy_cache_bypass  $http_upgrade;
    proxy_set_header  Upgrade            $http_upgrade;
    proxy_set_header  Connection         "upgrade";
    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;
    proxy_set_header  X-Forwarded-Host   $host;
    proxy_set_header  X-Forwarded-Port   $server_port;
    proxy_connect_timeout  60s;
    proxy_send_timeout     60s;
    proxy_read_timeout     60s;


After: Caddyfile

# ingress/Caddyfile
root * /var/www/html
reverse_proxy /api/* api:8080

Built with Go, it is available as a standalone binary(opens new window). Packages for popular package managers(opens new window) are available. And there is also a ready-to-use Docker image(opens new window).

# Usage with Docker Compose

Here's a basic docker-compose file that runs Caddy:

# ingress/docker-compose.yml
version: "3.7"
    image: caddy
    restart: unless-stopped
      - "80:80"
      - "443:443"
      - ./Caddyfile:/etc/caddy/Caddyfile
      - ./public:/var/www/html
      - caddy_data:/data
      - caddy_config:/config

Now when I run docker-compose up -d, Caddy starts up and:

  • Listens to port 80 and 443.
  • Obtains a Let's Encrypt certificate for domain
  • Sets up HTTP to HTTPS redirection on port 80.
  • Serves files from public folder (mounted to /var/www/html inside container).
  • Proxies requests to /api/ to the api hostname.

# Configuring Docker networks for reverse proxy

Now, other projects I run on this VPS have their own docker-compose file. We need to make the container join Caddy's Docker network for Caddy to be able to look it up via hostname.

This can be done by joining an external network and setting a network alias for that network.

# api/docker-compose.yml
version: '3.8'
    restart: unless-stopped
    build: .
          - api
    external: true
    name: ingress_default

  1. A commenter(opens new window) recommended me try out new window) should I need to use nginx again. ↩︎

  2. Boilerplate include but not limited to:

    • Creating a file in sites-available.
    • Setting up a server block.
    • Setting up redirect from HTTP to HTTPS.
    • Setting up the path to the SSL certificate file.
    • Setting up HTTP headers for the reverse proxy, such as Host, X-Real-IP, X-Forwarded-For.
    • Setting up WebSocket support.
    • Symlinking the file to sites-enabled.