I’ve deployed my personal blog in several different places over the years – Netlify, Vercel, and GitHub pages. Each one of these platforms made it easy to get the site up and running, but even with the ease of setup, it was surprisingly annoying to get simple analytics over how many people were reading my blog posts.
Netlify required a paid subscription to view analytics, which I used for a while, but it was annoying that it would only show me analytics for the past 7 days. Plus, I really don’t like Netlify’s credit-based pricing model. I find it very confusing.
Vercel isn’t much better when it comes to ease of understanding their pricing. Their pricing page is filled with dozens and dozens of rows of different kinds of things you can get charged for.
GitHub pages at least wouldn’t hit me with any surprise bills, but they don’t provide any kind of analytics at all. For a while, I hosted my blog on GitHub pages and used Google Analytics to track my blog traffic, but something about that felt dirty. I want my blog to be a place where people can easily read interesting articles and learn something new without their data being packaged and sold by a tech giant. All I really wanted was to know which of my blog articles were read the most so I could write more content that people found useful.
VPS
With AI-assisted coding having improved so much in the past couple of years, I decided to go back to basics and think about how I would host my blog if none of these cloud-hosting providers were options. A simple web server running on a virtual private server (VPS) would do the trick, so that's exactly what I decided to do. It would be more setup than GitHub pages, but with Claude this would be a lot easier than it would have been a few years ago. I’d have full control and web servers like Nginx and Caddy log requests to the server in a file on disk. If I wanted analytics, all I’d need was a simple aggregator over the log file to see how many people were visiting my blog.
After some research, I purchased a Hetzner VPS running Ubuntu Linux. I connected to it with SSH and came up with a few steps to get my blog running:
- Secure the VPS to protect it from malicious traffic.
- Get my blog’s assets on the VPS.
- Set up a web server to respond to requests and serve the static assets. I decided to go with Caddy for my web server.
Securing the VPS
There are endless numbers of bots on the internet scanning for servers and trying to attack them, so it was important that I secured my VPS from unwanted traffic.
Hetzner supports uploading a setup script as a YAML file that defines what your new server should do when it’s created, so this is where I could add my security steps. I had Claude help me create the setup script. It does a few things:
- Only allows key-based SSH access, not password. That way bots can’t spam my server and get access by guessing a password.
- Set up Tailscale so I can connect to the VPS while I’m on my Tailscale network.
- Use
ufw(uncomplicated firewall) to define which ports on the box are available for traffic. Ports 80 and 443 are open for web traffic, 22 for SSH, 41641 for Tailscale, and all other ports are closed. - Use
fail2banto automatically block IP addresses of machines that repeatedly try and fail to gain access to the server.
Here’s the script Claude gave me:
#cloud-config
# Hetzner Cloud -> Tailscale node
#
# REPLACE THREE THINGS:
# <SSH_PUBKEY> your public key, e.g. ssh-ed25519 AAAAC3Nza... you@laptop
# <TS_AUTHKEY> single-use, tagged pre-auth key from the Tailscale admin console
# ts-hetzner hostname (optional)
hostname: ts-hetzner
timezone: Etc/UTC
users:
- name: ops
groups: [users, sudo]
sudo: "ALL=(ALL) NOPASSWD:ALL"
shell: /bin/bash
lock_passwd: true
ssh_authorized_keys:
- <SSH_PUBKEY>
disable_root: true
ssh_pwauth: false
package_update: true
package_upgrade: true
packages:
- ufw
- fail2ban
- unattended-upgrades
- curl
write_files:
# 00- prefix matters: in sshd_config the FIRST value for a keyword wins,
# so this must sort before cloud-init's own 50-cloud-init.conf.
- path: /etc/ssh/sshd_config.d/00-hardening.conf
permissions: "0644"
content: |
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
AuthenticationMethods publickey
MaxAuthTries 3
X11Forwarding no
AllowAgentForwarding no
AllowUsers ops
- path: /etc/fail2ban/jail.local
permissions: "0644"
content: |
[sshd]
enabled = true
backend = systemd
maxretry = 3
bantime = 1h
- path: /etc/apt/apt.conf.d/20auto-upgrades
permissions: "0644"
content: |
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";
runcmd:
- curl -fsSL https://tailscale.com/install.sh | sh
- tailscale up --auth-key="<TS_AUTHKEY>" --ssh --accept-dns=false --hostname="$(hostname)"
- tailscale set --auto-update
- ufw default deny incoming
- ufw default allow outgoing
- ufw allow in on tailscale0
- ufw allow 41641/udp comment 'tailscale direct connections'
- ufw limit 22/tcp comment 'ssh'
- ufw allow 80/tcp comment 'http (caddy)'
- ufw allow 443/tcp comment 'https (caddy)'
- ufw --force enable
- systemctl enable --now fail2ban
- systemctl enable --now unattended-upgrades
power_state:
mode: reboot
condition: true
delay: now
message: cloud-init finished, rebooting
If you use this script, you'll just need to replace <SSH_PUBKEY> with your public key. Tailscale is optional, but if you do decide to add your machine to your Tailscale network then you'll replace <TS_AUTHKEY> with an authentication key you can generate in the Tailscale dashboard.
Creating a deployment script
With the VPS secure, I needed to get my blog's static assets on the box so they could be served by a web server. My VPS only has 2GB of RAM, which was more than enough to run a simple static blog, but I wanted to avoid building the assets on the box itself. What would be better is to build the assets on my PC and then sync the built assets to the VPS.
I had Claude generate a simple script I could invoke that would do that.
#!/usr/bin/env bash
# Build the static export and rsync it to the Hetzner server that serves
# brettfisher.dev via Caddy. Run from anywhere: `npm run deploy`.
#
# Override the target with DEPLOY_HOST / DEPLOY_PATH, e.g.
# DEPLOY_HOST=user@<ip address> ./deploy.sh
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
HOST="${DEPLOY_HOST:-hetzner}"
DEST="${DEPLOY_PATH:-/var/www/brettfisher.dev}"
cd "$REPO_ROOT"
npm run build
# Trailing slash on out/ copies its contents, not the directory itself.
# --delete removes files on the server that no longer exist in the build.
rsync -az --delete --info=stats1,progress0 out/ "$HOST:$DEST/"
echo "deployed to $HOST:$DEST"
HOST defaults to hetzner, which I have as an alias for the box in my SSH config.
This blog is made with NextJS which typically requires a NextJS process running on the server, but since this blog is all static assets, I could just create a static build with npm run build and serve the built assets on the VPS. This script uses rsync to sync all changed files to the directory /var/www/brettfisher.dev on the VPS.
I also have this in my next.config.ts:
import type { NextConfig } from "next";
import { dirname } from "path";
import { fileURLToPath } from "url";
const nextConfig: NextConfig = {
output: "export", // enable static build
trailingSlash: true // map /about to /about.html, etc.
};
export default nextConfig;
Before running the script, I created the /var/www/brettfisher.dev directory on the VPS:
sudo mkdir -p /var/www/brettfisher.dev
sudo chown ops:ops /var/www/brettfisher.dev
I also added a deploy script to my package.json that calls this script so I could deploy by running npm run deploy.
Set up Caddy
Caddy is a lightweight web server, and what I really like about it is that it automatically grabs and renews TLS certificates so that HTTPS would work without any hassle.
I installed Caddy by following the instructions on their website:
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg
sudo chmod o+r /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install caddy
Then I had Claude write a Caddyfile for me which I pasted into /etc/caddy/Caddyfile:
www.brettfisher.dev {
redir https://brettfisher.dev{uri} permanent
}
brettfisher.dev {
root * /var/www/brettfisher.dev
encode zstd gzip
header X-Served-By hetzner
log {
output file /var/log/caddy/brettfisher.dev.log {
roll_size 50mb
roll_keep 10
}
}
handle_errors {
@404 expression {http.error.status_code} == 404
rewrite @404 /404.html
file_server
}
file_server
}
This serves the static assets at /var/www/brettfisher.dev and logs requests to /var/log/caddy/brettfisher.dev.log.
I reloaded Caddy so it picked up the new Caddyfile config with sudo systemctl reload caddy. Then I updated the DNS records for brettfisher.dev to point to my VPS's IP address and verified that everything was working.
Each line in the logfile is a JSON object with information about what was requested, so when I want to see analytics I'll have Claude write a simple script for me that will aggregate the requests in the logfile and show me the number of requests for each post across the time period I'm interested in (something like 7 days). No Google Analytics or pricey cloud provider needed!