by Arpit Kumar
25 Nov, 2023
14 minute read
Sum of Bytes Tech Stack

Details about this blog tech stack and how I migrated from Ghost on Hetzner to Phoenix based markdown blog with bunch of technologies (Phoenix + Nimble Publisher + Neon Tech + Hetzner + Kamal Deploy + wsrv.nl) connected to provide me great experience


After my previous stint at an AgTech company I started thinking of writing. I picked up Ghost for my blog. I actually tried writing consistently earlier also but couldn’t continue. So this time I decided to first publish at least 10 posts and then only think of a long term solution.

Moving from Ghost to Phoenix

I wanted my blog to look like something custom and not picked from a template. So after I published 10 posts, I decided to re-look at what could be a simple tool for me where I can control back-end and front-end both. This would give me freedom to tweak the whole thing when necessary.

I was working on some side project with Phoenix framework so it was my first choice to build my blog.

Initially I started with Phoenix with Trix from basecamp but I wanted the editor to be markdown so that later I can export and use it with some other solution also.

So I started exploring markdown based solution which can easily fit with Phoenix. I tried Paradall Markdown. But knowing that dashbit blog is built with nimble_publisher I decided to give it a try and obviously it was mostly what I was looking for.

Custom Nimble Parser

Nimble Parser provides a very easy to start with Parser but I wanted few more ways to add custom attrs on top of what is provided out of the box.

I used some custom steps to add target=”_blank” on links, Table of Contents and Reading time. Those custom parser and processor I will add in the appendix at the bottom of the post if anyone is interested.

Subscription Management

Moving from a Ghost also caused me to build something for subscription management. So again I kept it simple and just added a form which captures the email address and dumps the data into a database.

The whole blog is bunch of markdown files in a folder. Subscription list needed to be in a database so I picked up NeonTech for my serverless postgres server.

While setting up my database there were couple of things which needed to be setup. First one was adding a pooler for the database and second one was disabling the named query caching from Phoenix Adapter.

This is required because named cache query may not go to the same server so we need to disable this for it to work properly.

For sending emails I relied on Sendgrid. Quite easy to integrate and use with Phoenix. I have also used it earlier for other purpose so was comfortable using it.

Serving Images with CDN

Serving rendered markdown from server is efficient but I didn’t wanted to serve images from Phoenix itself. I wanted something similar to cloudinary so that images can be easily scaled up/down, crop etc. I explored solution around that and used an open source solution provided by very generous guys at wsrv.nl.

You can use their service free of cost to serve images after modifications and are cached by cloudflare R2.


wsrv nl
https://wsrv.nl

Release and Deployment

Earlier I used manual deployment with custom shell scripts on Hetzner. But recently basecamp team released a new tool kamal to deploy services agnostic of cloud VM or bare metal. I liked the idea and decided to give it a try.

After initial hiccups of setting up correct traefik configuration it worked as advertised and I am pretty happy with the final outcome.

    
        # Name of your application. Used to uniquely configure containers.
        service: sumofbytes

        # Name of the container image.
        image: user/sumofbytes

        # Deploy to these servers.
        servers:
        web:
            hosts:
            - 192.168.0.2 # your server ip
            labels:
            traefik.http.routers.web.rule: Host(`sumofbytes.com`)
            traefik.http.routers.web_secure.entrypoints: websecure
            traefik.http.routers.web_secure.rule: Host(`sumofbytes.com`)
            traefik.http.routers.web_secure.tls: true
            traefik.http.routers.web_secure.tls.certresolver: letsencrypt

        # Credentials for your image host.
        registry:
        username: dockerhub-user-name
        password:
            - KAMAL_REGISTRY_PASSWORD
        env:
        clear:
            MIX_ENV: prod
            PHX_SERVER: true
        secret:
            - SECRET_KEY_BASE
            - DATABASE_URL
            - NEW_RELIC_LICENSE_KEY
            - AWS_ACCESS_KEY_ID
            - AWS_SECRET_ACCESS_KEY
            - OTEL_EXPORTER_OTLP_HEADERS
            - SENDGRID_KEY
        # Use a different ssh user than root
        # ssh:
        #   user: app

        # Configure builder setup.
        builder:
        remote:
            arch: arm64
            host: ssh://[email protected]
        # Use accessory services (secrets come from .env).
        # accessories:
        #   db:
        #     image: mysql:8.0
        #     host: 192.168.0.2
        #     port: 3306
        #     env:
        #       clear:
        #         MYSQL_ROOT_HOST: '%'
        #       secret:
        #         - MYSQL_ROOT_PASSWORD
        #     files:
        #       - config/mysql/production.cnf:/etc/mysql/my.cnf
        #       - db/production.sql:/docker-entrypoint-initdb.d/setup.sql
        #     directories:
        #       - data:/var/lib/mysql
        #   redis:
        #     image: redis:7.0
        #     host: 192.168.0.2
        #     port: 6379
        #     directories:
        #       - data:/data

        traefik:
        options:
            publish:
            # Do not add 80:80, as it added by default
            - "443:443"
            volume:
            - "/tmp/acme.json:/tmp/acme.json"
        args:
            entryPoints.web.address: ":80"
            entrypoints.web.http.redirections.entryPoint.to: websecure
            entrypoints.web.http.redirections.entryPoint.scheme: https
            entrypoints.web.http.redirections.entrypoint.permanent: true
            entryPoints.websecure.address: ":443"
            certificatesResolvers.letsencrypt.acme.email: "[email protected]"
            certificatesResolvers.letsencrypt.acme.storage: "/tmp/acme.json"
            certificatesResolvers.letsencrypt.acme.httpchallenge: true
            certificatesResolvers.letsencrypt.acme.httpchallenge.entrypoint: web
            accesslog: true
            accesslog.format: json

        # Configure a custom healthcheck (default is /up on port 3000)
        healthcheck:
        path: /up
        port: 4000
        interval: 10s
        # Bridge fingerprinted assets, like JS and CSS, between versions to avoid
        # hitting 404 on in-flight requests. Combines all files from new and old
        # version inside the asset_path.
        # asset_path: /rails/public/assets

        # Configure rolling deploys by setting a wait time between batches of restarts.
        # boot:
        #   limit: 10 # Can also specify as a percentage of total hosts, such as "25%"
        #   wait: 2
    

