New year, new site

Written by John Mikael Lindbakk - 2025-02-01

New year, new site

For the longest time, my website has been hosted on Squarespace. Although I've never been particularly good at front-end development or making things look nice, Squarespace made it easy to get a decent-looking site up and running without too much fuss.

Unfortunately, Squarespace has quirks, and I have become increasingly annoyed with the platform. Therefore, I finally decided to write on my own using Gleam!

Why I want to move away from Squarespace

I don't want to spend too much time talking about Squarespace, as I don't find that to be very compelling. In short, it boils down to this:

Out with the old, in with the new!

Requirements

When making the new site, I had a few non-negotiable requirements:

Then I had a few nice-to-haves:

The stack

I won't describe every library or technology I use in detail. See the technologies page for a complete overview.

Programming language: Gleam

Gleam is a new language, which runs on the remarkable Beam engine. It is fully typed and functional, has errors-as-returns, access to Erlang and Elixir's ecosystem and much more.

I could've chosen something more familiar, but what would be the fun in that? I wanted to use something new and experience how it is to make something within a smaller ecosystem with fewer resources.

Given the rise of ChatGPT and other LLM-driven chatbots, getting something up and running is easy, which I think has made people a little lazy. More important, though, is that learning is a skill that needs to be practised. Heavy use of LLMs dulls that skill, so I wanted to challenge myself with a technology that wouldn't be part of most LLM training sets—or at least that would have evolved so much since then that the data it would have had would have been practically useless.

The last reason I chose Gleam is that its creator, Louis Pilfold, genuinely seems like a nice guy. I don't know him personally, but that's the vibe I get from him, which is something I can support.

Server and Web Framework: Mist and Wisp

I didn't want to build a web server or a web framework. This is a simple blog; I don't want to reinvent all the wheels. Due to Gleam's limited ecosystem, the options were pretty straightforward:

Frontend framework: Lustre

Lustre is a front-end web framework written in Gleam. It is written by Hayleigh Thompson, which I know very little about, but she has really taken to Gleam and has contributed massively to the Gleam ecosystem. It is only fitting that she is recognised for her effort here.

Now, Hayleigh isn't the reason I picked Lustre. I picked Lustre simply because I don't like HTML (We'll get into how Lustre works later).

CSS Framework: TailwindCSS

If you ask me to centre a div, I'll run to Google and try N different things until it works on a single browser. CSS has never clicked for me, and I need all the help I can get to make stuff look nice. That's where TailwindCSS comes in.

Hosting platform: Fly.io

Fly has long been active within the Beam ecosystem and was an early supporter of Gleam.

I'm uncomfortable with the idea that Microsoft, Google, and Amazon control the "public cloud." Instead, we should support smaller vendors like Fly, Vercel, Heroku, etc., which will most likely lead to a healthier web.

Fly supports containers, and I wanted to make my site container-based—it's as simple as that. This is just a tiny blog; I'm sure any cloud provider could host it.

The retrospective: How did it go?

So, I made all these decisions and considerations. How did it go?

Everything Gleam

The first thing I had to figure out was Gleam. Guess what? It's pretty nice. The slogan on Gleam's website says the following:

Gleam is a friendly language for building type-safe systems that scale!

I can't speak for the "scale" part, and luckily, I won't have to, but I can certainly say that the language does feel friendly. I have done some Erlang and Elixir, which have a learning curve. Gleam, on the other hand, is both familiar, small and straightforward.

Getting Wisp up and running was pretty easy:

pub fn main() {
  wisp.configure_logger()

  //More stuff...

  let handler = router.handle_request(_, ctx)

  let assert Ok(_) =
    wisp_mist.handler(handler, secret_key_base)
    |> mist.new
    |> mist.port(8000)
    |> mist.bind("0.0.0.0")
    |> mist.start_http

  process.sleep_forever()
}

Note

For brevity's sake, I'm simplifying some of the code I'll be showing throughout.

Handling routes is straightforward as well:

fn route_request(req: Request, ctx: Context) -> Response {
  case wisp.path_segments(req) {
    [] | ["home"] -> index(ctx)
    ["blog", blog_id] -> blog_entry(blog_id, ctx)
    ["blog"] -> blog(ctx)
    ["contact"] -> contact()
    ["health"] -> wisp.html_response(string_tree.new(), 200)
    ["technologies"] -> technologies()
    ["images", post_file_name, size, image_name] ->
      try_load_image(post_file_name, size, image_name, ctx)
    _ -> wisp.not_found()
  }
}

I come from the world of Java/Kotlin with Spring Boot. Java isn't a small language, and Spring Boot isn't a tiny framework. There's so much simplicity knowledge required to be good at either. You need to know how AOP works. You need to know about the IoC container. There are many annotations - some of which are default and some that belong to Spring Boot itself (and don't get me started on Jakarta and Javax).

Given the depth needed to do things right, I would not be surprised if new developers are overwhelmed when they start with Spring Boot. They must feel lost for a long time (or just "embrace the magic").

