A custom blog server built with Elixir. No Phoenix, no framework. Just Plug and Bandit, a hand-rolled template engine, and ETS for caching.
- Custom Templating: Nested partials and named slots with recursive parsing.
- ETS Caching: Concurrent template serving with automatic, event-driven invalidation.
- SCSS Support: Modular design system built with Dart Sass and hot-reloading.
- LiveReload: Instant browser style patching and page refreshes via SSE.
- Front-Matter Content: Metadata embedded in page files drives the blog index and
sitemap.xmlautomatically. - Infrastructure: Automated deployment to DigitalOcean via OpenTofu and Watchtower.
- Elixir 1.19+ & Erlang 27+
- System deps (used by
mix assets.build):vipsandvipsheader(libvips)cwebp(WebP tools)
- Sass (Dart Sass installed automatically via
mix sass.install)
- Install dependencies:
mix deps.get
- Setup Sass binary:
mix sass.install
- Verify system deps:
vips --version vipsheader --version cwebp -version
- Start the server:
Access the server at
iex -S mix
http://localhost:4040.
Run before pushing:
mix checkRuns formatting, Credo (linting), Dialyzer (types), and the test suite.
Images are built by CI and pushed to GHCR on every merge to main.
Manual build:
docker build -t ghcr.io/frodi-karlsson/blog:latest .The app runs on a DigitalOcean Droplet ($4/mo) using Docker Compose. Watchtower polls GHCR every 30 seconds and restarts the container when a new image is available.
- Setup your secrets:
cp tofu/terraform.tfvars.example tofu/terraform.tfvars # Fill in cloudflare_token, cloudflare_zone_id, and do_token - Deploy:
cd tofu tofu init tofu apply
The Tofu code handles:
- Provisioning the Droplet.
- Configuring Cloudflare DNS (proxied).
- Launching the app, an observability sidecar, and Watchtower.
Metadata lives in each page file as front-matter — no separate JSON files to keep in sync.
mix webserver.new_post "My Post Title"Creates priv/templates/pages/my-post-title.html pre-filled with front-matter and the blog_post.html template. Fill in category, summary, and write the content — the blog index and sitemap update automatically on the next request.
---
title: My Post Title
date: 2026-02-25 # ISO 8601; shown as "Feb 25, 2026" on the index
category: Elixir
summary: One-line description shown on the blog index card.
noindex: true # Omit from sitemap (e.g. admin pages)
---
A page is treated as a blog post (and appears on the index) when it has both date and summary. Pages without front-matter are still served but won't appear in the sitemap.