sh4.tech

Lab part 1

Building this site in Elixir, part 1

4 min read

Why Elixir, why Docker, and how the posts end up compiled inside the app. First notes from building sh4.tech.

Topic
Elixir · Phoenix
Stack
Elixir 1.18, OTP 28, Phoenix 1.8, NimblePublisher, Docker
Also in
Português
  • 1.18Elixir
  • 1.8Phoenix
  • 0databases
  • 2languages

Why Elixir

I could build this site in one afternoon with the tools I use every day. I chose Elixir anyway, and for three reasons. First, I want to learn it, and I learn better when there is something real to ship in the end. Second, the BEAM. For ten years I ran systems where thousands of devices talk with the servers at the same time, and Elixir was made for this kind of load: lots of small processes, each one cheap, supervised, restarted when it crashes. A blog doesn't need any of that. But it's a good place to learn the model before I need it for real work.

Third, the site itself is a case. When this is online, the page you are reading comes from a Phoenix app I wrote while learning the language.

Docker, because apt is old

The first problem came before any code. apt install elixir on Ubuntu gives Elixir 1.14, and the Phoenix version I wanted asks for newer. I didn't want to fight with version managers on my machine, so everything runs in Docker: the official elixir:1.18 image with OTP 28, the project folder mounted as a volume, port 4000 open. docker compose up and the site is there. Nothing installed on the host except Docker itself.

Every mix command goes through the container:

bash
docker compose exec -T web mix test
docker compose exec -T web mix compile --warnings-as-errors

Phoenix 1.8, no Ecto

I generated the project without Ecto, so there is no database. Posts are Markdown files in the repository, one folder per language: priv/posts/en/ and priv/posts/pt/. No login, no comments, no admin panel. When I want to change a post I edit a file and commit, same as any other code change.

The posts are compiled into the app

This is the part I like most. NimblePublisher reads all the Markdown files at compile time, converts them to HTML and keeps the list inside the compiled module. In production there is no disk read and no query for a page. It's a function returning data that is already in memory.

For each file it calls a build/3 function that I write. The file path carries the language and the date, so the frontmatter stays short:

text
priv/posts/en/2026-10-22-clickhouse-codecs-per-column.md
           ^^ ^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
          lang   date                 slug
sheet 1 · rev. 2026-10 en + pt *.md priv/posts/ NimblePublisher Earmark + makeup Sh4.Blog @posts no database posts live in memory Phoenix Docker · :4000 nginx HTTPS next build time · mix compile runs once, before deploy each visit reads from memory
How this site is built and served today. scroll →

The dashed box on the right don't exist yet. More about it in the end.

Two languages in the URL

Every page exists in /en/... and /pt/.... The one who validates the language is a plug: a small function that gets the connection and returns it, maybe changed. In Elixir this is two clauses of the same function, and the runtime picks the one that matches.

lib/sh4_web/plugs/locale.ex
def call(%Plug.Conn{path_params: %{"locale" => locale}} = conn, _opts)
    when locale in @locales do
  Gettext.put_locale(Sh4Web.Gettext, locale)

  conn
  |> assign(:locale, locale)
  |> assign(:alternates, Map.new(@locales, &{&1, swap_locale(conn.request_path, &1)}))
end

def call(conn, _opts) do
  raise Phoenix.Router.NoRouteError, conn: conn, router: Sh4Web.Router
end

If the first piece of the path is en or pt, the first clause runs. Anything else falls in the second one and becomes a 404, the same as a route that doesn't exist. Short labels like "Articles" go through Gettext. The long texts of the home page are one map per language in a single module, because it's easier to review a whole paragraph in Portuguese next to the English one than hunting pieces in a .po file.

Design: three blind proposals, then the real one

I asked for three design proposals without the full briefing: a paddock one, a field notebook and a studio. Blind on purpose, to see what comes out when nobody knows yet what I want. Then a fourth version with the real positioning, and that is the one in the app today.

Two things from the blind round came back later. The log ruler on the home page is from the studio proposal. And this Lab, with the graph paper and the notes in the margin, is from the notebook.

What's next

  • Search and tag filter with LiveView, without reloading the page. That's where processes and state start to matter.
  • Deploy: an Elixir release in a container on my own server, behind the nginx that already runs there, with Let's Encrypt. First on a test subdomain, then the DNS.

Part 2 will be about LiveView, probably starting from the search box.

Luciano Serra
Luciano Serra Founder-engineer · co-founder & CTO, built a 15,000-screen network from zero, 2015–2024 · São Paulo, remote

I design production systems from zero and I take over the ones already running. Either way they have to hold up when the business grows. I co-founded a digital out-of-home company and built its technology for ten years; my partners raised the money and ran sales.

Your business is growing faster than your systems? Let's talk →