saulutions.ca
My personal site, built with Astro, self-hosted on my homelab, with way more custom features than a personal blog probably needs
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
| Layer | What | Why |
|---|---|---|
| Framework | Astro 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 bits | Svelte | Only the things that actually need JavaScript, the theme toggle and the comments widget. Everything else is just HTML and CSS |
| Styling | Tailwind CSS + Typography plugin | Tokyo Night color scheme, dark mode by default. Fonts are Fraunces for headings, JetBrains Mono for code, Outfit for body text |
| Comments | Remark42 (self-hosted) | My data, no ads, no tracking. Runs on my homelab in a Docker container behind Nginx |
| Newsletter | Listmonk (self-hosted) | Also on the homelab. Handles subscriptions, double opt-in, transactional emails, and campaign management |
| Contact form | SMTP2Go + Nodemailer | Sends form submissions to my Gmail from [email protected]. Free tier, 1,000 emails/month |
| Email routing | Cloudflare Email Routing | Forwards [email protected] to Gmail for free. I wrote a [[custom-domain-email |
| Diagrams | D2 + astro-d2 | Write diagrams in Markdown code blocks, pre-rendered to SVGs at build time. Dark Mauve theme, zero JavaScript shipped |
| Hosting | Proxmox LXC container | Not 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
homelaband 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
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.
| Element | Choice | Why |
|---|---|---|
| Display font | Fraunces | Has personality without being distracting. Used for headings and the homepage hero |
| Body font | Outfit | Clean, legible, good at small sizes |
| Code font | JetBrains Mono | Monospace with ligatures. Shows up in code blocks and inline code |
| Color scheme | Tokyo Night | I 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:
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:
- Walks through
src/content/blog/andsrc/content/projects/ - Parses frontmatter with its own custom parser (no dependencies)
- Finds anything published today
- Generates an HTML email digest
- 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.
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.
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:
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.