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:
docker compose exec -T web mix test
docker compose exec -T web mix compile --warnings-as-errorsPhoenix 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:
priv/posts/en/2026-10-22-clickhouse-codecs-per-column.md
^^ ^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
lang date slugThe 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.
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.