← back to the scrapbook issue #2026 · free · take one
saulutions.ca · project

saulutions.ca

My personal site, built with Astro, self-hosted on my homelab, with way more custom features than a personal blog probably needs

AstroSvelteTailwind CSSD2Node.jsProxmoxListmonkRemark42SMTP2Go

Live Demo GitHub

This started as a place to document Home Assistant stuff. Then it became homelab stuff in general. Then I added a newsletter. Then self-hosted comments. Then wikilinks. Then a series sidebar system. Then a daily email digest that auto-generates from new posts. At some point it stopped being “a blog” and became a project.

Here’s how everything works.

The stack

LayerWhatWhy
FrameworkAstro 5 (SSR mode)Server-renders everything, ships almost no JavaScript to the browser. Content collections mean my Markdown files are type-safe and validated at build time
Interactive bitsSvelteOnly the things that actually need JavaScript, the theme toggle and the comments widget. Everything else is just HTML and CSS
StylingTailwind CSS + Typography pluginTokyo Night color scheme, dark mode by default. Fonts are Fraunces for headings, JetBrains Mono for code, Outfit for body text
CommentsRemark42 (self-hosted)My data, no ads, no tracking. Runs on my homelab in a Docker container behind Nginx
NewsletterListmonk (self-hosted)Also on the homelab. Handles subscriptions, double opt-in, transactional emails, and campaign management
Contact formSMTP2Go + NodemailerSends form submissions to my Gmail from [email protected]. Free tier, 1,000 emails/month
Email routingCloudflare Email RoutingForwards [email protected] to Gmail for free. I wrote a [[custom-domain-email
DiagramsD2 + astro-d2Write diagrams in Markdown code blocks, pre-rendered to SVGs at build time. Dark Mauve theme, zero JavaScript shipped
HostingProxmox LXC containerNot Vercel, not Netlify, a Dell OptiPlex Micro on my desk. node dist/server/entry.mjs as a systemd service

Why Astro

I looked at Next.js, Hugo, Eleventy, and a few others. Astro won because it hit a sweet spot between simplicity and power.

Server-side rendering. Content is rendered on the server before it reaches the browser. Great for SEO, great for perceived performance. The site runs as a Node.js server using Astro’s standalone adapter, a single entry.mjs file that I can deploy anywhere.

Content collections. All my blog posts and project pages are Markdown files in src/content/, but they’re validated with Zod schemas at build time. I get TypeScript autocomplete for frontmatter fields, and if I mess up a post’s metadata, the build fails fast instead of silently breaking something on the live site.

Islands architecture. This is the big one. Most of the site is static HTML, no JavaScript shipped. The only interactive parts are Svelte “islands” (the theme toggle and the comments embed). Those load independently, so the rest of the page isn’t waiting on them. The result is a site that feels fast because it mostly is just HTML and CSS.

Developer experience. Hot module replacement, fast builds, and a philosophy that prioritizes shipping less JavaScript to the browser. I can go from editing a Markdown file to seeing the change in under a second.

How I write

Every post is just a Markdown file. I write in Obsidian because it gives me:

  • Live preview of rendered Markdown
  • Wikilinks, I type homelab and it links to my homelab project right in the editor
  • Graph view to see connections between posts
  • Local-first editing, I don’t have a Git sync setup from Obsidian yet (the site is still in active development), but that’s on the todo list
Diagram

The magic happens with a custom rehype plugin I wrote. At build time, it parses wikilinks and converts them to proper HTML links. If I link to something that doesn’t exist yet, it still renders but with a different style so I can spot orphan links later. The way I take notes in Obsidian is the same way I write for the site, no context switching, no reformatting, just Markdown.

Series support

Multi-part guides get their own system. The OpenClaw guide is four parts, the Jellyseerr OIDC guide is three. Each series lives in a subfolder like blog/openclaw-series/. The first file (00-introduction.md) defines the series metadata and optional sidebar config. Later pages inherit their pubDate, description, and tags from the series index.

This keeps frontmatter clean, I only update dates and descriptions in one place. The sidebar navigation generates automatically from the config in the first file, with collapsible groups and the current page highlighted.

The design

The color scheme is Tokyo Night, dark mode by default, light mode if you want it. The theme toggle persists via localStorage so it remembers your choice.

ElementChoiceWhy
Display fontFrauncesHas personality without being distracting. Used for headings and the homepage hero
Body fontOutfitClean, legible, good at small sizes
Code fontJetBrains MonoMonospace with ligatures. Shows up in code blocks and inline code
Color schemeTokyo NightI use it in my editor already, so the site matches my dev environment

The site is mobile-first with breakpoints at 768px, 1024px, and 1280px. On wide screens, blog posts get a floating table of contents on the right and a series sidebar on the left. The TOC uses IntersectionObserver to track which section you’re reading and highlight it in real time.

There are card hover effects, link underline animations, button glow effects, and staggered fade-ins on the homepage. The hero has a typing animation that cycles through three role descriptions. I spent way too much time on these details, but the small touches are what make a site feel polished instead of generic.

Site features

Beyond the design, there’s a bunch of small features that add up:

Search. There’s a search bar that lets you find posts by title, tags, or content. It’s client-side — no external search service, no Algolia, just filtering against the content collection.

RSS feed. The site generates an RSS feed at /rss.xml so people can follow along in their reader of choice. It’s built automatically from the blog content collection at build time.

View Transitions. Page navigations use Astro’s View Transitions API, so clicking between pages feels smooth instead of doing a full page reload. The page content fades in, and the URL updates without a white flash. This is also why the Remark42 comments widget needs to reinitialize on navigation — it doesn’t know the page changed unless I tell it.

Reading time. Each blog post shows an estimated reading time at the top. It’s calculated from the word count at build time — nothing fancy, but it helps set expectations before someone dives into a 4,000-word guide.

Social sharing. Every post has share buttons for Twitter, LinkedIn, Mastodon, Bluesky, Reddit, Email, and a copy-link button. I wanted to make it easy for people to share without having to manually copy the URL and format a post.

Tags. Posts are tagged by topic — homelab, networking, self-hosting, OIDC, whatever applies. You can click a tag to see all posts with that tag. Tags come from frontmatter and are validated at build time, so typos get caught before they hit production.

D2 diagrams. I can write diagrams directly in Markdown using D2 code blocks. At build time, the astro-d2 integration runs the D2 CLI and pre-renders everything to SVGs. No JavaScript shipped to the browser, no client-side rendering. The Dark Mauve theme matches the site’s Tokyo Night aesthetic, and the diagrams work on the homelab project page and anywhere else I need a visual. Writing \“d2` in a Markdown file and getting a proper diagram on the site is exactly the kind of workflow I built this whole thing for.

The newsletter pipeline

The signup form on the site isn’t connected to Mailchimp or whatever. It hits an API route that talks to Listmonk, which is running on my Proxmox cluster.

Here’s the flow:

Diagram

The welcome email fires with embedded memes — Nic Cage nodding, a cat being a fan — because I’m not a corporate newsletter and I can do what I want.

The EmailSignup component auto-detects which newsletter backend to use at build time. If Listmonk environment variables are set, it uses Listmonk. Otherwise it falls back to Buttondown. This made the migration seamless, I didn’t have to change the component, just set the new env vars.

Daily digest automation

This is the part I’m proud of. Every night at 11:30 PM MST, a cron job runs a standalone Node script that:

  1. Walks through src/content/blog/ and src/content/projects/
  2. Parses frontmatter with its own custom parser (no dependencies)
  3. Finds anything published today
  4. Generates an HTML email digest
  5. Creates a Listmonk campaign via API

The script creates a draft campaign and emails me a preview with an “Approve & Send” button. I review the digest in my inbox, and if it looks good, one click starts the campaign — no need to log into the Listmonk admin.

Diagram

The approve link is token-authenticated — a secret token in the URL is validated against an env var using timing-safe comparison, so even if someone intercepts the URL they can’t forge it. The API route acts as a secure proxy to Listmonk’s internal API, which isn’t exposed to the internet.

The script doesn’t need the full Astro build pipeline, it’s just fs and fetch, runs as the saulutions user on the server, and does its thing in a couple seconds.

Comments with Remark42

Most people would just use Disqus. But then some company owns your comment data, slaps ads on it, and tracks your readers. No thanks.

Remark42 is a self-hosted comment engine running on my homelab. It’s a single Go binary in a Docker container behind Nginx, accessible at comments.saulutions.ca. Each blog post gets its own comment thread, and since I own the database, nobody can delete my comments or inject ads.

The Svelte component that embeds it handles a few things:

  • Theme sync, when you toggle dark mode, the comments switch too. Remark42 doesn’t natively support runtime theme changes, so I wrote a MutationObserver that watches for changes on the <html> element and calls Remark42’s theme API. Small thing, but it would’ve looked janky otherwise
  • Client-side navigation, Astro’s View Transitions API means page navigations don’t do full reloads, so the comments widget needs to reinitialize on each navigation
  • Per-URL threads, each post URL gets its own comment section automatically

The best part? I own the data. My database, my rules.

Email and the contact form

The whole email setup for this site is free. I wrote a full guide about how to do this, it’s one of my more popular posts.

Diagram

Receiving: Cloudflare Email Routing forwards anything at @saulutions.ca to my Gmail. Free, no mail server to manage.

Sending: Gmail is configured to send as [email protected] through SMTP2Go. When someone emails me at my domain and I hit reply, it goes out from my domain, not my Gmail address.

Contact form: The form on the site sends through SMTP2Go separately from the newsletter pipeline. The server validates input, blocks SMTP header injection attempts, and sends via Nodemailer. The email arrives in my Gmail from [email protected] with the visitor’s email as reply-to.

Total cost: $0. Cloudflare Email Routing is free, SMTP2Go’s free tier gives me 1,000 emails/month, and Gmail handles the inbox.

Deployment

This site isn’t on Vercel or Netlify. It’s running on one of my Proxmox LXC containers, behind Nginx Proxy Manager. Here’s how deploys work:

Diagram

The service itself is a systemd unit running node dist/server/entry.mjs. It auto-starts on boot, logs to journald, and restarts on failure. The running service owns the dist/ files, so I have to stop it and chown before rsync to avoid permission errors. The whole deploy takes about 10 seconds.

I also rsync the content files and scripts directory separately — the content files are used by the daily digest cron job, and the scripts need to stay in sync with the build.

Is it elegant? No. Does it work perfectly fine for a personal site? Yes. And I can break things locally all day without affecting production.

What I’d do differently

A proper CI/CD pipeline. Right now I build locally and rsync. A GitHub Actions workflow that builds on push and auto-deploys would be smoother. But I also like the control of deploying manually — if the build works locally, I know it’ll work on the server.

More tests. The codebase has almost no tests. I rely on manual testing and “ship it and see.” For a personal site this is fine. For anything serious, not so much.

The custom wikilink plugin. It works great, but maintaining a custom rehype plugin means keeping an eye on Astro updates. If they change how the plugin system works, I’m on the hook to fix it. Worth it for the workflow, but it’s tech debt I’m aware of.

Why build any of this

I could’ve used WordPress, Ghost, Hashnode, whatever. But then I wouldn’t own anything. Some platform would hold my content, my comments, my subscriber list. They could change their API, raise prices, or shut down, and I’d have to scramble.

Every post on this site is a Markdown file I can take anywhere. The comments are in my database. The newsletter subscribers are in my Listmonk instance. The domain is mine. If Astro disappears tomorrow, I still have all my content and can rebuild with literally any static site generator.

I’ve also learned more building this than I would’ve learned configuring a CMS — how Astro works under the hood, how to write rehype plugins, how to self-host a newsletter platform, how email routing works, how to run a Node server behind a reverse proxy. That stuff doesn’t go away.

And honestly? It’s just fun. Overengineering a personal blog is basically a hobby at this point.


The code is on GitHub if you want to poke around, or get in touch if you have questions about the setup.

contents.txt
mailing list
comments

RELATED PROJECTS

homelab.exe

The Homelab

Two mini PCs, an old laptop, a TrueNAS box, a MikroTik router, nearly 80 containers and VMs, and a growing list of things I probably didn't need to self-host

Open project