created: 2026-02-21 | updated: 2026-02-21

Hello World | Building My Blog with NixOS + FastAPI

There were many attempts to start my blog, I honestly lost count.
Hopefully this one lasts longer.

This time I wanted something simple, reproducible, fast and fun to maintain.
Here is how I set it up.

Stack

Domain

I bought domain saegl.me at namecheap.com. It costs $10 and needs yearly renewal
I chose namecheap because it is quite popular and has cheap in its name
All I need to configure in namecheap is Advanced DNS to point to my VPS

A Record * 172.236.220.83 Automatic
A Record @ 172.236.220.83 Automatic

This points all requests to domain saegl.me and all subdomain requests like dev.saegl.me to my VPS IP 172.236.220.83 that I rent with linode. Now that DNS points to the VPS, here's how the server is set up.

VPS + OS

I use NixOS so my entire server is defined declaratively. I rent VPS at linode.com. This one is Nanode 1 GB at $5/month. One nice thing about them, they have NixOS guide that I prefer to use as my OS. After that guide you will have ssh root access to your vps

Initial configuration took some time, about an hour. But after that I have just one file configuration.nix that defines my whole OS, here are snippets from it:

{
  networking.hostName = "malganis";
  networking.usePredictableInterfaceNames = false;
  networking.useDHCP = false;
  networking.interfaces.eth0.useDHCP = true;
  networking.firewall.allowedTCPPorts = [22 80 443];
  environment.systemPackages = with pkgs; [
    neovim
    wget
    htop
    git
    inetutils
    uv
  ];

  services.openssh = {
    enable = true;
    settings.PermitRootLogin = "prohibit-password";
    settings.PasswordAuthentication = false;
    settings.KbdInteractiveAuthentication = false;
  };
  users.users.root.openssh.authorizedKeys.keys = [
    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAID3mq6jo73DWU/soz5MM4hSh0q61HiDxBk2apfMDNsWV saegl@protonmail.com"
  ];
}

Entire configuration could be found at malganis

As you see from the snippet, I can configure my hostname, open firewall ports and install programs in single place. Unlike traditional Linux setups, where configuration is often scattered across the filesystem.

So when I need to update my VPS, I do this on my laptop

cd projects/nix/malganis
nvim configuration.nix  # to change config
just deploy

just is task runner, but actually just alias for

deploy:
    nixos-rebuild switch --target-host root@saegl.me --flake .#malganis

That will build VPS OS image locally and update remote machine. I don't need manual ssh, git pull, apt install or whatever to update my remote system

Web services

Another interesting thing you can find in the configuration is this

{
  security.acme.acceptTerms = true;
  security.acme.defaults.email = "saegl@protonmail.com";
  services.nginx = {
    enable = true;
    virtualHosts."saegl.me" = {
      default = true;
      enableACME = true;
      forceSSL = true;
      locations."/".proxyPass = "http://127.0.0.1:8000";
    };
    virtualHosts."dev.saegl.me" = {
      enableACME = true;
      forceSSL = true;
      locations."/".return = ''200 "dev.saegl.me is alive\n"'';
      extraConfig = ''default_type text/plain;'';
    };
  }
}

Nginx configuration is also inside configuration.nix, it is reverse proxy that can point outside request to domains that we configured earlier to local services. It also gives me https encryption with Let's Encrypt

One of the services is this blog

{
  systemd.services.blog = {
    description = "Blog FastAPI app";
    after = ["network.target"];
    wantedBy = ["multi-user.target"];
    serviceConfig = {
      WorkingDirectory = "/root/blog";
      ExecStart = "${pkgs.uv}/bin/uv run fastapi run --port 8000";
      Restart = "always";
      RestartSec = 5;
    };
  };
}

As you see it starts it when machine restarts or service dies. That acts as a system supervisor for my app/service. And to easily update service source, I inject this script to my VPS

{
  environment.systemPackages = with pkgs; [
    (writeShellScriptBin "deploy-blog" ''
      set -e
      cd /root/blog
      ${git}/bin/git pull
      systemctl restart blog
      echo "Blog deployed successfully"
    '')
  ];
}

