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.5gbfor 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-v6plan 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:
| Type | Host | Value |
|---|---|---|
| A | @ | YOUR_VPS_IP |
| CNAME | www | yourdomain.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.