tags:

tech

nix

glass beach

Using nix as a static site generator

written by ixhby; published on 2025/01/15; markdown source

A few weeks almost one year ago (???) i made this blogpost telling you how astro is a wonderful static site generator and how i made my blog using it.

Now i’m telling you to disregard everything i told you and use nix instead.

how to come up with stupid AWESOME ideas

As you may or may not know, i’m a pretty big fan of glass beach. They’re an awesome indie rock band that makes awesome music. If you haven’t listened to them, go listen to both the first glass beach album and plastic death and then come back to continue reading.

Anyway, while discussing potentially buying vinyls and/or merch with friends, i came up with the idea of a glass beach fan webring. I’ve always wanted to make a webring but didn’t have a theme for one, and now i had the perfect idea.

I had already set up a rust project with axum and sqlx when i came to an important discovery: Except for adding members, a webring is completely static content. You can just statically generate next and previous paths that redirect to the corresponding pages. And everything that can be built statically can be built with nix. As long as you’re dedicated and stupid enough.

Nix 101

Nix experts are gonna hate this :3c

Nix is both a functional programming language and a package manager*. But like any other functional programming language, it is highly impractical to write any actual programs with it.

*nix is a lot more, but listing all the things that are called nix would double the length of this article

Instead, nix (the language) serves a similar function to a makefile, telling nix (the package manager) how to build so-called derivations. Derivations are, in a broad sense, just directories (or files) that are stored in the nix store. Often these derivations contain binaries or config files, that are symlinked into their locations or the PATH to make up a functioning program or even a whole linux system. But you can put whatever you want into a derivation and it’ll show up in the nix store and can then be symlinked or directly accessed somewhere.

We’re gonna use nix to “build” a directory of html files and other assets that make up a website, and to configure our webserver of choice (nginx, always choose nginx) to serve these files, as well as redirecting the /next and /prev(ious) routes to the corresponding sites.

Writing HTML to files (but complicated)

“To display information on other’s computers, we as a species chose to build browsers that interpret HTML, CSS and Javscript. This has made a lot of people very angry and been widely regarded as a bad move.”

- Emilia, 2024

Writing text to files is a speciality of nix. So, let’s just put some HTML into the nix store by making it a derivation. Our tool for the job is pkgs.writeTextFile

packages.index = pkgs.writeTextFile {
  name = "index.html";
  text = ''
  <!DOCTYPE html>
  <html>
    <head>
      <title>bedroom community</title>
    </head>
    <body>
      <h1>glass beach fan webring</h1>
    </body>
  </html>
  '';
};

Running nix build .#index results in a file with the contents specified in the text attribute.

<!--cat result -->
<!DOCTYPE html>
<html>
  <!-- -snip- -->
</html>

However, we aren’t doing all of this shit just to write html but inside quotation marks. Since nix is a fully fledged programming language (citation needed), we can of course use concepts like lists and map to generate HTML based on data. In our case, we wanna make a list of all members in the webring. So, we first create a let binding with a list of the members

members = [
  { name = "luniya"; site = "lunakitpi.pages.gay"; } # <3
  { name = "emilia"; site = "garnix.dev"; }
  { name = "benjae"; site = "benjae.nekoweb.org"; }
  { name = "bee"; site = "frizzbees.dev"; }
];

And then we can create a table row for each member like this:

memberRows = lib.forEach ( members (x:
  ''
  <tr>
    <td>${x.name}</td>
    <td><a href="https://${x.site}">${x.site}</a></td>
  </tr>
  ''
));

Now we just apply lib.concatStrings on that so we get a single string we can insert into the html

let
  # -snip-
  memberTableRows = lib.concatStrings memberRows;
in # -snip-
''
  <!-- -snip- -->
  <table>
    <tr>
      <th>Name</th>
      <th>Site</th>
    </tr>
    ${memberTableRows}
  </table>
  <!-- -snip- -->
'';

Things other than HTML

Now in the background I’ve done some more styling for gleachring, and wanted to use the cover image of the first glass beach album for the background and favicon of the webring’s index page. Problem being: we only have a single HTML file so far. So, we have to adapt our method of pkgs.writeTextFile to instead create a directory we can add other files or derivations to. For that, we have pkgs.writeTextDir, which does the same thing but it creates a directory containing the file instead of only the file.

packages.index = let
  # -snip-
  memberTableRows = lib.concatStrings memberRows;
in pkgs.writeTextDir "/index.html" ''
  <!-- -snip- -->
'';

Now we can create a new directory for assets in our project repo, and make a simple derivation that is basically just that directory.

