Initial commit: Hugo site with Caddy infra and deploy tooling

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Louis Simoneau
2026-04-08 19:45:03 +10:00
commit b090231557
14 changed files with 770 additions and 0 deletions

20
.gitignore vendored Normal file
View File

@@ -0,0 +1,20 @@
# Hugo build output
site/public/
site/resources/
site/.hugo_build.lock
# Claude Code
.claude/
# Environment
.env
# OS
.DS_Store
Thumbs.db
# Editor
.vscode/
.idea/
*.swp
*.swo

102
CLAUDE.md Executable file
View File

@@ -0,0 +1,102 @@
# Monotrope
Personal blog and server infrastructure for monotrope.au.
## Project Structure
```
monotrope/
site/ # Hugo site (content, templates, config)
infra/ # Server setup scripts and config files
deploy.sh # Build + rsync to production
Makefile # Common tasks
```
## Tech Stack
- **Static site generator:** Hugo
- **Web server:** Caddy (automatic HTTPS via Let's Encrypt)
- **Hosting:** DigitalOcean droplet (Sydney region, Ubuntu 24.04 LTS)
- **Deployment:** `hugo build` then `rsync` to server
- **Future services:** Docker Compose for anything beyond the blog
## Setup Tasks
### 1. Initialise the repo
- Create the directory structure above
- `git init` with a sensible `.gitignore` (Hugo output dir `public/`, `.env`, etc.)
- Initialise a new Hugo site inside `site/`
- Use a minimal theme or no theme — we'll build templates from scratch later
- Hugo config should set `baseURL = "https://monotrope.au"`
### 2. Create a minimal Hugo site
- A single layout (`layouts/_default/baseof.html`, `single.html`, `list.html`)
- Minimal, clean CSS — no framework. Readable prose typography, dark mode support via `prefers-color-scheme`
- A sample first post in `site/content/posts/` so we can test the build
- RSS feed enabled (Hugo does this by default)
- No JavaScript unless strictly necessary
### 3. Server provisioning script
Create `infra/setup.sh` — a bash script intended to be run once on a fresh Ubuntu 24.04 droplet via SSH. It should:
- Update packages
- Install Caddy via the official apt repo
- Create a `www` user with no login shell to own the site files
- Create `/var/www/monotrope` owned by `www`
- Install a Caddyfile at `/etc/caddy/Caddyfile` that:
- Serves `monotrope.au` from `/var/www/monotrope`
- Enables gzip/zstd compression
- Sets sensible cache headers for static assets
- Handles `www.monotrope.au` redirect to apex
- Enable and start the Caddy service
- Set up UFW: allow SSH, HTTP, HTTPS, deny everything else
- Install Docker and Docker Compose (for future use, not needed yet)
- Create a deploy user with SSH key auth and permission to write to `/var/www/monotrope`
Also create `infra/Caddyfile` as a standalone config file that the setup script copies into place.
### 4. Deploy script
Create `deploy.sh` at the repo root:
- Run `hugo --minify` in `site/`
- `rsync -avz --delete site/public/ deploy@<DROPLET_IP>:/var/www/monotrope/`
- Print the URL on success
The droplet IP should come from an environment variable `MONOTROPE_HOST` or a `.env` file (not committed).
### 5. Makefile
Targets:
- `make build` — build the site locally
- `make serve``hugo server` for local dev with live reload
- `make deploy` — run `deploy.sh`
- `make ssh` — SSH into the droplet as the deploy user
- `make setup` — run the provisioning script on a fresh droplet
## Conventions
- All shell scripts should use `set -euo pipefail`
- Prefer clarity over cleverness in all scripts
- No unnecessary dependencies — this should stay simple
- Australian English in all content and comments
- Markdown content lives in `site/content/`; one subdirectory per content type (e.g. `posts/`, `pages/`)
## DNS
The user will configure DNS separately via their registrar. The server expects:
- `monotrope.au` → droplet IP (A record)
- `www.monotrope.au` → droplet IP (A record)
Caddy will handle certificate provisioning automatically once DNS is pointed.
## What This Is Not
- No CI/CD pipeline — deploy is manual via `make deploy`
- No containerisation of the blog itself — it's static files
- No database
- No analytics (for now)
- No comments system (for now)

26
Makefile Normal file
View File

@@ -0,0 +1,26 @@
.PHONY: build serve deploy ssh setup
# Load .env if it exists
-include .env
export
DEPLOY_USER := deploy
MONOTROPE_HOST ?=
build:
cd site && hugo --minify
serve:
cd site && hugo server --buildDrafts --disableFastRender
deploy:
@test -n "$(MONOTROPE_HOST)" || (echo "Error: MONOTROPE_HOST is not set"; exit 1)
bash deploy.sh
ssh:
@test -n "$(MONOTROPE_HOST)" || (echo "Error: MONOTROPE_HOST is not set"; exit 1)
ssh $(DEPLOY_USER)@$(MONOTROPE_HOST)
setup:
@test -n "$(MONOTROPE_HOST)" || (echo "Error: MONOTROPE_HOST is not set"; exit 1)
ansible-playbook -i "$(MONOTROPE_HOST)," -u root infra/ansible/playbook.yml

33
deploy.sh Executable file
View File

@@ -0,0 +1,33 @@
#!/usr/bin/env bash
set -euo pipefail
# deploy.sh — Build and deploy monotrope.au to the production droplet
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Load .env if present
if [[ -f "$SCRIPT_DIR/.env" ]]; then
# shellcheck disable=SC1091
source "$SCRIPT_DIR/.env"
fi
MONOTROPE_HOST="${MONOTROPE_HOST:-}"
DEPLOY_USER="deploy"
REMOTE_DIR="/var/www/monotrope"
if [[ -z "$MONOTROPE_HOST" ]]; then
echo "Error: MONOTROPE_HOST is not set."
echo "Set it in your environment or in a .env file at the repo root."
exit 1
fi
echo "==> Building site"
cd "$SCRIPT_DIR/site"
hugo --minify
echo "==> Deploying to ${DEPLOY_USER}@${MONOTROPE_HOST}:${REMOTE_DIR}"
rsync -avz --delete "$SCRIPT_DIR/site/public/" \
"${DEPLOY_USER}@${MONOTROPE_HOST}:${REMOTE_DIR}/"
echo ""
echo "==> Done. Live at https://monotrope.au"

24
infra/Caddyfile Normal file
View File

@@ -0,0 +1,24 @@
monotrope.au {
root * /var/www/monotrope
file_server
# Compression
encode zstd gzip
# Cache headers for static assets
@static {
path *.css *.js *.ico *.gif *.jpg *.jpeg *.png *.webp *.svg *.woff *.woff2 *.ttf *.eot
}
header @static Cache-Control "public, max-age=31536000, immutable"
# HTML and RSS — revalidate each time
@html {
path *.html / /posts/ /posts/*
}
header @html Cache-Control "public, max-age=0, must-revalidate"
}
# Redirect www to apex
www.monotrope.au {
redir https://monotrope.au{uri} permanent
}

188
infra/ansible/playbook.yml Normal file
View File

@@ -0,0 +1,188 @@
---
- name: Provision monotrope.au server
hosts: all
become: true
vars:
site_dir: /var/www/monotrope
deploy_user: deploy
deploy_pubkey: "{{ lookup('file', lookup('env', 'HOME') + '/.ssh/id_ed25519.pub') }}"
tasks:
# ── System ──────────────────────────────────────────────────────────────
- name: Update apt cache and upgrade packages
apt:
update_cache: true
upgrade: dist
cache_valid_time: 3600
- name: Install common dependencies
apt:
name:
- debian-keyring
- debian-archive-keyring
- apt-transport-https
- curl
- ufw
state: present
# ── Caddy ───────────────────────────────────────────────────────────────
- name: Add Caddy GPG key
shell: |
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
| gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
args:
creates: /usr/share/keyrings/caddy-stable-archive-keyring.gpg
- name: Add Caddy apt repository
shell: |
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
| tee /etc/apt/sources.list.d/caddy-stable.list
args:
creates: /etc/apt/sources.list.d/caddy-stable.list
- name: Install Caddy
apt:
name: caddy
state: present
update_cache: true
- name: Install Caddyfile
copy:
src: ../Caddyfile
dest: /etc/caddy/Caddyfile
owner: root
group: caddy
mode: '0640'
notify: Restart Caddy
- name: Enable and start Caddy
systemd:
name: caddy
enabled: true
state: started
# ── Site directory ───────────────────────────────────────────────────────
- name: Create www system user
user:
name: www
system: true
create_home: false
shell: /usr/sbin/nologin
state: present
# ── UFW ─────────────────────────────────────────────────────────────────
- name: Set UFW default incoming policy to deny
ufw:
default: deny
direction: incoming
- name: Set UFW default outgoing policy to allow
ufw:
default: allow
direction: outgoing
- name: Allow SSH
ufw:
rule: allow
name: OpenSSH
- name: Allow HTTP
ufw:
rule: allow
port: '80'
proto: tcp
- name: Allow HTTPS
ufw:
rule: allow
port: '443'
proto: tcp
- name: Enable UFW
ufw:
state: enabled
# ── Docker ──────────────────────────────────────────────────────────────
- name: Create Docker keyring directory
file:
path: /etc/apt/keyrings
state: directory
mode: '0755'
- name: Add Docker GPG key
shell: |
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
| gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
args:
creates: /etc/apt/keyrings/docker.gpg
- name: Add Docker apt repository
shell: |
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
| tee /etc/apt/sources.list.d/docker.list
args:
creates: /etc/apt/sources.list.d/docker.list
- name: Install Docker
apt:
name:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-buildx-plugin
- docker-compose-plugin
state: present
update_cache: true
- name: Enable Docker
systemd:
name: docker
enabled: true
state: started
# ── Deploy user ──────────────────────────────────────────────────────────
- name: Create deploy user
user:
name: "{{ deploy_user }}"
create_home: true
shell: /bin/bash
state: present
- name: Set up deploy user SSH directory
file:
path: "/home/{{ deploy_user }}/.ssh"
state: directory
owner: "{{ deploy_user }}"
group: "{{ deploy_user }}"
mode: '0700'
- name: Install deploy user SSH public key
ansible.posix.authorized_key:
user: "{{ deploy_user }}"
key: "{{ deploy_pubkey }}"
state: present
- name: Create site directory
file:
path: "{{ site_dir }}"
state: directory
owner: "{{ deploy_user }}"
group: www
mode: '0775'
handlers:
- name: Restart Caddy
systemd:
name: caddy
state: restarted

97
infra/setup.sh Executable file
View File

@@ -0,0 +1,97 @@
#!/usr/bin/env bash
set -euo pipefail
# setup.sh — Provision a fresh Ubuntu 24.04 droplet for monotrope.au
# Run as root via: ssh root@<DROPLET_IP> 'bash -s' < infra/setup.sh
DEPLOY_USER="deploy"
SITE_DIR="/var/www/monotrope"
DEPLOY_PUBKEY="${DEPLOY_PUBKEY:-}" # Set this env var before running, or edit below
echo "==> Updating packages"
apt-get update -y
apt-get upgrade -y
# ── Caddy ─────────────────────────────────────────────────────────────────
echo "==> Installing Caddy"
apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
| gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
| tee /etc/apt/sources.list.d/caddy-stable.list
apt-get update -y
apt-get install -y caddy
# ── Site directory ─────────────────────────────────────────────────────────
echo "==> Creating www user and site directory"
id -u www &>/dev/null || useradd --system --no-create-home --shell /usr/sbin/nologin www
mkdir -p "$SITE_DIR"
chown www:www "$SITE_DIR"
chmod 755 "$SITE_DIR"
# ── Caddyfile ──────────────────────────────────────────────────────────────
echo "==> Installing Caddyfile"
cp "$(dirname "$0")/Caddyfile" /etc/caddy/Caddyfile
chown root:caddy /etc/caddy/Caddyfile
chmod 640 /etc/caddy/Caddyfile
systemctl enable caddy
systemctl restart caddy
# ── UFW ────────────────────────────────────────────────────────────────────
echo "==> Configuring UFW"
apt-get install -y ufw
ufw default deny incoming
ufw default allow outgoing
ufw allow ssh
ufw allow http
ufw allow https
ufw --force enable
# ── Docker ────────────────────────────────────────────────────────────────
echo "==> Installing Docker"
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
| gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
| tee /etc/apt/sources.list.d/docker.list
apt-get update -y
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
systemctl enable docker
# ── Deploy user ───────────────────────────────────────────────────────────
echo "==> Creating deploy user"
id -u "$DEPLOY_USER" &>/dev/null || useradd --create-home --shell /bin/bash "$DEPLOY_USER"
# Give deploy user write access to the site directory
chown -R "$DEPLOY_USER":www "$SITE_DIR"
chmod 775 "$SITE_DIR"
# Set up SSH key auth
DEPLOY_HOME="/home/$DEPLOY_USER"
mkdir -p "$DEPLOY_HOME/.ssh"
chmod 700 "$DEPLOY_HOME/.ssh"
touch "$DEPLOY_HOME/.ssh/authorized_keys"
chmod 600 "$DEPLOY_HOME/.ssh/authorized_keys"
chown -R "$DEPLOY_USER":"$DEPLOY_USER" "$DEPLOY_HOME/.ssh"
if [[ -n "$DEPLOY_PUBKEY" ]]; then
echo "$DEPLOY_PUBKEY" >> "$DEPLOY_HOME/.ssh/authorized_keys"
echo "==> Deploy public key installed"
else
echo "WARNING: DEPLOY_PUBKEY not set. Add your public key to $DEPLOY_HOME/.ssh/authorized_keys manually."
fi
echo ""
echo "==> Done. Checklist:"
echo " - Point DNS A records for monotrope.au and www.monotrope.au to this server's IP"
echo " - If DEPLOY_PUBKEY was not set, add your key to $DEPLOY_HOME/.ssh/authorized_keys"
echo " - Run 'make deploy' from your local machine to push the site"

View File

@@ -0,0 +1,7 @@
---
title: "Hello, World"
date: 2026-04-08
draft: false
---
This is the first post on Monotrope. More to come.

13
site/hugo.toml Normal file
View File

@@ -0,0 +1,13 @@
baseURL = "https://monotrope.au"
languageCode = "en-au"
title = "Monotrope"
[markup.goldmark.renderer]
unsafe = false
[outputs]
home = ["HTML", "RSS"]
section = ["HTML", "RSS"]
[params]
description = "A personal blog."

View File

@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="en-AU">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ if not .IsHome }}{{ .Title }} · {{ end }}{{ .Site.Title }}</title>
<meta name="description" content="{{ with .Description }}{{ . }}{{ else }}{{ .Site.Params.description }}{{ end }}">
<link rel="stylesheet" href="/css/main.css">
{{ range .AlternativeOutputFormats -}}
{{ printf `
<link rel="%s" type="%s" href="%s" title="%s">` .Rel .MediaType.Type .Permalink $.Site.Title | safeHTML }}
{{ end -}}
</head>
<body>
<div class="site-wrap">
<header class="site-header">
<a class="site-title" href="/">{{ .Site.Title }}</a>
<nav class="site-nav">
<a href="/posts/">Writing</a>
</nav>
</header>
<main>
{{ block "main" . }}{{ end }}
</main>
<footer class="site-footer">
<p>&copy; {{ now.Year }} Monotrope. Built with <a href="https://gohugo.io">Hugo</a>.</p>
</footer>
</div>
</body>
</html>

View File

@@ -0,0 +1,11 @@
{{ define "main" }}
<h2 class="page-heading">{{ .Title }}</h2>
<ul class="post-list">
{{ range .Pages.ByDate.Reverse }}
<li>
<time datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "2006-01-02" }}</time>
<a href="{{ .Permalink }}">{{ .Title }}</a>
</li>
{{ end }}
</ul>
{{ end }}

View File

@@ -0,0 +1,9 @@
{{ define "main" }}
<article>
<h1>{{ .Title }}</h1>
<p class="post-meta">
<time datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "2 January 2006" }}</time>
</p>
{{ .Content }}
</article>
{{ end }}

10
site/layouts/index.html Normal file
View File

@@ -0,0 +1,10 @@
{{ define "main" }}
<ul class="post-list">
{{ range (where .Site.RegularPages "Type" "posts").ByDate.Reverse }}
<li>
<time datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "2006-01-02" }}</time>
<a href="{{ .Permalink }}">{{ .Title }}</a>
</li>
{{ end }}
</ul>
{{ end }}

195
site/static/css/main.css Normal file
View File

@@ -0,0 +1,195 @@
/* ── Reset & base ─────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* ── Colour tokens ────────────────────────────── */
:root {
--bg: #ffffff;
--fg: #1a1a1a;
--muted: #6b7280;
--accent: #2563eb;
--border: #e5e7eb;
--code-bg: #f3f4f6;
--max-width: 680px;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #111111;
--fg: #e5e5e5;
--muted: #9ca3af;
--accent: #60a5fa;
--border: #2d2d2d;
--code-bg: #1e1e1e;
}
}
/* ── Typography ───────────────────────────────── */
html {
font-size: 18px;
-webkit-text-size-adjust: 100%;
}
body {
background: var(--bg);
color: var(--fg);
font-family: Georgia, "Times New Roman", serif;
line-height: 1.7;
padding: 2rem 1.25rem;
}
/* ── Layout ───────────────────────────────────── */
.site-wrap {
max-width: var(--max-width);
margin: 0 auto;
}
/* ── Header ───────────────────────────────────── */
.site-header {
margin-bottom: 3rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
display: flex;
align-items: baseline;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem;
}
.site-title {
font-size: 1.1rem;
font-weight: bold;
letter-spacing: 0.02em;
text-decoration: none;
color: var(--fg);
}
.site-nav a {
color: var(--muted);
text-decoration: none;
font-size: 0.9rem;
font-family: system-ui, sans-serif;
margin-left: 1.25rem;
}
.site-nav a:hover {
color: var(--accent);
}
/* ── Footer ───────────────────────────────────── */
.site-footer {
margin-top: 4rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
color: var(--muted);
font-size: 0.8rem;
font-family: system-ui, sans-serif;
}
/* ── Post list ────────────────────────────────── */
.post-list {
list-style: none;
}
.post-list li {
margin-bottom: 1.5rem;
display: flex;
align-items: baseline;
gap: 1rem;
}
.post-list time {
color: var(--muted);
font-size: 0.85rem;
font-family: system-ui, sans-serif;
white-space: nowrap;
flex-shrink: 0;
}
.post-list a {
color: var(--fg);
text-decoration: none;
}
.post-list a:hover {
color: var(--accent);
}
/* ── Article ──────────────────────────────────── */
article h1 {
font-size: 1.75rem;
line-height: 1.25;
margin-bottom: 0.4rem;
}
.post-meta {
color: var(--muted);
font-size: 0.85rem;
font-family: system-ui, sans-serif;
margin-bottom: 2rem;
}
article h2 { font-size: 1.3rem; margin: 2rem 0 0.75rem; }
article h3 { font-size: 1.1rem; margin: 1.75rem 0 0.5rem; }
article p { margin-bottom: 1.25rem; }
article a {
color: var(--accent);
text-decoration: underline;
text-underline-offset: 2px;
}
article ul,
article ol {
padding-left: 1.5rem;
margin-bottom: 1.25rem;
}
article li { margin-bottom: 0.25rem; }
article blockquote {
border-left: 3px solid var(--border);
padding-left: 1rem;
color: var(--muted);
margin: 1.5rem 0;
}
article code {
background: var(--code-bg);
padding: 0.1em 0.35em;
border-radius: 3px;
font-size: 0.875em;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
}
article pre {
background: var(--code-bg);
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
margin-bottom: 1.25rem;
}
article pre code {
background: none;
padding: 0;
}
article hr {
border: none;
border-top: 1px solid var(--border);
margin: 2rem 0;
}
/* ── Page heading ─────────────────────────────── */
.page-heading {
font-size: 1.1rem;
color: var(--muted);
font-family: system-ui, sans-serif;
font-weight: normal;
margin-bottom: 2rem;
}