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 hugo on Ubuntu/Debian)
  • SSH key pair (~/.ssh/id_ed25519 or 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

  1. Deploy โ†’ Cloud Compute โ†’ Shared CPU
  2. Location: New Jersey (Newark) or nearest to audience
  3. Image: Alpine Linux (latest)
  4. Plan: Regular Cloud Compute $3.50/month
  5. SSH Keys: Select your key
  6. Hostname: yourdomain (no TLD)
  7. 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 @, Value YOUR_VPS_IP, TTL Automatic
  • CNAME Record: Host www, Value yourdomain.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

  1. Create content/posts/your-post-slug.md with frontmatter:
---
title: "Your Post Title"
date: 2026-01-15
summary: "Brief summary for post listings."
---

Your markdown content.
  1. 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

ItemCost
Vultr VPS$3.50/month
Domain (.com)~$10/year
SSLFree (Caddy/Let’s Encrypt)
Total~$52/year