Devlog: nginx to caddy migration


I recently made the switch from nginx to caddy, so I thought I’d document some of the weirder workarounds I ended up doing, as I couldn’t find examples for them online.

Here are (most of) both of them in full, I’ll show some of the patterns I used commonly below:

nginx config
include /etc/nginx/modules-enabled/\*.conf;

http {

    default_type application/octet-stream;

    access_log /var/log/nginx/access.log;
    error_log /var/log/nginx/error.log;

    include /etc/nginx/conf.d/*.conf;
    server {
        if ($host = www.purarue.xyz) {
            return 301 https://$host$request_uri;
        } # managed by Certbot

        if ($host = purarue.xyz) {
            return 301 https://$host$request_uri;
        } # managed by Certbot

        listen 80 default_server;
        listen [::]:80 default_server;

        server_name www.purarue.xyz purarue.xyz;
        return 301 https://purarue.xyz$request_uri;
    }

    server {
        listen [::]:443 ssl default_server;
        listen 443 ssl default_server;
        server_name www.purarue.xyz;
        ssl_certificate /etc/letsencrypt/live/purarue.xyz/fullchain.pem; # managed by Certbot
        ssl_certificate_key /etc/letsencrypt/live/purarue.xyz/privkey.pem; # managed by Certbot
        include /etc/letsencrypt/options-ssl-nginx.conf;
        ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

        rewrite ^/(.\*) https://purarue.xyz/$1 permanent;

    }

    server {
        listen [::]:443 ssl http2 ipv6only=on;
        listen 443 ssl;
        ssl_certificate /etc/letsencrypt/live/purarue.xyz/fullchain.pem; # managed by Certbot
        ssl_certificate_key /etc/letsencrypt/live/purarue.xyz/privkey.pem; # managed by Certbot
        include /etc/letsencrypt/options-ssl-nginx.conf;
        ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

        index index.html;
        server_name purarue.xyz;

        underscores_in_headers on;

        root /home/user/static_files;
        # base phoenix server

        location @phoenix {
            include /etc/nginx/pheonix_params;
            proxy_pass http://localhost:8082;
        }

        error_page 502 @offline;
        location @offline {
            try_files /502.html 502;
        }

        # if the path doesn't match some static file, forward to @phoenix server

        location / {
            try_files $uri $uri/index.html @phoenix;
        }

        location ~ ^(/remote/logincheck|wp-login.php).\*$ {
            return 404;
        }

        include /etc/nginx/sites-enabled/\*;

        error_page 400 /error_html/400.html;
        error_page 401 /error_html/401.html;
        error_page 402 /error_html/402.html;
        error_page 403 /error_html/403.html;
        error_page 404 /error_html/404.html;
        error_page 405 /error_html/405.html;
        error_page 406 /error_html/406.html;
        error_page 407 /error_html/407.html;
        error_page 408 /error_html/408.html;
        error_page 409 /error_html/409.html;
        error_page 410 /error_html/410.html;
        error_page 411 /error_html/411.html;
        error_page 412 /error_html/412.html;
        error_page 413 /error_html/413.html;
        error_page 414 /error_html/414.html;
        error_page 415 /error_html/415.html;
        error_page 416 /error_html/416.html;
        error_page 417 /error_html/417.html;
        error_page 418 /error_html/418.html;
        error_page 421 /error_html/421.html;
        error_page 422 /error_html/422.html;
        error_page 423 /error_html/423.html;
        error_page 424 /error_html/424.html;
        error_page 425 /error_html/425.html;
        error_page 426 /error_html/426.html;
        error_page 428 /error_html/428.html;
        error_page 429 /error_html/429.html;
        error_page 431 /error_html/431.html;
        error_page 451 /error_html/451.html;
        error_page 500 /error_html/500.html;
        error_page 501 /error_html/501.html;
        error_page 503 /error_html/503.html;
        error_page 504 /error_html/504.html;
        error_page 505 /error_html/505.html;
        error_page 506 /error_html/506.html;
        error_page 507 /error_html/507.html;
        error_page 508 /error_html/508.html;
        error_page 510 /error_html/510.html;
        error_page 511 /error_html/511.html;

    }

    # ========== ANIMESHORTS ===================
    rewrite ^/animeshorts$ /animeshorts/ permanent;
    location /animeshorts {
        # capture entire URL, if it ends with .html
        if ($request_uri ~ ^/(.*)\.html$) {
            # return the first capture group
            return 302 /$1;
        }
        try_files $uri $uri.html $uri/ @phoenix;
    }

    # ============ CLIPBOARD ==================
    location /clipboard/ {
        add_header "Access-Control-Allow-Origin"  *;
        proxy_pass http://127.0.0.1:5025/;
    }

    # ========== CURRENTLY LISTENING ===========
    location /currently_listening/ {
        add_header "Access-Control-Allow-Origin"  *;
        proxy_http_version 1.1;
        proxy_set_header X-Cluster-Client-Ip $remote_addr;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_pass http://127.0.0.1:3030/;
    }

    # =============== DBSENTINEL ================
    rewrite ^/dbsentinel$ /dbsentinel/ permanent;
    # uses elixir/phoenix
    location /dbsentinel/ {
        include /etc/nginx/pheonix_params;
        proxy_pass http://127.0.0.1:4600/dbsentinel/;
    }

    # ============= DOTFILES =============
    # don't include fallback here, 404 means something here
    location /d/ {
        proxy_pass http://127.0.0.1:8050/;
    }

    # ============== DVD =================
    rewrite ^/dvd$ /dvd/ permanent;
    location /dvd/ {
        try_files $uri $uri/ @phoenix;
    }

    # =========== FEED ===================
    rewrite ^/feed$ /feed/ permanent;
    location /feed/ {
        proxy_pass http://127.0.0.1:4500/feed;
    }
    location /feed/_next/ {
        # required since the above proxy pass doesn't end with '/'
        proxy_pass http://127.0.0.1:4500/feed/_next/;
    }
    location /feed_api/ {
        proxy_pass http://127.0.0.1:5100/;
    }

    # =========== GEOCITIES ================
    rewrite ^/geocities$ /geocities/ permanent;
    location /geocities/ {
        try_files $uri $uri/ @phoenix;
    }

    # ============== MAL UNAPPROVED =============
    location /mal_unapproved/ {
        include /etc/nginx/pheonix_params;
        proxy_pass http://127.0.0.1:4001/mal_unapproved/;
    }
    location /mal_unapproved/api/ {
        include /etc/nginx/pheonix_params;
        add_header "Access-Control-Allow-Origin"  *;
        proxy_pass http://localhost:4001/mal_unapproved/api/;
    }

    # =========== PROJECTS =====================
    rewrite ^/projects$ /projects/ permanent;
    location /projects/ {
        proxy_pass http://127.0.0.1:3000/projects;
    }
    location /projects/_next/ {
        # required since the above proxy pass doesn't end with '/'
        proxy_pass http://127.0.0.1:3000/projects/_next/;
    }

    # =========== PUBLIC REMSYNC ===========
    rewrite ^/p$ /p/ permanent;
    location /p/ {
        alias /home/user/p/;
        try_files $uri $uri/ @phoenix;
        autoindex on;
        autoindex_exact_size off;
        autoindex_localtime on;
        add_header "Access-Control-Allow-Origin"  *;
        expires 1h;
        add_header Cache-Control "public";
    }

    # ======= PRIVATE REMSYNC =========
    rewrite ^/f$ /f/ permanent;
    location /f/ {
      alias /home/user/f/;

      # dont serve directories
      try_files $uri @phoenix;
      autoindex off;

      # make sure files are downloaded instead of viewed
      expires -1;
      default_type application/octet-stream;
    }

    # ============== CONFIG/UTILS =================
    location /c/ {
        proxy_pass http://127.0.0.1:8051/;
    }

    # ============== DASHBOARD ====================
    location /dashboard/ {
        include /etc/nginx/pheonix_params;
        proxy_pass http://127.0.0.1:8082/dashboard/;
        auth_basic "for glue dashboard!";
        auth_basic_user_file /etc/nginx/.htpasswd;
    }

    # ============ SHORTURL =======================
    location /s/ {
        proxy_pass http://127.0.0.1:8040/;
    }

    # ============= EXOBRAIN/RSS ===================
    rewrite ^/x$ /x/ permanent;
    rewrite ^/rss$ /x/rss.xml permanent;
    rewrite ^/rss.xml$ /x/rss.xml permanent;
    rewrite ^/sitemap.xml$ /x/sitemap-index.xml permanent;
    rewrite ^/blog /x/blog/ permanent;

    location /x {
        try_files $uri $uri.html $uri/ =404;
        error_page 404 /x/404.html;
        index index.html;
    }

    location /x/notes/personal {
        try_files $uri $uri.html $uri/ =404;
        error_page 404 /x/404.html;
        index index.html;

        auth_basic "personal notes";
        auth_basic_user_file /etc/nginx/.htpasswd;
    }

    # ============= XKCD ===============
    rewrite ^/xkcd$ /xkcd/ permanent;
    location /xkcd/ {
      try_files $uri $uri/ @phoenix;
    }
}
caddy config
www.purarue.xyz {
	redir https://purarue.xyz{uri}
}

purarue.xyz {
	encode # default gzip/zstd encoding

	log {
		output file /var/log/caddy/access.log
	}

	# ========== ANIMESHORTS ==========
	redir /animeshorts /animeshorts/ permanent
	handle_path /animeshorts* {
		root /home/user/static_files/animeshorts/
		try_files {path} {path}.html {path}/ =404
	}

	# ========== CLIPBOARD ==========
	handle_path /clipboard/* {
		header Access-Control-Allow-Origin "*"
		reverse_proxy http://127.0.0.1:5025
	}

	# ========== CURRENTLY LISTENING ==========
	handle_path /currently_listening/* {
		header Access-Control-Allow-Origin "*"
		reverse_proxy http://127.0.0.1:3030
	}

	# ========== DBSENTINEL ==========
	redir /dbsentinel /dbsentinel/ permanent
	handle_path /dbsentinel/* {
		rewrite * /dbsentinel{path}
		reverse_proxy http://127.0.0.1:4600
	}

	# ========== DOTFILES ==========
	redir /d /d/ permanent
	handle_path /d/* {
		reverse_proxy http://127.0.0.1:8050
	}

	# ========== DVD ==========
	redir /dvd /dvd/ permanent
	handle_path /dvd* {
		root /home/user/static_files/dvd/
		try_files {path} {path}/ =404
	}

	# ========== FEED ==========
	redir /feed /feed/ permanent
	handle_path /feed/* {
		rewrite * /feed{path}
		reverse_proxy http://127.0.0.1:4500
	}
	handle_path /feed_api/* {
		reverse_proxy http://127.0.0.1:5100
	}

	# ========== GEOCITIES ==========
	redir /geocities /geocities/ permanent
	handle_path /geocities* {
		root /home/user/static_files/geocities
		try_files {path} {path}/ =404
	}

	# ========== MAL UNAPPROVED ==========
	handle_path /mal_unapproved/* {
		rewrite /mal_unapproved{path}
		reverse_proxy http://127.0.0.1:4001
	}
	handle_path /mal_unapproved/api/* {
		rewrite /mal_unapproved/api{path}
		header Access-Control-Allow-Origin "*"
		reverse_proxy http://localhost:4001
	}

	# ========== PROJECTS ==========
	redir /projects /projects/ permanent
	handle_path /projects/* {
		rewrite /projects{path}
		reverse_proxy http://127.0.0.1:3000
	}

	# ========== PUBLIC REMSYNC ==========
	redir /p /p/ permanent
	handle_path /p/* {
		root /home/user/p/
		header Access-Control-Allow-Origin "*"
		header Cache-Control max-age=3600
		file_server browse
	}

	# ========== PRIVATE REMSYNC ================
	handle_path /f/* {
		root /home/user/f/
		try_files {path} =404
	}

	# ========== CONFIG/UTILS ==========
	handle_path /c/* {
		reverse_proxy http://127.0.0.1:8051
	}

	# ========== DASHBOARD ==========
	handle_path /dashboard/* {
		rewrite /dashboard{path}
		reverse_proxy http://127.0.0.1:8082
		basicauth {
			user $<redacted_password_hash>
		}
	}

	# ========== SHORTURL ==========
	handle_path /s/* {
		reverse_proxy http://127.0.0.1:8040
	}

	# ========== EXOBRAIN/RSS ==========
	redir /x /x/ permanent
	redir /rss /x/rss.xml permanent
	redir /rss.xml /x/rss.xml permanent
	redir /sitemap.xml /x/sitemap-index.xml permanent
	redir /blog /x/blog/ permanent
	handle_path /x* {
		root /home/user/static_files/x/
		try_files {path} {path}.html {path}/ 404.html
	}
	handle_path /x/notes/personal* {
		root /home/user/static_files/x/notes/personal/
		try_files {path} {path}.html {path}/ 404.html
		basicauth {
			notes $<redacted_password_hash>
		}
	}

	# ========== XKCD ==========
	redir /xkcd /xkcd/ permanent
	handle_path /xkcd* {
		root /home/user/static_files/xkcd/
		try_files {path} {path}.html {path}/ =404
		file_server
	}

	@blockLogin path /remote/logincheck* /wp-login.php*
	respond @blockLogin 404

	handle_errors {
		root /home/user/static_files/error_html/
		@custom_err file /{err.status_code}.html
		handle @custom_err {
			rewrite * {file_match.relative}
			file_server
		}

		# fallback
		rewrite * 500.html
		file_server
	}

	# ========== FALLBACK STATIC ========
	root /home/user/static_files/
	file_server

	# ========== GLUE FALLBACK ==========
	handle {
		reverse_proxy http://localhost:8082
	}
}

# Refer to the Caddy docs for more information:
# https://caddyserver.com/docs/caddyfile

I run something like 15 different servers/static websites on here, some of them are just APIs, some are full webapps that are deployed under a different URL, some are just static files.

APIs

For an API, which doesn’t have to care about things like relative links/asset paths, it was pretty easy:

nginx:

# ============ CLIPBOARD ==================
location /clipboard/ {
    add_header "Access-Control-Allow-Origin" *;
    proxy_pass http://127.0.0.1:5025/;
}

caddy:

handle_path /clipboard/* {
	header Access-Control-Allow-Origin "*"
	reverse_proxy http://127.0.0.1:5025
}

Basic Static Website

pretty straightforward as well:

nginx:

rewrite ^/animeshorts$ /animeshorts/ permanent;
location /animeshorts {
    # capture entire URL, if it ends with .html
    if ($request_uri ~ ^/(.*)\.html$) {
        # return the first capture group
        return 302 /$1;
    }
    try_files $uri $uri.html $uri/ @phoenix;
}

caddy:

redir /animeshorts /animeshorts/ permanent
handle_path /animeshorts* {
	root /home/user/static_files/animeshorts/;
	try_files {path} {path}.html {path}/ =404
}

[semicolons aren’t required in caddy, I just included them to trick the highlighting engine into giving it some nicer colors]

Websites

For websites which have both frontend/backend pages, and CSS/JS, I deploy those at a subpath, like:

When I wrote webapps like those, I added a bit of config to the webserver itself, to prefix all the URLs with /dbsentinel so CSS/URLs can all use absolute URLs (e.g. /dbsentinel/search/...) instead of relative ones (../search/), they were never meant to be deployed at the base of the domain.

nginx:

rewrite ^/dbsentinel$ /dbsentinel/ permanent;
location /dbsentinel/ {
    proxy_pass http://127.0.0.1:4600/dbsentinel/;
}

caddy:

redir /dbsentinel /dbsentinel/ permanent;
handle_path /dbsentinel/* {
	rewrite * /dbsentinel{path};
	reverse_proxy http://127.0.0.1:4600;
}

The important bit here is the rewrite * /dbsentinel{path}, as caddy does not allow the reverse_proxy to have any trailing paths like nginx’s proxy_pass does.

Static Files/Errors

The most involved rewrite was my blog/notes site, which deploys as a static site at purarue.xyz/x.

nginx:

location /x {
    try_files $uri $uri.html $uri/ =404;
    error_page 404 /x/404.html;  # custom error page
    index index.html;
}

# personal notes, password protected
location /x/notes/personal {
    try_files $uri $uri.html $uri/ =404;
    error_page 404 /x/404.html;
    index index.html;

    # password protected using HTTP Basic Authentication
    auth_basic "personal notes";
    auth_basic_user_file /etc/nginx/.htpasswd;
}

# manually defined custom error pages, generated using https://github.com/purarue/darker_errors
error_page 400 /error_html/400.html;
error_page 401 /error_html/401.html;
error_page 402 /error_html/402.html;
error_page 403 /error_html/403.html;
...

caddy handles errors slightly differently than nginx. There is the option to use the handle_errors block to globally handle things (which I did attempt with some custom expressions matchers to filter like @is_x expression {http.request.uri.path}.startsWith('/x') && {err.status_code} == 404, but after hours of debugging why the request context wasn’t working how I expected, I realized I could just set 404.html as the last argument to try_files instead of =404, which then doesn’t forward it to the handle_errors block:

caddy:

handle_path /x* {
    root /home/user/static_files/x/;
    # note: this does mean that it doesn't return an actual 404 status
    # code, but I'm okay with this as a hack for now
    try_files {path} {path}.html {path}/ 404.html;
}

# personal notes, password protected
handle_path /x/notes/personal* {
    root /home/user/static_files/x/notes/personal/;
    basicauth {
        user $<redacted_password_hash>;
    }
    # since this has a different root, I did add this to my site build:
    # 'cp dist/404.html dist/notes/personal/404.html'
    try_files {path} {path}.html {path}/ 404.html;
}

handle_errors {
    root /home/user/static_files/error_html/;
    # dynamically map the err.status_code to the corresponding file (if it exists)
    @custom_err file /{err.status_code}.html;
    handle @custom_err {
        rewrite * {file_match.relative};
        file_server;
    }

    # fallback in-case it couldn't find a matching error file
    rewrite * 500.html;
    file_server;
}

This is all so that if you’re viewing a 404 like purarue.xyz/x/doesnt_exist, it uses the custom 404 instead of looking like this.


.. and that’s it! Caddy does seem pretty cool, and I’m glad I don’t have to mess with SSL certificates or set a bunch of things which feel like they should be defaults in the modern web. The file browser is a big upgrade from the nginx autoindex module as well :)