Just couple of files to manage deployment config with secrets. Everything from preparing the server to deploy docker container, building and publishing docker images and deploying the container and health check is just couple of commands.

Checkout the demo video by DHH -

Cloud Provider - Hetzner

I don’t deploy things on AWS, my choice for side projects is Hetzner. There performance and cost comparison with AWS or any other cloud provider is much better.


hetzner cloud
Hetzner

Performance Metrics

I ran bunch of performance test and wanted to see some numbers around what this server and Phoenix can provide. I ran perf test from locust tool with initial 100 concurrent users till 800 concurrent users and 95p latency was still under 700ms without any failures. This setup able to scale up till 800-900 rps without sweating a bit.

I have currently both New Relic and Opentelemetry setup (honeycomb.io) for basic monitoring.


Performance

Google Pagespeed Reports

Finally I also checked with Google Pagespeed and result are upto my satisfaction. So overall I am going to stick to this setup and let’s see how long it can serve my basic blogging requirements.


Google Pagespeed
Pagespeed

Custom Parser

    
        defmodule NimbleSchool.Parser do
            def parse(path, contents) do
                with {:ok, attrs, body} <- split(path, contents) do
                date_published = Date.from_iso8601!(Map.get(attrs, :date_published))
                date_updated = Date.from_iso8601!(Map.get(attrs, :date_updated))

                attrs = Map.put(attrs, :date_published, date_published)
                attrs = Map.put(attrs, :date_updated, date_updated)

                headers =
                    body
                    |> String.split("\n\n")
                    |> Enum.filter(&String.starts_with?(&1, "## "))
                    |> Enum.map(fn original ->
                    title = String.replace(original, "## ", "")

                    slug =
                        title
                        |> String.downcase()
                        |> String.replace(~r/[^a-z]+/, "-")
                        |> String.trim("-")

                    {original, title, slug}
                    end)

                attrs_with_reading_time =
                    Map.put(attrs, :reading_time, NimbleSchool.Blog.Utils.calculate_reading_time(body))

                if Enum.any?(headers) do
                    attrs_with_toc = Map.put(attrs_with_reading_time, :toc, append_table_of_contents(headers))
                    {attrs_with_toc, body}
                else
                    {attrs_with_reading_time, body}
                end
                end
            end

            defp split(path, contents) do
                case :binary.split(contents, ["\n---\n", "\r\n---\r\n"]) do
                [_] ->
                    {:error, "could not find separator --- in #{inspect(path)}"}

                [code, body] ->
                    case Code.eval_string(code, []) do
                    {%{} = attrs, _} ->
                        {:ok, attrs, body}

                    {other, _} ->
                        {:error,
                        "expected attributes for #{inspect(path)} to return a map, got: #{inspect(other)}"}
                    end
                end
            end

            defp append_table_of_contents(headers) do
                table =
                headers
                |> Enum.map(fn {_original, title, slug} ->
                    "[#{title}](##{slug})"
                end)

                table
            end
        end
    

Nimble Processor

    
        defmodule NimbleSchool.Processor do
            def process({"h2", [], [text], %{}}) do
                anchor_id =
                text
                |> String.downcase()
                |> String.replace(~r/[^a-z]+/, "-")
                |> String.trim("-")

                {"h2", [{"id", anchor_id}], [text], %{}}
            end

            def process({"a", [attrs], [text], %{}}) do
                {"a", [attrs, {"target", "_blank"}], [text], %{}}
            end

            def process(value), do: value
        end
    
Recent Posts

Understanding Asynchronous I/O in Linux - io_uring
Explore the evolution of I/O multiplexing from `select(2)` to `epoll(7)`, culminating in the advanced io_uring framework
Building a Rate Limiter or RPM based Throttler for your API/worker
Building a simple rate limiter / throttler based on GCRA algorithm and redis script
MicroVMs, Isolates, Wasm, gVisor: A New Era of Virtualization
Exploring the evolution and nuances of serverless architectures, focusing on the emergence of MicroVMs as a solution for isolation, security, and agility. We will discuss the differences between containers and MicroVMs, their use cases in serverless setups, and highlights notable MicroVM implementations by various companies. Focusing on FirecrackerVM, V8 isolates, wasmruntime and gVisor.

Get the "Sum of bytes" newsletter in your inbox
No spam. Just the interesting tech which makes scale possible.