惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Cisco Talos Blog
Cisco Talos Blog
K
Kaspersky official blog
T
The Exploit Database - CXSecurity.com
NISL@THU
NISL@THU
AWS News Blog
AWS News Blog
V2EX - 技术
V2EX - 技术
Google DeepMind News
Google DeepMind News
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
S
Security @ Cisco Blogs
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
Recent Commits to openclaw:main
Recent Commits to openclaw:main
J
Java Code Geeks
Microsoft Azure Blog
Microsoft Azure Blog
Attack and Defense Labs
Attack and Defense Labs
Jina AI
Jina AI
The Last Watchdog
The Last Watchdog
W
WeLiveSecurity
H
Help Net Security
V
Visual Studio Blog
宝玉的分享
宝玉的分享
C
Cybersecurity and Infrastructure Security Agency CISA
T
Threat Research - Cisco Blogs
IT之家
IT之家
Hugging Face - Blog
Hugging Face - Blog
Latest news
Latest news
T
Tor Project blog
I
Intezer
美团技术团队
GbyAI
GbyAI
T
Tailwind CSS Blog
Last Week in AI
Last Week in AI
博客园 - 三生石上(FineUI控件)
Google DeepMind News
Google DeepMind News
Scott Helme
Scott Helme
Y
Y Combinator Blog
博客园 - 司徒正美
T
Tenable Blog
O
OpenAI News
N
News and Events Feed by Topic
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
V
Vulnerabilities – Threatpost
P
Palo Alto Networks Blog
博客园 - 聂微东
酷 壳 – CoolShell
酷 壳 – CoolShell
D
Darknet – Hacking Tools, Hacker News & Cyber Security
T
Threatpost
Google Online Security Blog
Google Online Security Blog
Apple Machine Learning Research
Apple Machine Learning Research
云风的 BLOG
云风的 BLOG
Help Net Security
Help Net Security

jola.dev

Elixir Cluster 101 | jola.dev How to stop Claude from saying load-bearing | jola.dev Let libraries be libraries | jola.dev CI workflows on Tangled for Elixir | jola.dev Automatically syncing your blog to atproto and standard.site | jola.dev Appreciation for the small web | jola.dev Treating LLMs as programming books Publishing your blog to standard.site in Elixir Generating OG images in Elixir The social contract of writing Highest Random Weight in Elixir bunnyx: a bunny.net Elixir client library Building for the joy of building Running local models on an M4 with 24GB memory How to hit your Claude weekly limit so you can go outside and touch grass Dropping Cloudflare for bunny.net Stay in the Loop: How I Actually Use Claude Code Ruthless Prioritization: The Path to Delivery Estimates Are More Valuable Than You Think When Software Engineers Think They Need More Focus Time If the Goal is Resiliency, Defensive Programming is Your Enemy The Magic of Daily Pull Requests: Why Smaller is Better Building a Distributed Rate Limiter in Elixir with HashRing Announcing Hex Diff Building Hex Diff Push-based GenStage The Erlang :queue module in Elixir Patterns for managing ETS tables Health checks for Plug and Phoenix The new `Registry.select/2` and what match specs are Elixir String Processing Optimization
Building a blog with Elixir and Phoenix
Johanna Larsson · 2026-03-24 · via jola.dev

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.

PageSpeed Insights performance score for jola.dev

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:

<?xml version="1.0" encoding="UTF-8"?>

<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">

<channel>

<title>jola.dev</title>

<link><%= url(~p"/") %></link>

<description>Blog posts from jola.dev</description>

<language>en-us</language>

<lastBuildDate><%= JolaDevWeb.RssXML.format_rfc822(DateTime.utc_now()) %></lastBuildDate>

<atom:link href="<%= url(~p"/rss.xml") %>" rel="self" type="application/rss+xml" />

<%= for post <- @posts do %>

<item>

<title><%= post.title %></title>

<link><%= url(~p"/posts/#{post.id}") %></link>

<description><![CDATA[<%= post.description %>]]></description>

<content:encoded><![CDATA[<%= post.body %>]]></content:encoded>

<pubDate><%= JolaDevWeb.RssXML.format_rfc822(post.date) %></pubDate>

<guid isPermaLink="true"><%= url(~p"/posts/#{post.id}") %></guid>

<author><%= post.author %></author>

</item>

<% end %>

</channel>

</rss>

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

"""

<?xml version="1.0" encoding="UTF-8"?>

<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">

#{generate_static_pages()}#{generate_tag_pages()}#{generate_blog_posts()}

</urlset>

"""

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

"""

<url>

<loc>#{params.loc}</loc>

#{if params[:lastmod], do: "<lastmod>#{params.lastmod}</lastmod>", else: ""}

<changefreq>#{params.changefreq}</changefreq>

<priority>#{params.priority}</priority>

</url>

"""

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:

<meta property="og:type" content={if(@conn.assigns[:post], do: "article", else: "website")} />

<%= if post = @conn.assigns[:post] do %>

<meta property="article:published_time" content={Date.to_iso8601(post.date)} />

<meta property="article:author" content="https://jola.dev/about" />

<%= for tag <- post.tags do %>

<meta property="article:tag" content={tag} />

<% 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 €20 and I get €10. 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.

Written by Johanna Larsson. Thoughts on this post? Find me on Bluesky at @jola.dev or why not give it a vote on Bubbles.