And so my just runner can update service with just deploy

deploy:
    ssh root@saegl.me deploy-blog

You can imagine how it is just pulls source code on VPS and restarts systemd unit almost immediately

Blog software

I decided to build my own blog backend and frontend. There are many good complete options like jekyll, hugo and others if you want to deploy from markdown files on your own VPS. But I decided that I want more fun maintaining with FastAPI app, and so here it is:

blog app

FastAPI builds posts from markdown and puts them in Jinja templates, here is another snippet now from python backend source

@app.get("/posts/{slug}", response_class=HTMLResponse)
async def post(request: Request, slug: str):
    path = POSTS_DIR / f"{slug}.md"
    if not path.exists():
        return HTMLResponse("Not found", status_code=404)
    p = parse_post(path)
    return templates.TemplateResponse(request, "post.html", {"post": p})

The FastAPI code reads blogs posts like this one from posts dir

---
title: Hello World
created: 2026-02-21
updated: 2026-02-21
---

# Hello World | Building My Blog with NixOS + FastAPI

There were many attempts to start my blog ...

...

And then renders it with markdown-it-py and jinja to this templates:

Base template

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>{% block title %}blog{% endblock %}</title>
    <style>
        body { font-family: monospace; max-width: 700px; margin: 0 auto; padding: 1rem; }
        nav { margin-bottom: 2rem; }
        nav a { margin-right: 1rem; }
        a { color: #333; }
    </style>
</head>
<body>
    <nav>
        <a href="/">root</a>
        <a href="/posts">posts</a>
        <a href="/about">about</a>
    </nav>
    {% block content %}{% endblock %}
</body>
</html>

Post template

{% extends "base.html" %}
{% block title %}{{ post.title }}{% endblock %}
{% block content %}
<p>
    {% if post.created %}created: {{ post.created }}{% endif %}
    {% if post.updated %} | updated: {{ post.updated }}{% endif %}
</p>
{{ post.html | safe }}
{% endblock %}

Bonus - Nginx Cache

You might think that running python service is slow, or reading directories, files, parse markdown and building template. But here comes nginx caching. Right now I cache all routes since the blog is read-only.

{
  services.nginx = {
    enable = true;
    # levels=1:2 - two-level directory hierarchy for cache files
    # keys_zone=blog:10m - 10MB shared memory zone for cache keys
    # max_size=100m - 100MB max disk usage for cached responses
    # inactive=60m - remove entries not accessed for 60 minutes
    appendHttpConfig = ''
      proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=blog:10m max_size=100m inactive=60m;
    '';
    virtualHosts."saegl.me" = {
      default = true;
      enableACME = true;
      forceSSL = true;
      locations."/" = {
        proxyPass = "http://127.0.0.1:8000";
        # proxy_cache blog - use the "blog" cache zone
        # proxy_cache_valid 200 10m - cache 200 responses for 10 minutes
        # proxy_cache_use_stale - serve stale cache on error, timeout, or while updating
        # X-Cache-Status - header to check HIT/MISS/STALE etc.
        extraConfig = ''
          proxy_cache blog;
          proxy_cache_valid 200 10m;
          proxy_cache_use_stale error timeout updating;
          add_header X-Cache-Status $upstream_cache_status;
        '';
      };
    };
  };
}

I also invalidate the cache after deploy so readers always get fresh content.

{
  environment.systemPackages = with pkgs; [
    (writeShellScriptBin "deploy-blog" ''
      set -e
      cd /root/blog
      ${git}/bin/git pull
      rm -rf /var/cache/nginx/*  # Invalidate nginx cache
      systemctl restart blog
      echo "Blog deployed successfully"
    '')
  ];
}

Conclusions

It is pretty easy to have my blog online.
Change few files in VPS config and then do

just deploy

Or change few files in blog repo and then do

just deploy

It costs me 2 free github repos, 1 domain for $10/year, 1 VPS for $5/month and infinite NixOS/python tinkering