https://jola.dev/posts/building-a-blog-with-elixir-and-phoenix
jola.dev
Home About Blog Projects Talks
Home About Blog Projects Talks
Building a blog with Elixir and Phoenix
March 24, 2026
20 min read
elixir phoenix blog bunnynet hetzner
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.
[joladev-sp]
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-errors
* mix format --check-formatted
* mix credo --strict
* mix 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
and the corresponding XML:
jola.dev
<%= url(~p"/") %>
Blog posts from jola.deven-us<%= JolaDevWeb.RssXML.format_rfc822(DateTime.utc_now()) %>" rel="self" type="application/rss+xml" />
<%= for post <- @posts do %>
<%= post.title %>
<%= url(~p"/posts/#{post.id}") %>
]]>]]><%= JolaDevWeb.RssXML.format_rfc822(post.date) %><%= url(~p"/posts/#{post.id}") %><%= post.author %>
<% end %>
Sitemap
I was a bit surprised not to find a clean little library for
generating the sitemap (this may have changed since I wrote the
code!), but I guess the implementation is just going to heavily
depend on your setup. Anyway, just sharing this for reference.
defmodule JolaDevWeb.SitemapController do
use JolaDevWeb, :controller
def index(conn, _params) do
sitemap = JolaDev.Sitemap.generate()
conn
|> put_resp_content_type("text/xml")
|> send_resp(200, sitemap)
end
end
defmodule JolaDev.Sitemap do
alias JolaDev.Blog
@host "https://jola.dev"
def generate do
"""
#{generate_static_pages()}#{generate_tag_pages()}#{generate_blog_posts()}
"""
end
defp generate_static_pages do
pages = [
%{loc: @host, changefreq: "monthly", priority: "1.0"},
%{loc: "#{@host}/about", changefreq: "monthly", priority: "0.8"},
%{loc: "#{@host}/projects", changefreq: "weekly", priority: "0.9"},
%{loc: "#{@host}/talks", changefreq: "monthly", priority: "0.7"},
%{loc: "#{@host}/posts", changefreq: "weekly", priority: "0.9"}
]
Enum.map_join(pages, "\n", &url_entry/1)
end
defp generate_tag_pages do
Blog.all_tags()
|> Enum.map(fn tag ->
%{loc: "#{@host}/posts/tag/#{tag}", changefreq: "weekly", priority: "0.6"}
end)
|> Enum.map_join("\n", &url_entry/1)
end
defp generate_blog_posts do
Blog.all_posts()
|> Enum.map(fn post ->
%{
loc: "#{@host}/posts/#{post.id}",
lastmod: Date.to_iso8601(post.date),
changefreq: "monthly",
priority: "0.8"
}
end)
|> Enum.map_join("\n", &url_entry/1)
end
defp url_entry(params) do
"""
#{params.loc}
#{if params[:lastmod], do: "#{params.lastmod}", else: ""}
#{params.changefreq}#{params.priority}
"""
end
end
Blog redirect plug
When I first moved over to this new app I wanted to ensure that I
kept my old blog post links alive, so I set up this little plug to
rewrite requests to match the new layout.
defmodule JolaDevWeb.Plugs.BlogRedirect do
import Plug.Conn
def init(_), do: []
def call(conn, _opts) do
if conn.host == "blog.jola.dev" do
ids = JolaDev.Blog.ids()
path = strip_path(conn.request_path)
path =
if path in ids do
"posts/" <> path
else
path
end
conn
|> put_resp_header("location", "https://jola.dev/" <> path)
|> send_resp(:moved_permanently, "")
|> halt()
else
conn
end
end
defp strip_path("/" <> path), do: path
defp strip_path(path), do: path
end
SEO
I went a bit further on this one. Each page has its own meta
description, Open Graph tags, and Twitter Card tags -- all driven by
assigns passed from the controllers. Blog posts automatically get og
:type="article" with article:published_time and article:tag set from
the post metadata. The layout just reads from conn.assigns with
sensible fallbacks, so adding SEO to a new page is just a matter of
passing the right assigns. Here's what the blog-post-specific bits
look like in the layout:
<%= if post = @conn.assigns[:post] do %>
<%= for tag <- post.tags do %>
<% end %>
<% end %>
Same idea for the Twitter Card and description tags -- one place in
the layout, driven entirely by what the controller passes in.
I also added llms.txt and llms-full.txt endpoints, this is a newer
standard that helps AI systems understand your site. It follows the
same pattern as the sitemap: a module that generates the content from
Blog.all_posts(), and a controller that serves it as plain text.
Whether it actually matters yet, who knows, but it was trivial to add
and I figure it can't hurt.
Wrapping Up
This app is intentionally kept simple but powerful. Everything is set
up the way I want it and I have a zero effort and very fast pipeline
for publishing new posts. If you're an Elixir dev thinking about a
personal site, consider just using Phoenix. Combined with
NimblePublisher you've got a really powerful and blazing fast blog
framework right there.
And while you're at it, why not host it on Hetzner! If you use the
referral link to sign up you get EUR20 and I get EUR10. If you prefer not
to use the referral link, here's a plain link: https://
www.hetzner.com/cloud/. Also consider joining me in sponsoring
Dokploy.
Source code is available at: https://github.com/joladev/jola.dev.
Next up I'll talk about setting up bunny.net and a separate post on
Dokploy on Hetzner.
Back to all posts
Building software with care
(c) 2026 Johanna Larsson. All rights reserved.