Working with an explicit system, like Gleam and Wisp, feels good. Granted, some implicit knowledge is required. I need to understand OTP, Gleam's error handling and so forth. The point is that the entry barrier is much lower despite a smaller ecosystem and fewer resources.

One thing I rarely do when I work with languages like Java or Kotlin is look at the source code for the library or framework I use. I always go to the documentation or code examples. I can't say the exact reason, but with Gleam, I found myself just reading the source code to understand how to use something. It could be because some documentation could be better, but also because Gleam code is pretty easy to read.

Blog posts - Markdown to HTML

Regarding blog posts, there were a few challenges:

The first challenge I tackled was the Markdown challenge. The project would have died upon arrival if this hadn't been fixed.

My Markdown files have a few non-standard things that I need an HTML renderer to handle:

Luckily, Marked does a lot of this. At some point I might write my own Markdown-to-HTML renderer that does exactly what I want, but this is, for now, good enough. Marked also has extensions that adds a lot of the required features (but not all...).

All of this is executed as part of a multi-stage dockerfile:

FROM node:16-alpine AS convert-stage
RUN apk add --no-cache make g++ python3
RUN npm install -g marked
COPY . /src/
WORKDIR /src
RUN make convert-blog-entries-to-html

FROM ghcr.io/gleam-lang/gleam:v1.6.3-erlang-alpine
COPY . /build/
RUN cd /build \
  && gleam export erlang-shipment \
  && mv build/erlang-shipment /app \
  && rm -r /build
COPY ./blog_entries/index.json /app/blog_entries/index.json
COPY --from=convert-stage /src/blog_entries/generated /app/blog_entries/generated

EXPOSE 8000
WORKDIR /app
ENTRYPOINT ["/app/entrypoint.sh"]
CMD ["run"]

At some point I might write my own converter, or I might contribute to the library that already exists. I'd like to do the conversion on-the-fly rather than having it as a build step, but for now it'll do.

After the above I was left with these 3 challenges left:

Luckily, the same system solves all 3: a JSON file.

[
  {
    "title": "Modern testing - Test more and better with less friction (Talk @ JavaZone 2024)",
    "id": "modern-testing-test-more-and-better-with-less-friction-talk-javazone-2024",
    "file_name": "modern_testing",
    "publish_date_time": "2024-09-06T00:00",
    "image_name": "header.png",
    "blurb": "Test more and better with less friction is a talk I did @ JavaZone 2024, all about reducing friction in our automated tests."
  }
  //... More posts ...
]

It might not be fancy, but it gets the job done.

Performance

Code performance

One concern I had was disk I/O operations. With the setup above I had, essentially, locked myself into reading files from disk. This website doesn't really have much load, but I'd like for it to handle some load regardless.

The solution I came up with was fairly uncontroversial: Caching. After all, I don't have that many posts, and keeping them in-memory is a non-issue.

How do we make a quick and dirty cache in Gleam? After all, Gleam is an immutable language, which creates some interesting challenges. In Java I'd just create a static Map or use Spring Boot's own cache. In Gleam I can't simply "add" something to a Dictionary (Map) - because it is immutable.

This is where actors help us! Beam is great with concurrency. Some would even argue that it is unmatched in that regard. It can spin up millions of threads with ease, and these threds communicate using messages. This is also how we can "mutate" state in a running application:

pub type BlogEntryCacheMessage {
  PutBlogEntry(blog_entry_id: String, blog_entry_content: String)
  GetBlogEntries(reply_with: Subject(Dict(String, String)))
}

pub fn new() -> Result(Subject(BlogEntryCacheMessage), actor.StartError) {
  actor.start(dict.new(), handle_message)
}

pub fn get_cached_blog_entry(
  blog_entry_id: String,
  actor: Subject(BlogEntryCacheMessage),
) -> Result(String, Nil) {
  process.call(actor, GetBlogEntries, 10) |> dict.get(blog_entry_id)
}

pub fn put_blog_entry(
  blog_entry_content: String,
  blog_entry_id: String,
  actor: Subject(BlogEntryCacheMessage),
) -> String {
  process.send(actor, PutBlogEntry(blog_entry_id, blog_entry_content))
  blog_entry_content
}

fn handle_message(
  message: BlogEntryCacheMessage,
  blog_entries: Dict(String, String),
) -> actor.Next(BlogEntryCacheMessage, Dict(String, String)) {
  case message {
    GetBlogEntries(actor) -> {
      process.send(actor, blog_entries)
      actor.continue(blog_entries)
    }
    PutBlogEntry(blog_entry_id, blog_entry) -> {
      blog_entries |> dict.insert(blog_entry_id, blog_entry) |> actor.continue
    }
  }
}

When the system starts up it calls the new() functions. This spawns a new process which sits and waits for a BlogEntryCacheMessage. Before we read a blog post from disk, we first check whether it is cached by calling get_cached_blog_entry. If it returnes none, then the blog post is read from the HTML, and the content is stored in the cache.

