{
"$type": "site.standard.document",
"coverImage": {
"$type": "blob",
"ref": {
"$link": "bafkreidrjyokkrkpcuy74ddkt4fc5jpsbolozp4qdmcsb4anpzx3a5jwra"
},
"mimeType": "image/png",
"size": 50551
},
"description": "Setting up a website using Elixir and Phoenix, leaning on NimblePublisher for the blog posts.",
"path": "/posts/building-a-blog-with-elixir-and-phoenix",
"publishedAt": "2026-03-24T00:00:00Z",
"site": "at://did:plc:bvraa6gajy4tfr3eh2sisdkr/site.standard.publication/self",
"tags": [
"elixir",
"phoenix",
"blog",
"bunnynet",
"hetzner"
],
"textContent": "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](https://hetzner.cloud/?ref=SjrsM8GhyYOl), with [bunny.net](https://bunny.net?ref=f0l8865b7g) as a CDN sitting in front of it.\n\nThis 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.\n\n## Why Phoenix\n\nI have used static site generators before to power my blog (shoutout to [Hakyll](https://jaspervdj.be/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.\n\nIt 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.\n\nSo 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.\n\n<img src=\"/images/joladev-speed-test.png\" alt=\"PageSpeed Insights performance score for jola.dev\" width=\"808\" height=\"536\" loading=\"lazy\" decoding=\"async\" style=\"width:50%; margin: auto\" />\n\n## NimblePublisher\n\nMy setup closely matches the original Dashbit blog post [Welcome to our blog: how it was made\\!](https://dashbit.co/blog/welcome-to-our-blog-how-it-was-made), which led to the creation of NimblePublisher.\n\nThe heart of the blog is the [NimblePublisher](https://github.com/dashbitco/nimble_publisher) setup, which consists of a `use` block:\n\n```elixir\ndefmodule JolaDev.Blog do\n\n use NimblePublisher,\n build: JolaDev.Blog.Post,\n from: Application.app_dir(:jola_dev, \"priv/posts/**/*.md\"),\n as: :posts,\n html_converter: JolaDev.Blog.MarkdownConverter,\n highlighters: [:makeup_elixir]\n...\n```\n\nThis 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.\n\nPosts are organized by year: `priv/posts/2025/08-18-ruthless-prioritization.md` . We get beautiful code block syntax highlighting through [Makeup](https://github.com/elixir-makeup/makeup). The `Blog` module also defines a set of helpers for fetching the posts:\n\n```elixir\n@posts Enum.sort_by(@posts, & &1.date, {:desc, Date})\n\n# Let's also get all tags\n@tags @posts\n |> Enum.flat_map(& &1.tags)\n |> Enum.uniq()\n |> Enum.sort()\n\n# And finally export them\ndef all_posts, do: @posts\ndef all_tags, do: @tags\n\ndef posts_by_tag(tag) do\n Enum.filter(all_posts(), fn post -> tag in post.tags end)\nend\n\ndef find_by_id(id) do\n Enum.find(all_posts(), fn post -> post.id == id end)\nend\n```\n\nThe 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:\n\n```elixir\nEarmark.Options.make_options!(\n registered_processors: [\n Earmark.TagSpecificProcessors.new([\n {\"a\", &Earmark.AstTools.merge_atts_in_node(&1, class: \"underline\")},\n {\"h1\", &Earmark.AstTools.merge_atts_in_node(&1, class: \"text-3xl py-4\")},\n {\"h2\", &Earmark.AstTools.merge_atts_in_node(&1, class: \"text-2xl py-4\")},\n {\"h3\", &Earmark.AstTools.merge_atts_in_node(&1, class: \"text-xl py-4\")},\n {\"p\", &Earmark.AstTools.merge_atts_in_node(&1, class: \"text-md pb-4\")},\n {\"code\", &Earmark.AstTools.merge_atts_in_node(&1, class: \"\")},\n {\"pre\",\n &Earmark.AstTools.merge_atts_in_node(&1,\n class: \"mb-4 p-1 py-4 overflow-x-scroll border-y\"\n )},\n {\"ol\", &Earmark.AstTools.merge_atts_in_node(&1, class: \"list-decimal\")},\n {\"ul\", &Earmark.AstTools.merge_atts_in_node(&1, class: \"list-disc pb-4\")},\n {\"blockquote\",\n &Earmark.AstTools.merge_atts_in_node(&1,\n class: \"pl-4 border-l-2 mb-4 border-purple-700\"\n )}\n ])\n ]\n)\n```\n\nYou 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.\n\n## The Frontend\n\nAs 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\\!\n\nThe 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.\n\nAnd of course, dark mode. I know it’s not everyone’s cup of tea, but it is my website after all.\n\n## CI\n\nI’ve got Github Actions set up to run on every push and PR, just the basic Elixir quality assurance tools.\n\n- `mix compile --warnings-as-errors`\n- `mix format --check-formatted`\n- `mix credo --strict`\n- `mix test`\n\nAnd 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.\n\n## Deployment\n\nOn the hosting side things get a bit more spicy. The repo includes a [multi-stage Docker file](https://github.com/joladev/jola.dev/blob/main/Dockerfile), 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.\n\nAnd now we're arriving at [Dokploy](https://dokploy.com/), 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.\n\nSo 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.\n\nI run this Dokploy instance on [Hetzner](https://hetzner.cloud/?ref=SjrsM8GhyYOl) 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.\n\n## The Little Things\n\nI’ve set up a few little conveniences for my app so I’ll share some example code for them here.\n\n### RSS\n\nRSS is managed by a plain Phoenix controller that looks something like this:\n\n```elixir\ndefmodule JolaDevWeb.RssXML do\n use JolaDevWeb, :html\n\n embed_templates \"rss_xml/*\"\n\n def format_rfc822(%Date{} = date) do\n date\n |> DateTime.new!(~T[00:00:00], \"Etc/UTC\")\n |> format_rfc822()\n end\n\n def format_rfc822(%DateTime{} = datetime) do\n Calendar.strftime(datetime, \"%a, %d %b %Y %H:%M:%S +0000\")\n end\nend\n```\n\nand the corresponding XML:\n\n```xml\n<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<rss version=\"2.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\" xmlns:content=\"http://purl.org/rss/1.0/modules/content/\">\n <channel>\n <title>jola.dev</title>\n <link><%= url(~p\"/\") %></link>\n <description>Blog posts from jola.dev</description>\n <language>en-us</language>\n <lastBuildDate><%= JolaDevWeb.RssXML.format_rfc822(DateTime.utc_now()) %></lastBuildDate>\n <atom:link href=\"<%= url(~p\"/rss.xml\") %>\" rel=\"self\" type=\"application/rss+xml\" />\n\n <%= for post <- @posts do %>\n <item>\n <title><%= post.title %></title>\n <link><%= url(~p\"/posts/#{post.id}\") %></link>\n <description><![CDATA[<%= post.description %>]]></description>\n <content:encoded><![CDATA[<%= post.body %>]]></content:encoded>\n <pubDate><%= JolaDevWeb.RssXML.format_rfc822(post.date) %></pubDate>\n <guid isPermaLink=\"true\"><%= url(~p\"/posts/#{post.id}\") %></guid>\n <author><%= post.author %></author>\n </item>\n <% end %>\n </channel>\n</rss>\n```\n\n### Sitemap\n\nI 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.\n\n```elixir\ndefmodule JolaDevWeb.SitemapController do\n use JolaDevWeb, :controller\n\n def index(conn, _params) do\n sitemap = JolaDev.Sitemap.generate()\n\n conn\n |> put_resp_content_type(\"text/xml\")\n |> send_resp(200, sitemap)\n end\nend\n\ndefmodule JolaDev.Sitemap do\n alias JolaDev.Blog\n\n @host \"https://jola.dev\"\n\n def generate do\n \"\"\"\n <?xml version=\"1.0\" encoding=\"UTF-8\"?>\n <urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n #{generate_static_pages()}#{generate_tag_pages()}#{generate_blog_posts()}\n </urlset>\n \"\"\"\n end\n\n defp generate_static_pages do\n pages = [\n %{loc: @host, changefreq: \"monthly\", priority: \"1.0\"},\n %{loc: \"#{@host}/about\", changefreq: \"monthly\", priority: \"0.8\"},\n %{loc: \"#{@host}/projects\", changefreq: \"weekly\", priority: \"0.9\"},\n %{loc: \"#{@host}/talks\", changefreq: \"monthly\", priority: \"0.7\"},\n %{loc: \"#{@host}/posts\", changefreq: \"weekly\", priority: \"0.9\"}\n ]\n\n Enum.map_join(pages, \"\\n\", &url_entry/1)\n end\n\n defp generate_tag_pages do\n Blog.all_tags()\n |> Enum.map(fn tag ->\n %{loc: \"#{@host}/posts/tag/#{tag}\", changefreq: \"weekly\", priority: \"0.6\"}\n end)\n |> Enum.map_join(\"\\n\", &url_entry/1)\n end\n\n defp generate_blog_posts do\n Blog.all_posts()\n |> Enum.map(fn post ->\n %{\n loc: \"#{@host}/posts/#{post.id}\",\n lastmod: Date.to_iso8601(post.date),\n changefreq: \"monthly\",\n priority: \"0.8\"\n }\n end)\n |> Enum.map_join(\"\\n\", &url_entry/1)\n end\n\n defp url_entry(params) do\n \"\"\"\n <url>\n <loc>#{params.loc}</loc>\n #{if params[:lastmod], do: \"<lastmod>#{params.lastmod}</lastmod>\", else: \"\"}\n <changefreq>#{params.changefreq}</changefreq>\n <priority>#{params.priority}</priority>\n </url>\n \"\"\"\n end\nend\n\n```\n\n### Blog redirect plug\n\nWhen 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.\n\n```elixir\ndefmodule JolaDevWeb.Plugs.BlogRedirect do\n import Plug.Conn\n\n def init(_), do: []\n\n def call(conn, _opts) do\n if conn.host == \"blog.jola.dev\" do\n ids = JolaDev.Blog.ids()\n path = strip_path(conn.request_path)\n\n path =\n if path in ids do\n \"posts/\" <> path\n else\n path\n end\n\n conn\n |> put_resp_header(\"location\", \"https://jola.dev/\" <> path)\n |> send_resp(:moved_permanently, \"\")\n |> halt()\n else\n conn\n end\n end\n\n defp strip_path(\"/\" <> path), do: path\n defp strip_path(path), do: path\nend\n```\n\n### SEO\n\nI 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:\n\n```html\n<meta property=\"og:type\" content={if(@conn.assigns[:post], do: \"article\", else: \"website\")} />\n<%= if post = @conn.assigns[:post] do %>\n <meta property=\"article:published_time\" content={Date.to_iso8601(post.date)} />\n <meta property=\"article:author\" content=\"https://jola.dev/about\" />\n <%= for tag <- post.tags do %>\n <meta property=\"article:tag\" content={tag} />\n <% end %>\n<% end %>\n```\n\nSame idea for the Twitter Card and description tags — one place in the layout, driven entirely by what the controller passes in.\n\nI also added [`llms.txt`](https://llmstxt.org/) 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.\n\n## Wrapping Up\n\nThis 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.\n\nAnd 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](https://hetzner.cloud/?ref=SjrsM8GhyYOl). 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](https://github.com/sponsors/Dokploy).\n\nSource code is available at: <https://github.com/joladev/jola.dev>. [Next up](/posts/dropping-cloudflare) I’ll talk about setting up [bunny.net](https://bunny.net?ref=f0l8865b7g) and a separate post on Dokploy on Hetzner.",
"title": "Building a blog with Elixir and Phoenix",
"updatedAt": "2026-03-24T00:00:00Z"
}