This page documents the setup behind hedwards.dev: Hugo builds a static site locally, rsync copies the result to a small Alpine Linux VPS, and Caddy serves it over HTTPS. The Vultr vc2-1c-0.5gb plan in Newark has 512 MB RAM, a 10 GB disk, IPv4 and a price of US$3.50/month. The infrastructure and deployment flow here match the live site. The theme code is a small functional version of the same Hugo structure rather than a copy of the live theme, which also has dark mode, contents navigation, collapsible guide sections and copy buttons. Keeping hundreds of lines of theme code duplicated inside this post was why the original guide went stale.

Stack

  • Hugo Extended on the local machine
  • Alpine Linux on Vultr
  • Caddy for static files and automatic HTTPS
  • UFW for IPv4 and IPv6 firewall rules
  • rsync over SSH for deployment
  • A registrar with editable DNS records

Local prerequisites

On Ubuntu or Kubuntu:

sudo apt update
sudo apt install hugo rsync openssh-client dnsutils

Confirm the tools are available:

hugo version
rsync --version
ssh -V

Create the VPS

In Vultr, create a Cloud Compute instance with:

  • Location: Newark, or the region nearest the intended readers
  • Image: the latest Alpine Linux release
  • Plan: vc2-1c-0.5gb for the US$3.50/month IPv4 plan
  • SSH key: the public key from the local machine
  • Hostname: the domain name without the top-level domain The cheaper vc2-1c-0.5gb-v6 plan is IPv6-only. Use it only when the DNS and client-compatibility trade-off is deliberate. Record the instance’s public IP address.

Configure DNS

At the registrar, create:

TypeHostValue
A@YOUR_VPS_IP
CNAMEwwwyourdomain.com
Confirm the records before asking Caddy to obtain certificates:
dig +short yourdomain.com A
dig +short www.yourdomain.com

This guide uses the registrar’s web interface. Namecheap’s setHosts API replaces the domain’s host-record set, so a partial call can delete unrelated MX, TXT or verification records.

Configure SSH

Add an alias to ~/.ssh/config:

Host yourdomain
  HostName YOUR_VPS_IP
  User deploy
  IdentityFile ~/.ssh/id_ed25519
  IdentitiesOnly yes
  AddKeysToAgent yes

The new instance initially accepts the provisioned key for root. Use that access to create the deployment account:

ssh root@YOUR_VPS_IP
adduser -D deploy
install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
cp /root/.ssh/authorized_keys /home/deploy/.ssh/authorized_keys
chown deploy:deploy /home/deploy/.ssh/authorized_keys
chmod 600 /home/deploy/.ssh/authorized_keys

Open another terminal and verify the alias before closing the root session:

ssh yourdomain

The hedwards.dev VPS predates this hardening and still uses root for deployment. A new installation should use the scoped account in this guide.

Configure Alpine Linux

Run the server-administration commands as root.

Install and update packages

apk update
apk upgrade
apk add caddy rsync ufw

Create the web root

install -d -m 755 -o deploy -g deploy /var/www/yourdomain.com

Configure the firewall

Allow SSH before enabling the firewall:

ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
rc-update add ufw default
ufw status verbose

UFW applies the rules to IPv4 and IPv6. Hand-written iptables rules that only cover IPv4 leave an IPv6 VPS exposed.

Configure Caddy

Replace /etc/caddy/Caddyfile with:

www.yourdomain.com {
    redir https://yourdomain.com{uri} permanent
}
yourdomain.com {
    root * /var/www/yourdomain.com
    encode gzip
    file_server
}

Validate the file before starting Caddy:

caddy validate --config /etc/caddy/Caddyfile
rc-update add caddy default
rc-service caddy start
rc-service caddy status

Caddy requests and renews the TLS certificates automatically after the DNS records resolve and ports 80/443 are reachable.

Create the Hugo site

The live project keeps content files at the root of content/, uses a local theme, and builds into dist/public/:

yourdomain/
โ”œโ”€โ”€ config/
โ”‚   โ””โ”€โ”€ config.toml
โ”œโ”€โ”€ content/
โ”‚   โ”œโ”€โ”€ _index.md
โ”‚   โ””โ”€โ”€ first-post.md
โ”œโ”€โ”€ static/
โ”œโ”€โ”€ themes/
โ”‚   โ””โ”€โ”€ minimal-blog/
โ”‚       โ”œโ”€โ”€ assets/css/style.css
โ”‚       โ””โ”€โ”€ layouts/
โ”‚           โ”œโ”€โ”€ _default/
โ”‚           โ”‚   โ”œโ”€โ”€ baseof.html
โ”‚           โ”‚   โ””โ”€โ”€ single.html
โ”‚           โ””โ”€โ”€ index.html
โ”œโ”€โ”€ scripts/
โ”‚   โ”œโ”€โ”€ build.sh
โ”‚   โ”œโ”€โ”€ deploy.env
โ”‚   โ””โ”€โ”€ deploy.sh
โ””โ”€โ”€ dist/

Create the directories:

mkdir -p yourdomain/{config,content,static,scripts,dist}
mkdir -p yourdomain/themes/minimal-blog/{assets/css,layouts/_default}
cd yourdomain

Hugo configuration

Save this as config/config.toml:

