Self-hosting static sites on a Caddy-powered VPS

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!