This blog isn't running on WordPress, Phoenix, or even a static site generator. It's running on a bespoke web framework I wrote in Elixir. I didn't want a whole framework just to serve some HTML, so I built exactly what I needed, from scratch, minus the HTTP parsing.

I'm primarily a Node developer, and I'd been wanting an excuse to try Elixir and the BEAM.

Performance on a Budget

The whole thing runs on the cheapest DigitalOcean droplet available, $4 a month, and still gets perfect Lighthouse scores. There's no client-side JavaScript to speak of, which does most of the work there.

Web Vitals: Performance 100, Accessibility 100, Best Practices 100, SEO 100
Lighthouse scores for this very page.

Routing Requests

I used Plug.Router to handle the routing. It's a simple, declarative way to map paths to functions. Here's how the main router is structured:

defmodule Webserver.Router do
  use Plug.Router
  # ... imports and aliases ...

  plug(Plug.RequestId)
  plug(Plug.Logger)
  plug(Plug.Static, at: "/static", from: {:webserver, "priv/static"})

  plug(:match)
  plug(:dispatch)

  get "/health" do: json(conn, 200, %{status: "ok"})
  get "/live-reload", to: Webserver.LiveReload
  forward "/admin", to: AdminRouter
  forward "/", to: Webserver.Server
end

The Core Server Loop

The main logic for serving pages lives in Webserver.Server. It takes the request path, looks it up in the cache, and returns the parsed HTML, falling through to a 404 page if there's nothing there.

def call(conn, _opts) do
  path = request_path(conn)
  result = try_get_page(path)

  case result do
    {:ok, parsed} ->
      conn
      |> put_resp_content_type("text/html")
      |> send_resp(200, parsed)
    {:error, :not_found} ->
      # Render custom 404 page...
  end
end

Caching with ETS

To keep things fast, I use ETS (Erlang Term Storage) for caching fully rendered HTML. Multiple processes can read from the cache simultaneously without any locking, which is essential for high concurrency.

def get_page(server, path) do
  table = table_for(server)

  case :ets.lookup(table, {:page, path}) do
    [{_, %PageEntry{} = entry}] ->
      handle_maybe_stale(table, server, path, entry)

    [] ->
      GenServer.call(server, {:fetch_and_cache, path})
  end
end

The handle_maybe_stale function implements a simple TTL-based revalidation strategy. It checks if a configurable interval has passed since the last check. If it has, the cache revalidates the entry based on the file's mtime on disk.

Stale-While-Revalidate (SWR)

The first version of this cache did the revalidation synchronously on the request path. That keeps correctness simple, but it also means every "stale" request gets serialized through a single GenServer and can block behind filesystem reads + parsing. Under enough traffic, that becomes tail latency (or timeouts) even though we already have perfectly good HTML sitting in ETS.

The current approach is stale-while-revalidate: when a cached entry is due for a check, the server returns the cached HTML immediately and kicks off a background revalidation that updates ETS if the file changed.

Benefits: no request ever blocks on revalidation work, so latency is predictable and the cache stays responsive under load. It also naturally smooths out bursts because revalidation happens at most once per page per interval (deduped).

Trade-offs: the HTML can be briefly stale (bounded by the check interval) and background failures don't surface to the user directly. Instead, you monitor revalidation error counters and logs.

A Custom Templating Language

I wrote a tiny templating language for this project. The honest reason is that I really enjoy building parsers and state machines in Elixir (I'm doing something similar on my chess project). It supports partials, named slots, and lightweight attributes.

<% layout.html title="My Page" %>
  <slot:title>My Page</slot:title>
  <slot:body>
    <% header.html %/>
    <p>Content goes here.</p>
  </slot:body>
 <%/ layout.html %>

The parser is a simple tag scanner: it walks the input, finds <% ... %> tags, and renders depth-first so child content is fully resolved before the parent partial is applied. Slots are passed via <slot:name>...</slot:name> and injected into the partial with {{name}}. Attributes are passed on the component tag and injected with {{@attr}} (and missing required attrs are treated as an error, which makes mistakes loud).

Static assets are referenced with {{+ /static/...}} placeholders which resolve through the digested asset manifest in production (and pass through as-is in development). That keeps templates readable while still getting long-cache hashed filenames.

For images, there is a dedicated <% img ... %/> tag. It validates that the source is a real /static image, looks up width/height from a build-generated assets_meta.json, and emits responsive srcset. If a WebP variant exists it wraps the output in a <picture> with a WebP <source>. That means the HTML gets correct intrinsic sizing (no CLS), and the browser can pick an appropriately sized image.

Static Assets and Build Pipeline

There is an explicit asset build step that does a bit more than just copying files. In production it generates responsive raster variants (via vips), generates WebP variants (via cwebp), writes assets_meta.json for dimensions, and then digests the output for cache busting. The build is strict in production: missing tooling or failed conversions will fail the build instead of silently shipping a broken page.

Live Reload

The server watches the filesystem using fs. The browser keeps an SSE connection open, and when a change is detected the server sends a signal to reload the page or swap CSS in place.

Hosting and Deployment

Deployment is GitHub Actions and Docker. When I push to main, an image is built and pushed to GHCR. On the droplet, Watchtower detects the new image and restarts the container within 30 seconds.

Because image variants are generated during the build, the Docker image also includes the required native tools (libvips and webp) so production builds are reproducible.

After the first deploy I hit an annoying issue: the container would slowly degrade and eventually start returning internal 503s. The root cause turned out to be the Docker HEALTHCHECK. I originally used ./bin/webserver eval "IO.puts(:ok)", which spawns a separate BEAM instance every check. On a tiny droplet that can time out and create unnecessary pressure. The fix was to switch the healthcheck to a simple HTTP probe against /health.

Why Not Phoenix?

I've never actually used Phoenix, but from what I've heard it's one of the best web frameworks out there, not just in Elixir. If I were starting a real project I'd probably reach for it.

But I wanted to learn, not ship. I had just finished the Mix and OTP guide, which builds up a TCP server from scratch. It's a long guide and covers a lot of ground. Jumping straight to Phoenix after that felt like skipping steps. Plug sits in between: it handles HTTP without making all the other decisions for you.

Having to figure out caching, static files, and templates myself meant I actually understood what I was building. That's the point.

Elixir vs Node

Most of my professional work is in TypeScript and Node. What I enjoy most about the BEAM is not having to think about concurrency: no mutexes, no thread pools, no worrying about what a slow handler does to everything else. Node is harder to run safely, mostly because JavaScript grew up in the browser, where failing silently is a feature, rather than in a runtime built around supervision and crash recovery.

A few days of building this was a good way to learn the language, and I'll be reaching for it again.

You can even view the source for this very page on GitHub to see how the templating works in practice.