baseURL = "https://yourdomain.com/"
title = "Your Blog"
theme = "minimal-blog"
languageCode = "en-au"
enableRobotsTXT = true
[outputs]
  home = ["HTML", "RSS"]
[params]
  description = "Notes and technical guides"
  author = "Your Name"
[markup]
  [markup.highlight]
    noClasses = false

Minimal layouts

Save this as themes/minimal-blog/layouts/_default/baseof.html:

<!doctype html>
<html lang="{{ .Site.Language.LanguageCode | default "en" }}">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{{ if .IsHome }}{{ .Site.Title }}{{ else }}{{ .Title }} ยท {{ .Site.Title }}{{ end }}</title>
  <meta name="description" content="{{ with .Params.summary }}{{ . }}{{ else }}{{ .Site.Params.description }}{{ end }}">
  <link rel="canonical" href="{{ .Permalink }}">
  {{ $css := resources.Get "css/style.css" | minify | fingerprint }}
  <link rel="stylesheet" href="{{ $css.RelPermalink }}" integrity="{{ $css.Data.Integrity }}">
</head>
<body>
  <header><a href="{{ .Site.Home.RelPermalink }}">{{ .Site.Title }}</a></header>
  <main>{{ block "main" . }}{{ end }}</main>
  <footer>{{ .Site.Params.author }} ยท <a href="{{ "index.xml" | relURL }}">RSS</a></footer>
</body>
</html>

Save this as themes/minimal-blog/layouts/index.html:

{{ define "main" }}
  {{ .Content }}
  {{ range .Site.RegularPages.ByDate.Reverse }}
    <article>
      <h2><a href="{{ .RelPermalink }}">{{ .Title }}</a></h2>
      <time datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "2 January 2006" }}</time>
      {{ with .Params.summary }}<p>{{ . }}</p>{{ end }}
    </article>
  {{ end }}
{{ end }}

Save this as themes/minimal-blog/layouts/_default/single.html:

{{ define "main" }}
<article>
  <h1>{{ .Title }}</h1>
  <time datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "2 January 2006" }}</time>
  {{ .Content }}
</article>
{{ end }}

Save this as themes/minimal-blog/assets/css/style.css:

:root { color-scheme: light dark; font: 19px/1.6 Georgia, serif; }
body { max-width: 760px; margin: 0 auto; padding: 2rem 1.25rem; }
header, footer { font-family: system-ui, sans-serif; }
header { margin-bottom: 3rem; }
footer { margin-top: 4rem; }
h1, h2 { font-family: system-ui, sans-serif; line-height: 1.25; }
a { color: inherit; text-underline-offset: 0.15em; }
pre { overflow-x: auto; padding: 1rem; }
code, pre { font-family: ui-monospace, monospace; }

Initial content

Save this as content/_index.md:

---
title: "Home"
---
Notes and technical guides.

Save this as content/first-post.md:

---
title: "First post"
date: 2026-01-12
summary: "A short description used on the homepage and in page metadata."
---
Write the post in Markdown.

Build and deploy

Build script

Save this as scripts/build.sh:

#!/usr/bin/env bash
set -euo pipefail
site_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$site_dir"
rm -rf dist/public
hugo --source . \
  --config config/config.toml \
  --destination dist/public \
  --themesDir themes \
  --gc \
  --minify
find dist/public -type f -name '*.lock' -delete

Deployment settings

Save this as scripts/deploy.env:

VPS_HOST="yourdomain"
VPS_DEST="/var/www/yourdomain.com"
SITE_URL="https://yourdomain.com"

Deployment script

Save this as scripts/deploy.sh:

#!/usr/bin/env bash
set -euo pipefail
site_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$site_dir"
source scripts/deploy.env
bash scripts/build.sh
rsync -avz --delete \
  --partial \
  --chmod=D755,F644 \
  dist/public/ \
  "$VPS_HOST:$VPS_DEST/"
printf 'Deployed: %s\n' "$SITE_URL"

Make the scripts executable:

chmod +x scripts/build.sh scripts/deploy.sh

Deploy:

./scripts/deploy.sh

Static-file deployments do not require a Caddy reload. Reload Caddy only after changing its configuration.

hedwards.dev has several sibling sites, so its scripts/deploy.sh delegates to one shared deployer in the parent directory. The single-site script here performs the same build and rsync work without that repository-specific indirection.

Verify the deployment

Check the public responses:

curl -I https://yourdomain.com/
curl -I https://www.yourdomain.com/

The apex-domain request should return a successful response. The www request should redirect to the apex domain. Check the services on the VPS:

ssh root@YOUR_VPS_IP 'rc-service caddy status'
ssh root@YOUR_VPS_IP 'ufw status verbose'

Check the Caddy configuration before any later reload:

ssh root@YOUR_VPS_IP 'caddy validate --config /etc/caddy/Caddyfile'

Publish and maintain

Create each post as content/your-post-slug.md, then run ./scripts/deploy.sh. Update the VPS periodically:

ssh root@YOUR_VPS_IP 'apk update; apk upgrade'

View Caddy’s service log:

ssh root@YOUR_VPS_IP 'rc-service caddy status'
ssh root@YOUR_VPS_IP 'logread -e caddy'

Cost

The IPv4 VPS costs US$3.50/month, or US$42/year. The domain is additional and varies by top-level domain and registrar. Caddy obtains TLS certificates without a separate certificate fee.