Setting Up This Blog: Hugo + Caddy + Alpine for $3.50/month
Technical reference for setting up a Hugo blog on Alpine Linux with Caddy. Copy-paste friendly for humans and AI assistants.
This is a technical reference for replicating this blog setup. Designed to be copy-pasted into Claude Code or similar AI assistants.
Stack
- Static site generator: Hugo
- Web server: Caddy (automatic HTTPS)
- OS: Alpine Linux
- VPS: Vultr $3.50/month (512MB RAM, 10GB disk, Newark)
- Domain registrar: Namecheap (~$10/year for .com)
Prerequisites
Local machine needs:
- Hugo installed (
apt install hugoon Ubuntu/Debian) - SSH key pair (
~/.ssh/id_ed25519or similar) - rsync installed
Part 1: Domain Registration
Register domain at Namecheap (or any registrar). DNS configuration comes after VPS creation.
Part 2: Create VPS
Via Vultr Web UI
- Deploy โ Cloud Compute โ Shared CPU
- Location: New Jersey (Newark) or nearest to audience
- Image: Alpine Linux (latest)
- Plan: Regular Cloud Compute $3.50/month
- SSH Keys: Select your key
- Hostname:
yourdomain(no TLD) - Deploy
Note the IPv4 address.
Via Vultr API
# List plans
curl -s "https://api.vultr.com/v2/plans" \
-H "Authorization: Bearer YOUR_API_KEY" | jq '.plans[] | select(.monthly_cost == 3.5)'
# Get Alpine OS ID
curl -s "https://api.vultr.com/v2/os" \
-H "Authorization: Bearer YOUR_API_KEY" | jq '.os[] | select(.name | test("Alpine"))'
# List SSH keys
curl -s "https://api.vultr.com/v2/ssh-keys" \
-H "Authorization: Bearer YOUR_API_KEY"
# Create instance
curl -s "https://api.vultr.com/v2/instances" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-X POST \
-d '{
"region": "ewr",
"plan": "vc2-1c-0.5gb",
"os_id": 2076,
"label": "yourdomain",
"hostname": "yourdomain",
"sshkey_id": ["YOUR_SSH_KEY_ID"],
"backups": "disabled"
}'
Part 3: Configure DNS
Via Namecheap Web UI
Domain List โ Manage โ Advanced DNS:
- A Record: Host
@, ValueYOUR_VPS_IP, TTL Automatic - CNAME Record: Host
www, Valueyourdomain.com, TTL Automatic
Via Namecheap API
curl -s "https://api.namecheap.com/xml.response?\
ApiUser=YOUR_USERNAME&\
ApiKey=YOUR_API_KEY&\
UserName=YOUR_USERNAME&\
ClientIp=YOUR_WHITELISTED_IP&\
Command=namecheap.domains.dns.setHosts&\
SLD=yourdomain&\
TLD=com&\
HostName1=@&RecordType1=A&Address1=YOUR_VPS_IP&TTL1=300&\
HostName2=www&RecordType2=CNAME&Address2=yourdomain.com&TTL2=300"
Part 4: Configure SSH
Add to ~/.ssh/config:
Host yourdomain
HostName YOUR_VPS_IP
User root
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yes
AddKeysToAgent yes
Test: ssh yourdomain
Part 5: Configure VPS
Run all commands via SSH on the VPS.
Install packages
apk update && apk upgrade
apk add caddy rsync curl
Create directories
mkdir -p /srv/www/yourdomain/public /var/log/caddy
Create Caddyfile
Create /etc/caddy/Caddyfile:
yourdomain.com, www.yourdomain.com {
root * /srv/www/yourdomain/public
encode zstd gzip
file_server
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
}
@static path *.css *.js *.woff *.woff2 *.png *.jpg *.svg *.ico
header @static Cache-Control "public, max-age=31536000, immutable"
try_files {path} {path}/ {path}/index.html
@www host www.yourdomain.com
redir @www https://yourdomain.com{uri} permanent
log {
output file /var/log/caddy/access.log
}
}
Set permissions and start Caddy
chown -R caddy:caddy /srv/www/yourdomain /var/log/caddy
chmod -R 755 /srv/www/yourdomain
rc-update add caddy default
rc-service caddy start
Configure firewall
apk add iptables
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -A INPUT -p tcp --dport 22 -j ACCEPT
iptables -A INPUT -p tcp --dport 80 -j ACCEPT
iptables -A INPUT -p tcp --dport 443 -j ACCEPT
iptables -A INPUT -i lo -j ACCEPT
iptables -A INPUT -p icmp -j ACCEPT
iptables -A INPUT -j DROP
rc-update add iptables default
/etc/init.d/iptables save
Part 6: Create Hugo Site Locally
Directory structure
yourdomain/
โโโ config/
โ โโโ config.toml
โโโ content/
โ โโโ posts/
โ โโโ _index.md
โ โโโ first-post.md
โโโ themes/
โ โโโ minimal-blog/
โ โโโ theme.toml
โ โโโ layouts/
โ โ โโโ _default/
โ โ โ โโโ baseof.html
โ โ โ โโโ single.html
โ โ โ โโโ list.html
โ โ โโโ partials/
โ โ โ โโโ head.html
โ โ โ โโโ header.html
โ โ โ โโโ footer.html
โ โ โโโ index.html
โ โโโ assets/
โ โโโ css/
โ โโโ style.css
โโโ scripts/
โ โโโ build.sh
โ โโโ deploy.sh
โ โโโ deploy.env
โโโ dist/
Create directories
mkdir -p yourdomain/{content/posts,themes/minimal-blog/{layouts/{_default,partials},assets/css},scripts,config,dist}
config/config.toml
baseURL = "https://yourdomain.com/"
title = "Your Blog Title"
theme = "minimal-blog"
languageCode = "en"
enableRobotsTXT = true
[outputs]
home = ["HTML", "RSS"]
section = ["HTML", "RSS"]
[params]
description = "Your blog description"
author = "Your Name"
tagline = "Your tagline"
intro = "Brief intro text for homepage."
[markup]
[markup.goldmark]
[markup.goldmark.renderer]
unsafe = true
[markup.highlight]
style = "github"
lineNos = false
themes/minimal-blog/theme.toml
[theme]
name = "Minimal Blog"
license = "MIT"
description = "Clean, text-focused blog theme"
min_version = "0.120.0"
themes/minimal-blog/layouts/_default/baseof.html
<!DOCTYPE html>
<html lang="en">
{{ partial "head.html" . }}
<body>
<div class="container">
{{ partial "header.html" . }}
<main>
{{ block "main" . }}{{ end }}
</main>
{{ partial "footer.html" . }}
</div>
</body>
</html>
themes/minimal-blog/layouts/partials/head.html
<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 .Description }}{{ . }}{{ else }}{{ .Site.Params.description }}{{ end }}">
{{ $css := resources.Get "css/style.css" | minify | fingerprint }}
<link rel="stylesheet" href="{{ $css.RelPermalink }}" integrity="{{ $css.Data.Integrity }}">
{{ range .AlternativeOutputFormats -}}
{{ printf `<link rel="%s" type="%s" href="%s" title="%s" />` .Rel .MediaType.Type .Permalink $.Site.Title | safeHTML }}
{{ end -}}
</head>
themes/minimal-blog/layouts/partials/header.html
<header class="site-header">
<nav>
<a href="{{ .Site.BaseURL }}" class="site-title">{{ .Site.Title }}</a>
{{ with .Site.Params.tagline }}<span class="tagline">{{ . }}</span>{{ end }}
</nav>
</header>
themes/minimal-blog/layouts/partials/footer.html
<footer class="site-footer">
<p>
{{ with .Site.Params.author }}{{ . }}{{ end }}
ยท <a href="{{ .Site.BaseURL }}posts/index.xml">RSS</a>
</p>
</footer>
themes/minimal-blog/layouts/index.html
{{ define "main" }}
<article class="intro">
{{ with .Site.Params.intro }}
{{ . | markdownify }}
{{ end }}
</article>
<section class="posts">
<h2>Posts</h2>
{{ range where .Site.RegularPages "Section" "posts" }}
<article class="post-summary">
<h3><a href="{{ .Permalink }}">{{ .Title }}</a></h3>
<time datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "2 January 2006" }}</time>
{{ with .Params.summary }}<p class="summary">{{ . }}</p>{{ end }}
</article>
{{ end }}
</section>
{{ end }}
themes/minimal-blog/layouts/_default/single.html
{{ define "main" }}
<article class="post">
<header class="post-header">
<h1>{{ .Title }}</h1>
<time datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "2 January 2006" }}</time>
{{ with .Params.summary }}<p class="summary">{{ . }}</p>{{ end }}
</header>
<div class="post-content">
{{ .Content }}
</div>
<footer class="post-footer">
<a href="{{ .Site.BaseURL }}">โ Back to all posts</a>
</footer>
</article>
{{ end }}
themes/minimal-blog/layouts/_default/list.html
{{ define "main" }}
<h1>{{ .Title }}</h1>
<section class="posts">
{{ range .Pages }}
<article class="post-summary">
<h3><a href="{{ .Permalink }}">{{ .Title }}</a></h3>
<time datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "2 January 2006" }}</time>
{{ with .Params.summary }}<p class="summary">{{ . }}</p>{{ end }}
</article>
{{ end }}
</section>
{{ end }}
themes/minimal-blog/assets/css/style.css
:root {
--bg: #fefefe;
--fg: #222;
--muted: #666;
--link: #1a0dab;
--link-visited: #660099;
--border: #ddd;
--max-width: 680px;
--font-body: Georgia, 'Times New Roman', serif;
--font-heading: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'SF Mono', Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1a1a1a;
--fg: #e0e0e0;
--muted: #999;
--link: #8ab4f8;
--link-visited: #c58af9;
--border: #333;
}
}
*, *::before, *::after { box-sizing: border-box; }
html { font-size: 18px; line-height: 1.6; }
body {
margin: 0;
padding: 0;
font-family: var(--font-body);
background: var(--bg);
color: var(--fg);
-webkit-font-smoothing: antialiased;
}
.container {
max-width: var(--max-width);
margin: 0 auto;
padding: 2rem 1.5rem;
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-heading);
font-weight: 600;
line-height: 1.3;
margin: 2rem 0 1rem;
}
h1 { font-size: 2rem; }
h2 { font-size: 1.5rem; }
h3 { font-size: 1.25rem; }
p { margin: 1rem 0; }
a {
color: var(--link);
text-decoration: underline;
text-underline-offset: 2px;
}
a:visited { color: var(--link-visited); }
a:hover { text-decoration-thickness: 2px; }
blockquote {
margin: 1.5rem 0;
padding-left: 1.5rem;
border-left: 3px solid var(--border);
color: var(--muted);
font-style: italic;
}
code {
font-family: var(--font-mono);
font-size: 0.9em;
background: var(--border);
padding: 0.15em 0.35em;
border-radius: 3px;
}
pre {
background: var(--border);
padding: 1rem;
overflow-x: auto;
border-radius: 4px;
}
pre code {
background: none;
padding: 0;
}
hr {
border: none;
border-top: 1px solid var(--border);
margin: 2rem 0;
}
ul, ol { padding-left: 1.5rem; }
li { margin: 0.5rem 0; }
strong { font-weight: 600; }
.site-header {
margin-bottom: 3rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border);
}
.site-header nav {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.site-title {
font-family: var(--font-heading);
font-size: 1.5rem;
font-weight: 600;
color: var(--fg);
text-decoration: none;
}
.site-title:visited { color: var(--fg); }
.tagline { color: var(--muted); font-size: 0.95rem; }
.intro {
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border);
color: var(--muted);
}
.posts h2 {
font-size: 1rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--muted);
margin-bottom: 1.5rem;
}
.post-summary { margin-bottom: 2rem; }
.post-summary h3 { font-size: 1.25rem; margin: 0 0 0.25rem; }
.post-summary h3 a { color: var(--fg); text-decoration: none; }
.post-summary h3 a:hover { text-decoration: underline; }
.post-summary time { font-size: 0.85rem; color: var(--muted); }
.post-summary .summary { margin-top: 0.5rem; color: var(--muted); font-size: 0.95rem; }
.post-header { margin-bottom: 2rem; }
.post-header h1 { margin: 0 0 0.5rem; }
.post-header time { color: var(--muted); font-size: 0.9rem; }
.post-header .summary { margin-top: 1rem; font-size: 1.1rem; color: var(--muted); font-style: italic; }
.post-content { margin-bottom: 3rem; }
.post-footer {
padding-top: 2rem;
border-top: 1px solid var(--border);
}
.post-footer a { color: var(--muted); font-size: 0.9rem; }
.site-footer {
margin-top: 4rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
font-size: 0.85rem;
color: var(--muted);
}
.site-footer a { color: var(--muted); }
@media (max-width: 600px) {
html { font-size: 16px; }
.container { padding: 1.5rem 1rem; }
h1 { font-size: 1.75rem; }
}
content/posts/_index.md
---
title: "Posts"
---
content/posts/first-post.md
---
title: "First Post"
date: 2026-01-12
summary: "Your summary here."
---
Your content here. Markdown supported.
Part 7: Build and Deploy Scripts
scripts/deploy.env
VPS_HOST="yourdomain"
VPS_DEST="/srv/www/yourdomain/public"
VPS_IP="YOUR_VPS_IP"
scripts/build.sh
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
cd "$PROJECT_ROOT"
echo "Building site..."
rm -rf dist/public
hugo --source . \
--config config/config.toml \
--destination dist/public \
--themesDir themes \
--minify
echo "Build complete: dist/public"
echo "Files: $(find dist/public -type f | wc -l)"
scripts/deploy.sh
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
cd "$PROJECT_ROOT"
source scripts/deploy.env
./scripts/build.sh
echo "Deploying to $VPS_HOST..."
rsync -avz --delete \
--compress-level=9 \
--chmod=D755,F644 \
dist/public/ \
"$VPS_HOST:$VPS_DEST/"
echo "Reloading Caddy..."
ssh "$VPS_HOST" 'rc-service caddy reload'
echo "Deploy complete: https://$(echo $VPS_HOST | sed 's/-/./')"
Make scripts executable
chmod +x scripts/build.sh scripts/deploy.sh
Part 8: Deploy
./scripts/deploy.sh
Site will be live at https://yourdomain.com with automatic HTTPS.
Publishing New Posts
- Create
content/posts/your-post-slug.mdwith frontmatter:
---
title: "Your Post Title"
date: 2026-01-15
summary: "Brief summary for post listings."
---
Your markdown content.
- Deploy:
./scripts/deploy.sh
Maintenance
Update Alpine packages
ssh yourdomain 'apk update && apk upgrade'
Check Caddy status
ssh yourdomain 'rc-service caddy status'
View access logs
ssh yourdomain 'tail -f /var/log/caddy/access.log'
Restart Caddy
ssh yourdomain 'rc-service caddy restart'
Costs
| Item | Cost |
|---|---|
| Vultr VPS | $3.50/month |
| Domain (.com) | ~$10/year |
| SSL | Free (Caddy/Let’s Encrypt) |
| Total | ~$52/year |