In the past few days, I switched the company’s test environment Nginx to Caddy, and there is still a little problem in the actual switching process, but I feel good so far, so I will record some details here.

Why switch

Most of the time we use a domain name for our production environment, and to ensure isolation we use another domain name for our test environment (test environment buy *.link domain name, which can be filed in China and is also very cheap); however, we are not too willing to pay for the test domain name to buy a certificate, so we have been using ACME.

As we all know, the certificate of this thing needs to be renewed once every 3 months, scripted renewal and then nginx reload is sometimes not very reliable, in short, the scripted operation is still a bit risky in a complicated internal environment, so we finally decided to use Caddy once and for all.

Details involved in switching

Rule Matching

In one site we used Nginx to determine the User-Agent to handle whether the visit was mobile or desktop, and to be honest I hate this kind of stuff:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
map $http_user_agent $is_desktop {
    default 0;
    ~*linux.*android|windows\s+(?:ce|phone) 0; # exceptions to the rule
    ~*spider|crawl|slurp|bot 1; # bots
    ~*windows|linux|os\s+x\s*[\d\._]+|solaris|bsd 1; # OSes
}

map $is_desktop $is_mobile {
    1 0;
    0 1;
}

server {
    # reverse proxy
    location / {
        if ($is_mobile) {
            rewrite ^ https://$host/h5 redirect;
            break;
        }
        proxy_pass http://backend;
        include conf.d/common/proxy.conf;
    }
}

At first, I found out that Caddy also supports maps by looking up the Caddy documentation:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
map {host}             {my_placeholder}  {magic_number} {
	example.com        "some value"      3
	foo.example.com    "another value"
	(.*)\.example.com  "${1} subdomain"  5

	~.*\.net$          -                 7
	~.*\.xyz$          -                 15

	default            "unknown domain"  42
}

In the actual configuration found that this problem only requires a custom rule matcher to determine whether it is mobile or not:

