This Site is Built on NixOS

Sebastian Whiting | Mar 30, 2024 min read

My personal website was due for an overall (and in many ways simplification) and I’ve been working around Nix long enough that I decided to build out my new site on it. Well, this is it!

At a high level, these are the tools I used:

  • DigitalOcean for hosting the server (configured with Terraform)
  • A custom NixOS image which I found directions for here.

  • Flakes for configuration.

  • A flake for the hugo site, which I derived from here

Getting up to Speed

Before I could really get started, I decided to do a deeper dive into Nix. I had some working knowledge of Nix prior to this project so it was more a matter of filling in gaps. I specifically wanted to make sure I understood Flakes as they seemingly continue to grow in popularity. If you, like me, are just diving into Nix(OS) these should help you out as well!

The resources I used:

  • https://zero-to-nix.com/ from Determinate Systems for getting up and running.

  • https://nix.dev/tutorials/nix-language for getting a handle on the language. Lots of great resources here that I plan on referencing in the future.

  • Haskell Programming from First Principles Really just the first chapter or two here is super helpful, and you could probably find other tutorials. Functional programming is new to me so going over Lambda Calculus was very beneficial. I happened to have started this book just before this endeavor.

Hosting on Digital Ocean

There are two options for hosting a NixOS machine on Digital Ocean:

  1. Use NixOS-Infect. A lot of people online seem to have success with this method but I lost SSH access to the box every time I ran the script despite having my keys appropriately staged on it. I’m not ruling this out as a method, I might have made mistakes but I found the second option to be the easier way forward.

  2. Use a custom NixOS Image. Once you have the image, simply upload it to your Digital Ocean account and fire up the instance!

Configuration with Flakes

Below is my configuration.nix file for this server. The only thing I modified here was nix.settings.experimental-features and system.stateVersion. The imports were setup when I created the NixOS image and are what enables this image to work on Digital Ocean. They are best left alone!

Configuration.nix

{ modulesPath, lib, pkgs, ... }:
{
  imports = lib.optional (builtins.pathExists ./do-userdata.nix) ./do-userdata.nix ++ [
    (modulesPath + "/virtualisation/digital-ocean-config.nix")
  ];

  nix.settings.experimental-features = [ "nix-command" "flakes" ];

  system.stateVersion = "23.11";

}

So there isn’t much in configuration.nix because I’m using Flakes to manage this server. My flake.nix is below.

Flake.nix

Notes

  • The inputs section calls all the flakes I use in outputs. Some of these are referenced using git+ssh and others just use the relative file path. All my NixOS work lives in the same monorepo so I could have just stuck with relative filepaths for everything but I was experimenting.

  • My only output is a nixosConfiguration called sebsec.

  • Within that we have a handful of modules being imported. The first is my configuration.nix file from above, the next two are some flakes which define my users and standard packages for the machine.

  • The last one is calling in some additional configuration which I was not able to setup within a flake. Specifically, it setups up services.openssh.enable = true and some other OpenSSH related settings. Apparently pulling in additional nixosConfigurations from other flakes isn’t quite supported as indicated by this wonderfully snarky error message:

While loading a configuration into the module system is a very sensible idea, it can not be done cleanly in practice.

  • Also note that it calls my hugo-site package (using the system variable to ensure we grab the right version) which we will see defined below and in the ngnix settings points the root to where the package will live in /nix/store. This is extremely important because each time changes are made, that filepath will change to reflect the hash of the new build.

  • The last section does what it says, it sets up nginx and handles generating the SSL certificate. Realistically, I could move this to its own module file similar to baseConfig.nix in order to keep my main flake.nix as clean as possible. And of course, it opens up the required ports.


{
  description = "flake for yourHostNameGoesHere";

  inputs = {
    nixpkgs = {
      url = "github:NixOS/nixpkgs/nixos-23.11";
    };

    flake-utils.url = "github:numtide/flake-utils";

    users-flake.url = "git+ssh://git@github.com/sjdwhiting/NixOS.git?dir=users";
    users-flake.inputs.nixpkgs.follows = "nixpkgs";

    packages-flake.url = "git+ssh://git@github.com/sjdwhiting/NixOS.git?dir=systemPackages";
    packages-flake.inputs.nixpkgs.follows = "nixpkgs";

    hugo-site.url = "../../packages/hugo";
    hugo-site.inputs.nixpkgs.follows = "nixpkgs";
  };


  outputs = { self, nixpkgs, flake-utils, users-flake, packages-flake, hugo-site, ... }: {
    nixosConfigurations = {
      sebsec = nixpkgs.lib.nixosSystem rec {
        system = "x86_64-linux";
        modules = [
          ./configuration.nix
          users-flake.nixosModules.users
          packages-flake.nixosModules.defaultPackages
          ../../modules/baseConfig.nix
  

          # Sets up the static website based on flake
          ({ pkgs, ... }: {
            environment.systemPackages = with pkgs; [
              hugo-site.packages.${system}.website
            ];

            # Sets up NGINX and points it to the updated website.
            networking.firewall.allowedTCPPorts = [ 80 443 ]; 
            services.nginx.enable = true;
            security.acme.acceptTerms = true;
            security.acme.defaults.email = <email>";
            services.nginx.virtualHosts."sebsec.io" = {
              enableACME = true;
              forceSSL = true;
              root = "${hugo-site.packages.${system}.website}";
              locations."/".extraConfig = ''
                try_files $uri $uri/ /index.html;
              '';
            };
          })
        ];
      };
    };
  };
}

