Building a Blog with Elixir and Phoenix

A deep dive into building a high-performance blog using Elixir and Phoenix, featuring NimblePublisher for content management and a self-hosted deployment strategy.
TL;DR: it’s an Elixir app using Phoenix server side rendered pages, with the blog post pages generated from Markdown using NimblePublisher. It’s running on a self-hosted Dokploy instance running on Hetzner, with bunny.net as a CDN sitting in front of it.
This is a very belated write up of how this blog was put together! There’s nothing terribly original here, but I figure it could come in handy for someone out there as a reference. And the world needs more Elixir content.
Why Phoenix
I have used static site generators before to power my blog (shoutout to Hakyll), but I wanted to open the door for myself to also have little experiments on this site, ones that would require more interactivity than a static site allows. Besides, I just like using Phoenix. Although most of my Phoenix projects use LiveView, this felt like a good place to do things old-school with DeadViews.
It also means I get full control of what I’m building. Using a tool someone else created means getting a lot for free, but the moment you step outside of the expected you’re having to figure out how to make things work for their tool.
So I kept things simple. No Ecto, no DB. Just server-side rendered HTML. It’s blazingly fast, as you can see from this PageSpeed Insights report.
NimblePublisher
My setup closely matches the original Dashbit blog post Welcome to our blog: how it was made!, which led to the creation of NimblePublisher.
The heart of the blog is the NimblePublisher setup, which consists of a use block:
defmodule JolaDev.Blog do
use NimblePublisher,
build: JolaDev.Blog.Post,
from: Application.app_dir(:jola_dev, "priv/posts/**/*.md"),
as: :posts,
html_converter: JolaDev.Blog.MarkdownConverter,
highlighters: [:makeup_elixir]
...
This will load up all the posts, parse the frontmatter, run it through the markdown converter, and compile it into module attributes. This means there’s no work left to be done at runtime, it’s all pre-compiled.
Posts are organized by year: priv/posts/2025/08-18-ruthless-prioritization.md. We get beautiful code block syntax highlighting through Makeup. The Blog module also defines a set of helpers for fetching the posts:
@posts Enum.sort_by(@posts, & &1.date, {:desc, Date})
# Let's also get all tags
@tags @posts
|> Enum.flat_map(& &1.tags)
|> Enum.uniq()
|> Enum.sort()
# And finally export them
def all_posts, do: @posts
def all_tags, do: @tags
def posts_by_tag(tag) do
Enum.filter(all_posts(), fn post -> tag in post.tags end)
end
def find_by_id(id) do
Enum.find(all_posts(), fn post -> post.id == id end)
end
The only thing that took a bit of figuring out for me was getting Tailwind classes into the outputted HTML. I’m pretty sure I’ve seen better approaches shared since I wrote this, but this works too. Under earmark_options, pass:
Earmark.Options.make_options!(
registered_processors: [
Earmark.TagSpecificProcessors.new([
{"a", &Earmark.AstTools.merge_atts_in_node(&1, class: "underline")},
{"h1", &Earmark.AstTools.merge_atts_in_node(&1, class: "text-3xl py-4")},
{"h2", &Earmark.AstTools.merge_atts_in_node(&1, class: "text-2xl py-4")},
{"h3", &Earmark.AstTools.merge_atts_in_node(&1, class: "text-xl py-4")},
{"p", &Earmark.AstTools.merge_atts_in_node(&1, class: "text-md pb-4")},
{"code", &Earmark.AstTools.merge_atts_in_node(&1, class: "")},
{"pre",
&Earmark.AstTools.merge_atts_in_node(&1,
class: "mb-4 p-1 py-4 overflow-x-scroll border-y"
)},
{"ol", &Earmark.AstTools.merge_atts_in_node(&1, class: "list-decimal")},
{"ul", &Earmark.AstTools.merge_atts_in_node(&1, class: "list-disc pb-4")},
{"blockquote",
&Earmark.AstTools.merge_atts_in_node(&1,
class: "pl-4 border-l-2 mb-4 border-purple-700"
)}
])
]
)
You probably have your own preferences for how to set up your classes, but this gives you a pattern you can use to ensure that the tags that come out have the appropriate classes.
The Frontend
As mentioned this is all server-side rendered Phoenix templates. It’s using standard Tailwind CSS. It predates DaisyUI and I don’t think there’s a strong reason for me to make the lift of getting it in, although I wouldn’t have minded it being a part of the scaffolding back when I set up the blog!
The only JS snippets in here are a mobile menu toggle and the Phoenix topbar. Apart from the Tailwind library, the custom CSS in here is pretty minimal. You get a lot out of the box with a Phoenix project.
And of course, dark mode. I know it’s not everyone’s cup of tea, but it is my website after all.
CI
I’ve got Github Actions set up to run on every push and PR, just the basic Elixir quality assurance tools.
mix compile --warnings-as-errorsmix format --check-formattedmix credo --strictmix test
And then I’ve got Dependabot set up as well. I’ve been hearing and thinking a lot about how it creates a lot of noise, but I feel like that’s less of an issue in the Elixir community. Packages tend to not have a lot of dependencies, and so you don’t get the same waves of bumps going out that npm does. And merging them is satisfying.
Deployment
On the hosting side things get a bit more spicy. The repo includes a multi-stage Docker file, roughly based on the Phoenix recommended example file. This means that most of the dependencies are only pulled in at build time, and the image you get out on the other side is a bit smaller. I’m using Elixir 1.18.4, Erlang 28.0.2, and Debian trixie-20250721-slim at the time of writing this, but that’s likely to change. There’s something very satisfying about bumping dependencies.
And now we’re arriving at Dokploy, an open source platform as a service (PaaS) for running apps, basically a self-hosted Heroku. It does everything, automatic builds and deploys from Github updates, built-in Docker Swarm, networking, orchestration of replicas across the cluster, rolling deploys, rollbacks, preview builds, and much more.
So my publish flow is basically: create a PR and wait for CI to finish (I could skip this but it’s nice to know I didn’t mess something up). When I merge the PR Dokploy automatically picks that up and triggers a checkout and build of the repo. Once that finishes, it starts a rolling deploy to replace the running replicas. And we’re live. With cached layers on the server, deploys can finish in 30s, zero effort.
I run this Dokploy instance on Hetzner and my experience has been really positive. The pricing is unbeatable, even with the recent increase, and it’s been rock solid for me. Really, with the Dokploy instance, there’s nothing stopping me from packing up and going somewhere else. Having that kind of freedom is very nice. But I’m more than happy to stick with Hetzner.
The Little Things
I’ve set up a few little conveniences for my app so I’ll share some example code for them here.
RSS
RSS is managed by a plain Phoenix controller that looks something like this:
defmodule JolaDevWeb.RssXML do
use JolaDevWeb, :html
embed_templates "rss_xml/*"
def format_rfc822(%Date{} = date) do
date
|> DateTime.new!(~T[00:00:00], "Etc/UTC")
|> format_rfc822()
end
def format_rfc822(%DateTime{} = datetime) do
Calendar.strftime(datetime, "%a, %d %b %Y %H:%M:%S +0000")
end
end
Source: Hacker News