1
2
3
4
5
6
@mobile {
    header_regexp User-Agent (?i)linux.*android|windows\s+(?:ce|phone)
    not path_regexp ^.+\.(?:css|cur|js|jpe?g|gif|htc|ico|png|html|xml|otf|ttf|eot|woff|woff2|svg)$
    not path /web/*
}
rewrite @mobile /h5/{path}?{query}

When writing subsequent matching rules, we found that Caddy’s matching rules are indeed very powerful, and you can find basically From request headers to request methods, protocols, request paths, from standard matching to wildcard and regular matching, and even support code-based CEL (Common Expression Language) expressions. matching; multiple matches can also be custom named as business-related matchers

Rule Rewriting

In Nginx, the rewrite directive has multiple behaviors, such as rewriting URLs implicitly or returning redirect codes such as 301 and 307; however, in Caddy, these two behaviors are divided into two directives:

  • rewrite: internal rewrite, internal replacement of URL, parameters, etc., browser address will remain unchanged
  • redir: redirect, return HTTP status code for client to redirect to new page by itself

rewrite

The implicit rewrite command for addresses has the following syntax rules:

1
rewrite [<matcher>] <to>

The matcher is the global standard matcher definition, you can use the built-in one, or you can combine the built-in matcher into a custom matcher, which is much more powerful than Nginx; there are three cases in to:

  • Replace only PATH: rewrite /abc /bcd :

    In this case, rewrite determines the matching path based on the “matcher” and then completely replaces it with the last path; the last path can be referenced to the original path using the {path} placeholder.

  • Replace only request parameters: rewrite /api ?a=b :

    In this case, Caddy uses ? as a separator, so if ? is followed by something it means that the request parameter is replaced with a later request parameter; the last request parameter can be referenced to the original request parameter by {query}.

  • replace all: rewrite /abc /bcd?{query}&custom=1 :

    In this case, Caddy replaces both the request path and the request parameters according to the “matcher” match, and of course both placeholders are available.

    Note: rewrite only does rewrites, it does not break the request chain, which means that the final result is determined by the subsequent request matches.

redir

redir is used to declare an explicit redirect to the client, i.e. to return a specific redirect status code, with the following syntax:

1
redir [<matcher>] <to> [<code>]

The matchers are the same; <to> This parameter is returned as the Location header, where you can use placeholders to refer to the original variables:

1
redir * https://example.com{uri}

The code section is divided into four cases:

  • a custom status code for 3xx
  • temporary : returns a 302 temporary redirect
  • permanent : returns a 301 permanent redirect
  • html : redirects using the HTML document method

For example, to permanently redirect all requests to a new site:

1
redir https://example.com{uri} permanent

The HTML approach here is more difficult to understand, and it originates from a specification as follows:

The redirection mechanism in the HTTP protocol is the preferred way to create redirection maps, but sometimes web developers have no control over the server or cannot configure it. For these application-specific scenarios, web developers can add an element to the carefully crafted HTML page’s

section and set the value of its http-equiv attribute to refresh. When the page is displayed, the browser will detect the element and jump to the specified page.

If the html redirect method is used in the source code, Caddy will return an HTML page for the browser to refresh itself if the above method is used:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
var body string
switch code {
case "permanent":
	code = "301"
case "temporary", "":
	code = "302"
case "html":
	// Script tag comes first since that will better imitate a redirect in the browser's
	// history, but the meta tag is a fallback for most non-JS clients.
	const metaRedir = `<!DOCTYPE html>
<html>
<head>
	<title>Redirecting...</title>
	<script>window.location.replace("%s");</script>
	<meta http-equiv="refresh" content="0; URL='%s'">
</head>
<body>Redirecting to <a href="%s">%s</a>...</body>
</html>
`
	safeTo := html.EscapeString(to)
	body = fmt.Sprintf(metaRedir, safeTo, safeTo, safeTo, safeTo)
	code = "302"
default:
	codeInt, err := strconv.Atoi(code)
	if err != nil {
		return nil, h.Errf("Not a supported redir code type or not valid integer: '%s'", code)
	}
	if codeInt < 300 || codeInt > 399 {
		return nil, h.Errf("Redir code not in the 3xx range: '%v'", codeInt)
	}
}

uri

The uri directive is a special directive that is similar to rewrite, except that it is more convenient for rewriting URIs, and has the following syntax:

1
2
3
uri [<matcher>] strip_prefix|strip_suffix|replace|path_regexp \
	<target> \
	[<replacement> [<limit>]]

The second parameter in the syntax is a verb that defines how to replace the URI:

  • strip_prefix : removes the prefix from the path
  • strip_suffix : Remove suffixes from paths
  • replace : perform subreplacements throughout the URI path (e.g. /a/b/c/d is replaced with /a/1/2/d)
  • path_regexp : perform regular replacement in paths

Here are some examples:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Remove the "/api/v1" prefix
uri strip_prefix /api/v1

# Removing the ".html" suffix
uri strip_suffix .html

# Subpath replacement "/v1" => "/v2"
uri replace /v1/ /v2/

# Regular replacement "/api/version" => "/api"
uri path_regexp /api/v\d /api

where replace can be followed by a number at the end, representing how many times to find the replacement from the URI, the default is -1 that is, replace all.

WebSocket Proxy

In the Nginx configuration, if you want to proxy WebSocket links, we need to add the following settings:

1
2
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";

But in Caddy it’s much simpler… so simple that we don’t have to do anything, it’s automatically supported:

Websocket proxying “just works” in v2; there is no need to “enable” websockets like in v1.

URL encoding

When using a path matcher, URLs are decoded by default, e.g. :

1
2
# Chinese has been decoded, you need to write the decoded string directly to match the
redir /2016/03/22/Java-内存之直接内存 https://mritd.com/2016/03/22/java-memory-direct-memory permanent

As for the reverse proxy reverse_proxy, I haven’t encountered the encoding when passing it out yet, so I need to test it

Forced HTTP

Some sites may be HTTP by default, and we don’t expect to access them by HTTPS; however, Caddy will apply for ACME certificate for the site by default, and we can’t access them if we can’t apply for the certificate; in this case, we just need to write the HTTP protocol on the site address forcibly:

1
2
3
http://example.com {
    reverse_proxy ...
}

Proxy HTTPS

If you want to proxy HTTPS service, you only need to fill in the HTTPS address in reverse_proxy; but unlike Nginx, Caddy’s TLS checksum is on by default, so if the backend HTTPS certificate is expired, it may cause Caddy to return a 502 error; this can be turned off by using transport:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
reg.example.com {
    handle {
        request_body {
            max_size 1G
        }
        reverse_proxy {
            to https://172.16.11.40:443
            transport http {
                # SNI
                tls_server_name reg.example.link
                # Turn off back-end TLS authentication
                tls_insecure_skip_verify
            }
        }
    }
}

Custom Certificates

If you already have your own certificate and do not expect Caddy to apply it automatically, just add the certificate after the tls command:

1
2
3
4
5
6
7
8
reg.example.com {
    handle {
        ...
    }
    
    # Using custom certificates
    tls cert.pem key.pem
}

Log Printing

Caddy’s logging system is completely different from Nginx, Caddy logs are divided by Namespace, by default only the request log of the current site can be printed in the site configuration, if you need to print the upstream address of the reverse proxy, for example, you need to configure it in the global logging configuration. If you know go, you can take a look at uber-go/zap logging framework; here is a sample of printing request logs and upstream logs separately by file:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# Global options
{
    # Print the reverse proxy upstream information log (upstream is a random name for this location)
    log upstream {
        level DEBUG
        format json {
            time_format "iso8601"
        }
        output file /data/logs/upstream.log {
            roll_size 100mb
            roll_keep 3
            roll_keep_for 7d
        }
        # Namespace needs to be specified in order to print
        include "http.handlers.reverse_proxy"
    }
}

example.com {
    # Print site request log
    log {
        format json {
            time_format "iso8601"
        }
        output file "/data/logs/example.com.log" {
            roll_size 100mb
            roll_keep 3
            roll_keep_for 7d
        }
    }
}

TLS versions are not supported

Unfortunately we have a TLS 1.1 compatible service and when switching to Caddy TLS 1.1 is no longer supported, currently Caddy has a minimum TLS 1.2 and a maximum TLS 1.3 compatibility:

1
protocols <min> [<max>]

protocols specifies the minimum and maximum protocol versions. Default min: tls1.2. Default max: tls1.3

Summary

To sum up: the matcher is comfortable, the configuration behavior is clear, the configuration reference is written in 10,000 lines less, and the rest of the pitfalls continue to be stepped on.


Reference https://mritd.com/2021/08/20/switching-rrom-nginx-too-caddy/