Setting up a dockerised Caddy-based webserver on Hetzner Cloud
This blog—along with a number of other websites I run—had been hosted by Dreamhost since 2008. But last year, when they announced they’d now have to charge VAT on top of the $13/mth I was already paying them, I figured it was time to shop around for alternatives.
It wasn’t just about price either – I was also increasingly uncomfortable with having my data stored in the USA, and as my needs had progressed beyond just basic PHP hosting to building Jekyll sites and deploying with Git hooks, Dreamhost’s “not quite a VPS” basic tier became more and more awkward to work with.
I settled on Hetzner Cloud’s CX22 vCPU as a suitable alternative – as long as your processing or bandwidth requirements aren’t significant, they just can’t be beaten on price. With VAT and an (optional extra!) IP4 address, it costs me £4/mth – cheaper than a Digital Ocean Small Droplet or a Mythic Beasts VPS 1, but with four times the RAM and twice the storage. Wow. I even snagged a €20 voucher on the Hetzner Community site, which effectively gave me the first four months for free.
Dreamhost also used to handle the DNS for some of my domains, so I needed to find a replacement for that too. I went with Cloudflare. I used Luar Roji’s Dreamhost DNS exporter to save me a half an hour’s work copying and pasting between the two sites.
Setting up the VPS
The Hetzner Cloud setup wizard makes it super easy to boot and configure a new VPS. You pick a distribution (I chose Ubuntu, as I’m most familiar with that), upload an SSH public key for the root
user, and boom, you’re ready to SSH in.
You’ll then want to do basic setup and security – see some examples here, here, and here. In my case:
Set up a non-root user account
adduser zarino
usermod -aG sudo zarino
su zarino
cd /home/zarino
mkdir .ssh
chmod 700 .ssh
Then I copied my SSH public key to /home/zarino/.ssh/authorized_keys
on the remote server, with ssh-copy-id zarino@<hetzner-ip-address>
from my Mac (because I already had ssh-copy-id installed via Homebrew). I guess you could copy/paste your SSH key in by hand.
Tighten login requirements
Secure your logins by editing /etc/ssh/sshd_config
:
- Uncomment the
#PermitRootLogin…
line and changeprohibit-password
tono
- Uncomment the
#PasswordAuthentication…
line and changeyes
tono
- Change
UsePAM yes
toUsePAM no
- Confirm the following are set (or the default):
ChallengeResponseAuthentication no
(was replaced byKbdInteractiveAuthentication
in Ubuntu 22.04)KerberosAuthentication no
GSSAPIAuthentication no
X11Forwarding no
PermitUserEnvironment no
DebianBanner no
Then validate the syntax of sshd_config
with sudo sshd -t
. Assuming it’s fine, restart SSH with sudo systemctl restart ssh
. Then, in a new terminal on your local machine (without closing your current SSH session in the original terminal – just in case!):
- Confirm
ssh root@<hetzner-ip-address>
is refused - Confirm
ssh -o PubkeyAuthentication=no zarino@<hetzner-ip-address>
is refused - Confirm
ssh zarino@<hetzner-ip-address>
is accepted
You can now do the rest of the setup in that new, non-root user account.
Set up firewall
With logins secured, it’s time to set up Ubuntu’s firewall:
sudo ufw allow ssh
sudo ufw allow http
sudo ufw allow https
sudo ufw enable
sudo ufw status
will show you that ports 22, 80, and 443 are allowed. Everything else is denied.
Update system packages
I also updated system packages and removed a few packages I knew I wouldn’t need (unused packages are just a potential source of vulnerabilities!), although I think in the end all of the packages had never been installed in the first place:
sudo apt update
sudo apt full-upgrade -y
sudo apt autoremove -y
sudo apt-get purge --auto-remove telnetd ftp vsftpd samba nfs-kernel-server nfs-common
Set up unattended upgrades
sudo apt install unattended-upgrades
systemctl enable unattended-upgrades
systemctl start unattended-upgrades
Then confim /etc/apt/apt.conf.d/20auto-upgrades
contains:
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";
And perform a dry run with sudo unattended-upgrades --dry-run --debug
.
You could also enable email notifications about security updates, by adding the following two lines to /etc/apt/apt.conf.d/50unattended-upgrades
:
Unattended-Upgrade::Mail "your@email.com";
Unattended-Upgrade::MailOnlyOnError "true";
Set up Docker
Last time I set up a webserver (an EC2 instance in 2020) I configured a LAMP stack from scratch. It was horrendous. This time, I decided to use Docker as much as possible – both to compartmentalise projects and pieces of software, and also to create a reproducable build that could be torn down and recreated on another server if I ever needed to.
Install Docker by following the instructions here and then follow the Linux post-install steps, namely:
sudo groupadd docker
(already existed)sudo usermod -aG docker $USER
- Log out and back in
- Confirm your user can run
docker
commands withoutsudo
, eg:docker run hello-world
sudo systemctl enable docker.service
sudo systemctl enable containerd.service
I also enabled the “local” logging driver with "log-driver": "local"
in Docker’s daemon.json
.
And finally, because I’m a lazy typist, I enabled bash completions for docker commands with docker completion bash > /etc/bash_completion.d/docker-compose.sh
(run in a root
shell).
Set up sendmail, logwatch, and sysstat
These aren’t necessary, but I wanted some form of regular monitoring of my server, via a daily/weekly email.
Setting these three things up is a little beyond the scope of this post – but maybe I’ll write another one about them, specifically, later!
Set up Caddy via docker-compose
I created a directory at /opt/personal-hosting
to store my docker provisioning stuff, and also initialised that as a Git repo, so that I could track changes, and pull it to my local machine, to make editing easier.
I also created a directory at /srv
to store the source files for the simpler domains I wanted to host (eg: Jekyll’s static output for this blog, and the PHP source files for my parents’ website).
In all, the files and directories of interest were:
/
├ opt/
│ └ personal-hosting/
│ ├ etc-caddy/
│ │ ├ access_log.conf
│ │ ├ Caddyfile
│ │ └ security_headers.conf
│ ├ script/
│ │ └ caddy-reload
│ └ docker-compose.yml
├ srv/
│ ├ zarino.co.uk/
│ │ └ …
│ └ zappia.co.uk/
│ └ …
└ var/
└ log/
└ caddy/
docker-compose.yml
My initial docker-compose.yml
looked like this:
services:
caddy:
container_name: caddy
hostname: caddy
image: caddy:latest
restart: unless-stopped
depends_on:
- php-fpm
cap_add:
- NET_ADMIN
ports:
- "80:80"
- "443:443"
- "443:443/udp"
networks:
- caddynet
volumes:
# Share directory containing Caddyfile, rather than Caddyfile itself,
# because of https://github.com/caddyserver/caddy-docker/issues/364
- ./etc-caddy:/etc/caddy:ro
# Share vhost directories, to serve static files from.
- /srv:/srv
# Share /var/log/caddy (created with `mkdir` and chmodded to be writeable).
- /var/log/caddy:/var/log/caddy
# Persist Caddy data and config across container restarts.
- caddy_data:/data
- caddy_config:/config
php-fpm:
container_name: php-fpm
hostname: php-fpm
image: php:fpm
restart: unless-stopped
networks:
- caddynet
volumes:
- /srv:/var/www/html
networks:
caddynet:
attachable: true
driver: bridge
volumes:
caddy_data:
caddy_config:
With this, I am able to start and stop the entire set of containers with:
docker compose up -d
docker compose down
Or start/stop an individual container with, eg:
docker compose up -d caddy
docker compose down caddy
Most professional docker images are set to send their logging output to stdout, so you can read that output with, eg:
docker compose logs -f --tail 20 caddy
Commands that I run often, I tend to put into their own file, eg: script/caddy-reload
, which I run every time I’ve edited my Caddyfile:
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
Of course I can also just SSH into the container for a service, if I need to do anything more involved, eg:
docker compose exec caddy bash
Caddy config
My Caddyfile
looks like this:
{
log default {
output stdout
format json
}
}
www.zarino.co.uk {
redir https://zarino.co.uk{uri}
}
zarino.co.uk {
import access_log.conf "zarino.co.uk"
import security_headers.conf
encode zstd gzip
root * /srv/zarino.co.uk
file_server
handle_errors {
rewrite * /{err.status_code}/
file_server
}
}
www.zappia.co.uk {
redir https://zappia.co.uk{uri}
}
zappia.co.uk {
import access_log.conf "zappia.co.uk"
import security_headers.conf
encode zstd gzip
root * /srv/zappia.co.uk
php_fastcgi php-fpm:9000 {
# Tell php-fpm where to find the PHP files _inside_ the Docker container.
# (Our docker-compose.yml maps /srv on the host to /var/www/html inside the container.)
root /var/www/html/zappia.co.uk
}
file_server
}
To save repeating the same config options again and again for each domain Caddy is hosting, I broke those out into their own partial files I could then import. Namely, access_log.conf
:
log {
output file /var/log/caddy/{args[0]}.log
}
And security_headers.conf
:
header /* {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options nosniff
X-Frame-Options sameorigin
Referrer-Policy strict-origin-when-cross-origin
Content-Security-Policy "default-src https:; font-src https: data:; img-src https: data: 'self' about:; script-src 'unsafe-inline' https: data:; style-src 'unsafe-inline' https:; connect-src https: data: 'self'"
}
Caddy handles the registration and management of SSL certificates for every domain, automatically. Which is, frankly, witchcraft.
Other things to note:
- The Caddyfile format is a breath of fresh air compared to nginx configs or (god forbid) Apache configs, but it still has its own quirks. In particular, note that directives inside your site blocks are re-ordered by Caddy before being applied, which can result in unexpected behaviour. I try to ensure the order of directives inside my blocks roughly matches the order that Caddy expects them, so there’s less opportunity for surprise.
- The
zarino.co.uk
site is simply hosting a bunch of static HTML, CSS, and image files, pre-compiled by Jekyll. So all it needs is afile_server
block to handle that. - The
zappia.co.uk
site, in comparison, is a PHP site. So it needs both thephp_fastcgi
block, and thefile_server
directive for any non-PHP static files. - The
php_fastcgi
block is communicating with thephp-fpm
container, over port9000
. It’s really nice being able to refer to services from mydocker-compose.yml
file, by their hostname, in thisCaddyfile
– especially when you set up containers for each WordPress site you’re hosting, for example.