Migrating from NGINX + Certbot to Caddy
How I replaced NGINX and Certbot with a single Caddyfile: a Caddy reverse proxy with automatic Let's Encrypt TLS, Cloudflare DNS-01, and a hardened systemd service.
Caddy is a web server written in Go that obtains and maintains Let’s Encrypt TLS certificates automatically. It also has excellent reverse proxy configuration defaults, so much so that I was able to replace NGINX and certbot and multiple large configuration files with a single, simple Caddyfile.
To give a sense of what I mean, here is an example of a Caddyfile for a Caddy server on the edge of a network that routes traffic to various applications behind one public IP address based on hostname and allows those backend servers to have their own publicly trusted TLS certificates with no additional configuration. It uses the caddy-dns/cloudflare module to obtain Let’s Encrypt TLS certificates for itself using the ACME DNS-01 challenge, and proxies ACME HTTP-01 challenges to backend servers. This is great because it means that only the server running Caddy on the edge needs the sensitive API token that manages public DNS records, and you can continue to use hardware and software that only allows HTTP-01 verification to obtain certificates, even on internal servers.
This configuration assumes an internal DNS server or a customized /etc/hosts file is in use on the Caddy host so that backend hostnames resolve to their internal addresses.
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# Catch-all
http:// {
respond 404
}
# importable config snippet keeps the config nice and DRY
# Proxy ACME HTTP-01 challenges for internal and public-facing apps
(acme_relay) {
handle /.well-known/acme-challenge/* {
reverse_proxy {args[0]}:80
}
respond 404
}
http://gateway.example.net {
import acme_relay gateway.example.net
}
http://pbx.example.net {
import acme_relay pbx.example.net
}
http://unifi.example.net {
import acme_relay unifi.example.net
}
http://graylog.example.net {
import acme_relay graylog.example.net
}
http://sandbox.example.net {
import acme_relay sandbox.example.net
}
http://nextcloud.example.net {
import acme_relay nextcloud.example.net
}
# Proxy public-facing apps
(cloudflare_tls) {
tls {
dns cloudflare {env.CF_API_TOKEN}
resolvers 1.1.1.1 # Use public DNS when checking for the challenge record
}
}
sandbox.example.net {
import cloudflare_tls
reverse_proxy https://sandbox.example.net
}
nextcloud.example.net {
import cloudflare_tls
reverse_proxy https://nextcloud.example.net
}
For comparison, here is the same reverse proxy NGINX configuration with Certbot for just one of those sites.
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
server {
server_name nextcloud.example.net;
# Proxy ACME HTTP-01 challenges to the backend
location /.well-known/acme-challenge/ {
proxy_pass http://nextcloud.example.net;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location / {
proxy_pass https://nextcloud.example.net;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# set max upload size and increase upload timeout:
client_max_body_size 512M;
client_body_timeout 300s;
fastcgi_buffers 64 4K;
# This setting allows you to optimize the HTTP2 bandwidth.
# See https://blog.cloudflare.com/delivering-http-2-upload-speed-improvements/
# for tuning hints
client_body_buffer_size 512k;
}
listen [::]:443 ssl; # managed by Certbot
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/nextcloud.example.net/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/nextcloud.example.net/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
server {
if ($host = nextcloud.example.net) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
listen [::]:80;
server_name nextcloud.example.net;
return 404; # managed by Certbot
}
Installing Caddy
Caddy is a self-contained Go binary. Extra features must be added at build time. This can be done from the download page, but I prefer to build it myself using xcaddy.
1
2
sudo apt install golang
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest
This will install xcaddy in ~/go/bin/xcaddy.
For example, to build the latest version of caddy with the module caddy-dns/cloudflare:
1
~/go/bin/xcaddy build --with github.com/caddy-dns/cloudflare
Tip: You can add multiple modules by adding multiple --with arguments.
Then change file ownership of the generated caddy binary to root, and move it to /usr/bin.
1
2
sudo chown root:root caddy
sudo mv caddy /usr/bin
Then test that it worked.
1
caddy version
Next, create a directory for storing the config.
1
sudo mkdir /etc/caddy
Once you have created your Caddyfile, change ownership, and move it into place.
1
2
sudo chown root:root Caddyfile
sudo mv Caddyfile /etc/caddy
Running Caddy as a systemd service
A common way to run Caddy is as a systemd service.
Create the caddy system group.
1
sudo groupadd --system caddy
Create the caddy system user.
1
2
3
4
5
6
7
sudo useradd --system \
--gid caddy \
--create-home \
--home-dir /var/lib/caddy \
--shell /usr/sbin/nologin \
--comment "Caddy web server" \
caddy
Create an environment file for storing secrets. It must be owned by and readable only by root, not the caddy user. The environment file configured by EnvironmentFile= is loaded by systemd before the service process is executed, so the service user never needs access to the file.
1
2
sudo touch /etc/caddy/caddy.env
sudo chmod 0600 /etc/caddy/caddy.env
Secrets like CF_API_TOKEN can be placed in this file in this format:
1
2
CF_API_TOKEN=value
SOME_OTHER_TOKEN=value
Create the service unit file at /etc/systemd/system/caddy.service.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
[Unit]
Description=Caddy
Documentation=https://caddyserver.com/docs/
After=network.target network-online.target
Requires=network-online.target
[Service]
Type=notify
User=caddy
Group=caddy
ExecStart=/usr/bin/caddy run --config /etc/caddy/Caddyfile
ExecReload=/usr/bin/caddy reload --config /etc/caddy/Caddyfile --force
TimeoutStopSec=5s
LimitNOFILE=1048576
PrivateTmp=true
ProtectSystem=full
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE
EnvironmentFile=/etc/caddy/caddy.env
[Install]
WantedBy=multi-user.target
This differs from the Caddy project’s example unit file in two key ways:
--environis not included in theExecStartvalue, because that switch logs the environment (including secrets) to the journalEnvironmentFile=/etc/caddy/caddy.envis added
Enable and start the new service.
1
2
sudo systemctl daemon-reload
sudo systemctl enable --now caddy
Verify that it is running.
1
sudo systemctl status caddy
Upgrading Caddy
Like any other software, Caddy and its modules must be kept up to date to stay secure. The one downside of manually installing Caddy is that upgrades won’t happen via apt or dnf. You are responsible for building and installing upgraded versions.
- Build a new
caddybinary usingxcaddy - Change
caddyfile ownership torootwithsudo chown root:root caddy - Run
sudo mv caddy /usr/binto replace the existing binary - Run
sudo systemctl restart caddyto restart the service
The Caddy CLI does list an upgrade command, but it is labeled [EXPERIMENTAL], and there has been discussion of removing it.
You can use GitHub watchers to receive notifications of new releases and security alerts. This requires a GitHub account, which is free. Go to the Caddy project on GitHub, click on the dropdown next to Watch, click Custom, check Releases and Security Alerts, then click Apply. This allows you to keep an eye out for new releases and security alerts without the noise of issue, pull request, or discussion notifications. Do the same for each Caddy module that you use.