# Note: There are probably better ways of doing this
packages.assets = pkgs.stdenv.mkDerivation {
  name = "assets";
  # Especially this
  src = lib.fileset.toSource {
    root = ./assets;
    fileset = ./assets;
  };
  postInstall = ''
  mkdir -p $out/assets
  cp -v ./* $out/assets
  '';
};

And then we can join these two together to get a single derivation using pkgs.symlinkJoin

packages.default = pkgs.symlinkJoin {
  name = "gleachring";
  paths = [ packages.index packages.assets ]; # Note: This requires your flake outputs to be a *recursive* set
};

And now our resulting file structure looks something like this:

$ ls result
assets
index.html
$ ls result/assets
cover.jpg
favicon.ico

Actually making the webring

So far we’ve only actually made an index page listing the members and showing some info on the webring. Now we need to actually implement the webring.

A webring works by having URLs for the next and previous site, taking the current site as a parameter to know where to go. We have to somehow do this in nix. Now there were 2 options here, both of them hacky. Either we create a /next/site.html for every site that contains some JS that redirects to the site, or we make a NixOS module that configures nginx to return the corresponding 302’s to redirect to the sites at an HTTP level. The pretty obviously better option here is making the nixos module, since it doesn’t require any Javascript, less javascript is always better, and has way faster loading times. Problem with both being that you can’t use query parameters like most sites use, instead having to resort to the actual path of the url. This can introduce some caching problems, but is mostly fine.

Making a NixOS module 101

A NixOS module consists of three things:

Since the nginx module is included in base NixOS, we don’t need any imports here. Options are the configuration we define for our own module, while config is the actualy configuration we apply to either nixos directly or more commonly to other modules. In this case, our options can be pretty sparse, only having an enable option, a domain name, and 2 SSL tweaks.

nixosModules.default = { config, options, pkgs, ...}: let
  cfg = config.services.gleachring; # little shortcut that will help us later
in {
  # Note that you need to manually namespace your options or they'll be at the top-level, which is pretty bad.
  options.services.gleachring = {
    #        helper function for enable options
    enable = lib.mkEnableOption "gleachring";
    domain = lib.mkOption {
      type = lib.types.nonEmptyStr;
      example = "gleach.garnix.dev";
      description = "Domain to be used by the nginx virtual host";
    };
    # -snip-, some nginx config for SSL/ACME
  };
}

Now we have our enable option and our domain name, which we can access through the config.services.gleachring namespace, so we can start actually configuring nginx. This is basically just the nginx config language, translated into JSON nix, so we don’t have to think about much extra stuff, except for some helpers and default for e.g. HTTPS and ACME.

config = {
  services.nginx.virtualHosts = [
    "${cfg.domain}" = {
      onlySSL = cfg.ssl; # Creates a HTTPS route and a HTTP route that redirects to the HTTPS one
      enableACME = cfg.acme; # NixOS can automate certificate acquisition using ACME, it uses the name of the virtual host as the domain by default

      root = packages.default; # Once again, flake outputs need to be a recursive Set for this

      locations = {
        "/" = {
          index = "index.html";
          tryFiles = "$uri $uri.html $uri/index.html =404";
        };
      };
    };
  ];
};

A lot of fuckin’ routes

Now that we have the index page set up, we can concern ourselves with the redirection routes. Let’s look at the problem we want to solve for each host x with index n:

To iterate over all the members and create the config for their routes we can use lib.imap1, which is a 1-indexed map operation, so we apply a function to every element that takes the 1-indexed position of the element in the list as well as the element itself. You’ll see why we use a 1-index for this soon. After we’ve mapped over the list of members, we get a list of sets which we need to merge into one, for which we can use lib.attrsets.mergeAttrsList, resulting in something like this:

locations = lib.attrsets.mergeAttrsList (lib.imap1(i: v: {
  "/next/${v.site}" = {
    # TODO: Redirection
  };
  "/prev/${v.site}" = {
    # TODO: Redirection
  };
}) members);

Now we’ve removed the index route for this, which we can merge back into the set with the // operator

locations = lib.attrsets.mergeAttrsList (lib.imap1(i: v: {
  # -snip-
}) members) // {
  "/" = {
    index = "index.html";
    tryFiles = "$uri $uri.html $uri/index.html =404";
  };
};

To generate the next and previous routes correctly, we need to get the previous and next element in the members list. Also, for wrap around, we need to have a small little check if we’re the last or first element in the list and taking the first or last element of the list as the next or previous based on that.

locations = lib.attrsets.mergeAttrsList (lib.imap1(i: v: let
  x = builtins.length members;
in {
  "/next/${v.site}" = {
    return = let
      #       this is why we 1-index    this is not 1-indexed though :3c
      member = if i == x then (builtins.elemAt members 0) else (builtins.elemAt members i);
      host = member.site;
    in
    "302 https://${host}";
  };
  "/prev/${v.site}" = {
    return = let
      member = if i == 1 then (builtins.elemAt members (x - 1)) else (builtins.elemAt members (i - 2));
      host = member.site;
    in
    "302 https://${host}";
  };
}) members) // {
  "/" = {
    index = "index.html";
    tryFiles = "$uri $uri.html $uri/index.html =404";
  };
};

And now we’ve actually got everything we need!

Conclusion

”We did not do it because it was reasonable. We also did not do it because we thought it was reasonable. We did it because it was nix”

- Emilia, 2025

We’ve now made a webring with an index page and next and previous routes with as much nix as possible.

FAQ

Q: Why?

A: hehe :3c

Q: Doesn’t it take a fuck ton of time to build and update with all the overhead nix, and especially remote deployments, bring?

A: Yes :3

Q: Couldn’t you have just written the same thing with a tiny little rust HTTP server?

A: Yes :3

Q: Is this reasonable in any way?

A: No :3

Credits

This project would not have been possible without the inspiration and support of Luniya, benjae, bee and juni. Thank you <3