I operate a few different static websites, relatively low-traffic ones. So far these sites were hosted on managed hosting providers, including Netlify, Cloudflare Pages, and Bunny CDN. Last weekend I moved most of them over to an Ubuntu Linux VPS managed using pyinfra, where they're being served using Caddy.
In this post I'll walk through why I made the switch, how the new setup works, what I like about it, and how Caddy makes hosting multiple domains from one server surprisingly painless.
Why move away from managed hosting
Managed hosting is a fine way to serve websites, especially if the contents are static. For the longest time, Netlify was my go-to option for serving static sites. Over time though, I started getting bothered by the fact that I was adapting more and more of my workflows according to how the provider worked instead of what I actually needed.
This was visible in mainly two aspects - configuration and deployment workflows.
Every hosting provider has their own way of setting configuration. If you need to set a
specific HTTP header, for instance, you either define it in their user interface
somewhere or put it in a configuration file. Netlify, for instance, has a netlify.toml
where all this stuff can go. Maybe it's just because I'm older now, but over time I've
come to the realization that I want less configuration in my life, not more. I
want things to just work, and the less custom configuration there is the better.
Deployment workflows were a similar story. When setting up automatic deployments I need to allow access to the Github/Gitlab repository. One of my static sites was hosted on Bunny CDN backed by a pull zone and a storage bucket. Since Bunny doesn't provide a native way to deploy static sites, I had Claude vibe-code a Python script that would copy files from the local disk to the bucket and then manually purge the cache. It worked fine, but it was yet another thing for me to maintain.
Setting up the VPS
In my mind, the only logical solution was to set up a Linux VPS and put those static sites behind a proxy server. I've operated Linux servers in the past, and self-hosting static websites is really not a lot of work. It'll still involve configuring bits and pieces, but at least the configuration would not be tied to a hosting provider.
So I looked around for cheap VPS providers, ideally based in the EU, and came across Netcup. They offered a plan costing €3 per month for a server with 2 CPU cores, 2GB RAM, and 60GB SSD, which sounded more than enough for what I needed, so I provisioned a VPS on that plan and got to work.
The first step was to generate an SSH key and copy it over to the VPS for password-less login. Next, I set up a few infrastructure definitions using pyinfra so that all the changes I make to the server are contained in source files somewhere.
Setting up pyinfra inventory
When using pyinfra, the host configuration is usually put in something called an "inventory", that defines the IP address of the VPS along with the SSH configuration needed to log in to it.
# inventory.py
import os.path
servers = [
(
"1.2.3.4",
{
"ssh_user": "username",
"ssh_key": os.path.expanduser("~/.ssh/id_rsa"),
"_sudo": True,
},
),
]
It's basically just a Python list of servers where each entry is a tuple containing the server's IP address and SSH arguments. With this inventory in place, pyinfra is able to log in to the VPS on our behalf to perform actions.
Setting up pyinfra Operations
The next step after configuring a pyinfra inventory is to define a few operations. Operations are how we can instruct pyinfra to take actions and make changes on the target machines.
Below is the complete deploy.py with all operations. Each section is explained
afterward.
# deploy.py
from pyinfra.operations import apt, files, server, systemd
apt.packages(
name="Ensure base packages", packages=["caddy", "rsync"], update=True
)
server.shell(
name="Set up ufw",
commands=[
"ufw default deny incoming",
"ufw default allow outgoing",
"ufw allow 22/tcp comment 'SSH'",
"ufw allow 80/tcp comment 'HTTP'",
"ufw allow 443/tcp comment 'HTTPS'",
"ufw enable",
],
)
files.directory(
path=f"/srv/example.com",
present=True,
recursive=True,
user="caddy",
group="caddy",
)
caddyfile = files.template(
name="Update Caddy configuration",
src="Caddyfile",
dest="/etc/caddy/Caddyfile",
)
systemd.service(
name="Start and enable Caddy service",
service="caddy.service",
running=True,
enabled=True,
restarted=caddyfile.will_change,
daemon_reload=caddyfile.will_change,
)
Here's what each section does.
Setting up a firewall
The server.shell block configures ufw to deny all incoming traffic by default and
allow outgoing traffic, then opens ports 22 (SSH), 80 (HTTP), and 443 (HTTPS).
pyinfra currently does not have a native operation for ufw, the rationale being that
ufw rules ultimately translate to operations.iptables rules. I tried using
iptables but ran into a bunch of errors, so I decided to just call ufw via
operations.server and move on.
Beyond the firewall, it may be worth spending a bit more time on hardening SSH access. Things like disabling password authentication and optionally changing the default port would significantly reduce brute-force attempts.
Installing Caddy and hosting multiple domains
After installing Caddy, it's likely that Caddy's default configuration is not what you want since you probably have your own websites you'd like to deploy.
Serving static sites is quite straightforward using Caddy. Assuming that the contents of
your static website (HTML, CSS, JS, etc.) are at /srv/example.com, here is a minimal
(but fully functional) Caddy configuration:
example.com {
root * /srv/example.com
encode gzip
file_server
log {
format console
output file /var/log/caddy/example.com.log {
roll_size 10MB
}
}
}
www.example.com {
redir https://example.com{uri} permanent
}
Copy the contents of the snippet above into a local file (let's name it Caddyfile)
next to inventory.py and deploy.py.
Looking back at the deploy.py script, the apt.packages call installs Caddy and
rsync (we'll need rsync later for deployments). The files.directory call creates
/srv/example.com and makes
sure that this directory is owned by the caddy user and group. This is where the
contents of our static site would go, and the correct user/group permissions are
important for Caddy to be able to read (and serve) files from this directory.
Next, files.template copies the Caddyfile from local disk to
/etc/caddy/Caddyfile, and systemd.service runs and enables the Caddy service,
also making sure that whenever the contents of Caddyfile change, the associated
systemd service and the systemd daemon itself is restarted.
One thing to keep in mind: make sure your domain's DNS records (an A record for the apex
and a CNAME for www) point to the VPS IP address before starting Caddy. Caddy needs
the domain to resolve before it can provision TLS certificates automatically.
Deployment Workflow
After all this is in place, the last remaining piece is how to deploy your website. The
contents of the site will be deployed to /srv/example.com on the VPS, so the
"deployment" process is nothing more than copying a bunch of files from the local disk
to the VPS, which is exactly where rsync shines!
There are a few different ways to use rsync to copy contents over. I've personally
defined a deploy-site bash script that looks as follows:
#!/usr/bin/env bash
set -e
if [ $# -ne 2 ]; then
echo "Usage: $(basename "$0") <source_dir> <domain>" >&2
exit 1
fi
if [ ! -d "$1" ]; then
echo "Error: source directory '$1' does not exist" >&2
exit 1
fi
rsync -avz --progress --rsync-path "sudo -u caddy rsync" "${1%/}/" "vps:/srv/$2"
and placed it at $HOME/.local/bin/deploy-site. Whenever I start a new project,
independent of how the final HTML/CSS/... artifacts are generated, I call this script
like deploy-site dist/ example.com, that calls rsync, and within just a few seconds
the site is deployed.
Note that the --rsync-path "sudo -u caddy rsync" flag requires the remote user to have
passwordless sudo, which you'll need to set up if you haven't done so already.
Conclusion
All in all, the migration took less than a day and the setup has been running without issues since. The next thing I'd like to add is some form of monitoring, for instance Prometheus metrics to keep track of CPU and memory usage, just for fun. Maybe that's what the next post will be about!