Hugo Flake

Lastly, we have the flake.nix for Hugo which we imported in the flake.nix above. What I appreciate about this is the simplicity, it made for a great way to get my hands on using flakes and achieve the goal of launching this site on NixOS.

Notes

Inputs

  1. nixpkgs
  2. hugo-coder which is a theme. I’m not actively using that theme, nor am I using inputs for my theme at this time but I left it in as an example of how you can ensure your themes are always up to date using Nix.

Outputs

  • utils.lib.eachSystem ensures this will build on multiple systems. Adjust based on your needs.

  • We are building the package and a development shell. The build of the package is pretty straightforward, but I did have to modify it a bit and you can see it contains some debugging commands.

  1. It links any themes you called as inputs into the themes folder of your hugo project

  2. It runs hugo to build the site

  3. In the installPhase it copies the results of running hugo, which is simply the public folder, to $out which is the /nix/store path and what we referenced in the above flake.nix.

  • The development shell allows you to easily work on the hugo site by either running nix develop while in the same directory as your flake or by using direnv to automatically utilize the shell defined in the flake. direnv and Nix

  • For some added context this is what my file structure looks like. Eventually, this could support multiple websites but for now you can see how the hugo site lives under the sebsec folder which is why in the buildPhase there is that cd sebsec command. Check out this quick start guide for getting up and running with hugo

File Structure

├── hugo
│   ├── flake.lock
│   ├── flake.nix
│   └── sebsec
│       ├── archetypes
│       ├── assets
│       ├── content
│       ├── data
│       ├── hugo.toml
│       ├── i18n
│       ├── layouts
│       ├── public
│       ├── resources
│       ├── static
│       └── themes

The actual flake

# To use a new theme:
# 1. Add the theme as an input
# 2. Symlink the theme to the themes directory
# 3. Update the hugo.toml file to use the new theme

# Alternativeliy, download and add the theme folder to sebsec/themes and updae hugo.toml
# This option allows you to customize the theme files if you desire. 

{
  description = "Personal website for Sebastian";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs";
    utils.url = "github:numtide/flake-utils";

    hugo-coder = {
      url = "github:luizdepra/hugo-coder";
      flake = false;
    };
  };

  outputs = inputs@{ self, nixpkgs, utils, ... }:
    utils.lib.eachSystem [
      utils.lib.system.x86_64-darwin
      utils.lib.system.x86_64-linux
      utils.lib.system.aarch64-darwin
      utils.lib.system.aarch64-linux
    ]
      (system:
        let
          pkgs = import nixpkgs {
            inherit system;
          };
        in
        rec {

          packages.website = pkgs.stdenv.mkDerivation {
            name = "website";
            src = self;
            buildInputs = [ pkgs.git pkgs.nodePackages.prettier ];
            # I've kept these debug statements because I've hadd occasional issues with the build phase and 
            # its proven valuable to have this info in the logs
            buildPhase = '' 
              cd sebsec
              echo "Currently in the following directory:"
              pwd
              ln -s ${inputs.hugo-coder} themes/hugo-coder
              echo "themes folder currently contains:"
              ls themes
              echo "Running hugo -v "
              ${pkgs.hugo}/bin/hugo --logLevel info
            '';
            installPhase = ''
            cp -r public $out
            '';
          };

        
          packages.default = packages.website;

          apps = rec {
            hugo = utils.lib.mkApp { drv = pkgs.hugo; };
            default = hugo;
          };

          devShells.default = pkgs.mkShell {
             buildInputs = [ pkgs.git pkgs.nixpkgs-fmt pkgs.hugo ]; };
        });
}

Conclusion

All in all, I’m happy with how things turned out. The site is easy to maintain now, especially with some GitHub actions setup that automate my deploys and the ability to run hugo server to do local development. I made a lot of silly mistakes as I wrestled with Nix’s syntax but I’m in am much stronger place and ready to jump into my next Nix project! As I maintain this site, I already have some plans to clean up my configs further and make things more consistent but I wanted post everything as is right now in hopes that will help others on their Nix journey.

It ain’t much but it’s honest work