This ensures that we only read a post once. There has been scenarios where larger orgs have discovered my articles and shared it internally, which in turns have resulted in a traffic spike for a single post. Now we will only do a single disk read when such spikes occur.

The website uses caches for a few things:

The good ting here is that things only changes when there's a new blog post - which means a new deployment. Therefore there's no need to worry about caches being outdated.

Let's look at the response times:

We see that tehre are some calls that can take 0.5 seconds and, at the peak, almost take 5 seconds. The vast majority of responses takes less than 100ms - which is great.

I believe a lot of the peaks are from services waking up or starting.

Not all instances of the services boots up after a deployment. In fact, most just lay idle. This means that the first call the service gets has to also boot the service - and I believe this is happening during some of these peaks.

Once the service hasn't had traffic for some time it'll go into a hibernated state. Waking up from hibernation is much faster than from a cold boot, but there's still some time needed regardless. I believe this accounts for most of the request between the 0.5 second and 3 seconds range.

Overall I'm very happy with the performance. I will most likely dig deeper into what is going, as I'd love to have sub 1 second response time for all request, but it's a good starting point.

Bandwidth performance

The current page loads in transferres 619kB on the main page, which is pretty alright. I have a lot I can cut down, like using modern image formats, minimize scripts and so forth. There are definitely improvements to be made.

Where things are hosted is also important for responsiveness, so I've scaled the website to run from 12 different locations around the world. Everywhere from India, South Africa, USA, Sweden to Australia. The hope is to be decently close to most people.

Memory usage

The system uses an average of 122mb, which is a lot considering its just a blog. Had this been written in Go, Rust or any native language we'd probably be under a 100.

I'm more familiar with Java and Spring Boot, which easily hovers around 200-500 just starting up.

I'm also caching both images and articles in-memory, which most likely adds a good handful of megabytes.

Overall, not bad, not great. Could most likely improve it, but it is well below the smallest machine available on fly, so I won't fret to much over it.

Deployment

Deployment is fully automated with GitHub Actions:

      - uses: superfly/flyctl-actions/setup-flyctl@master
      - run: flyctl deploy --remote-only
        env:
          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}

There's a lot more going on in the CI/CD script as well:

The second some change is pushed to the main branch it'll be picked up and automatically pushed towards production.

Cost

There was two things that really bothered me with Squarespace. One being functionality, and the other being cost. I've had the site running on Fly for a month now, so what was the monthly cost?

To quote the email about billing from Fly:

Good news from Fly.io! We don’t collect bills smaller than $5.00 on your default (personal) organization.

This month, your bill of $2.92 falls below that threshold, so we’re discounting it by 100%.

This includes around 50 deployments, which also comes with some cost.

Am I happy with this result? Absolutely! Why wouldn't I be? When starting this project I wasn't aware of Fly not collecting anything under 5 dollars, so it is a pleasant surprise to say the least.

One thing it demonstrates is that this website isn't the center of the universe. It has limited reach, which is fine. But that also strengthens the argument against Squaespace's price.

Misc

Technology Use-case Comment
SimpleAnalytics Analytics Grants me some analytics without needing a cookie banner.
PrismJS Code highlighting Allows me to have code blocks with actual highlighting.
Web3Forms Contact me A simple "contact me" form so that I don't have to build anything on the backend and deal with emails.
TailwindCSS Styling I've always been awful with CSS and TailwindCSS really made my life easier.

What's left?

Comments

I won't be able to import my comments from Squarespace. It's a shame as there were a handful of comments I'd love to bring over. Unfortunately, that won't happen.

The main issue is that I really do not want a database, nor do I want to deal with implementing anti-spam functionality or administration functionality. I'm also cheap, so I don't want to pay for it if I can avoid it.

There are alternatives, like using someting like Giscus which bases itself on GitHub Discussions, but that feels a tad hacky.

Comments are, luckily, not a core feature, so I'm okay with its absence.

Markdown to HTML using pure Gleam

Some might wonder why I used Marked versus something available on Beam. Actually, there is a project that converts Markdown to HTML written in pure Gleam: kirala_bbmarkdown. The only issue is that it had some... quirks:

It also lacks a lot of the features I need. I would therefore be forced to implement a lot myself, which I found to be out of scope for V1.

At some point I might either contribute to bbmarkdown or build my own implementation where I can take the Markdown files and convert them to HTML on the fly - or maybe even convert it to Lustre!

There might be something else in Erlang or Elixir which I haven't explored that I migth be able to use as well.

Delightfully frustration free

My overall experience has been great! Gleam has been a pleasant language, and its ecosystem has offered most things I need. While there has been some challenges due to lacking documentation I've still managed to get things to working with relative ease.

All technologies involved has been great! If you have the opportunity I recommend you head over and support the creators by either using their stuff or by donating!