Managing servers with NixOS: a gitea instance

Published on February 19, 2024

I recently moved all my servers to NixOS1, which lets these machines be configured using code. The configuration for these servers is stored in a single git repository in a gitea instance managed by one of the servers. I’m going through the configuration for one of these machines in this post.

The base of this configuration is flake.nix,

{
  description = "Nix servers configurations";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-23.11";
    home-manager.url = "github:nix-community/home-manager/release-23.11";
  };

  outputs = { self, nixpkgs, home-manager }: {
    nixosConfigurations = {
      mothership = nixpkgs.lib.nixosSystem {
        system = "x86_64-linux";
        modules = [
          ./shared.nix
          ./mothership.nix
        ];
      };
      /* ... other machine configurations ... */
    };
  };
}

When I was first starting out with Nix, I found everything here a bit complicated but it’s simple (-ish) when you break it down.

First, this is a flakes file which lets you “lock” the version of inputs you use, just like a lock file in yarn or npm. Flakes is less a feature of nix as a different mode of usage. Without flakes, external nix dependencies like nixpkgs and home-manager would be managed by something called “nix channels” and stored in a global location, behaving similar to a traditional linux package manager like apt. Since I’m using flakes the dependencies and the versions to use are stored at the flakes level. Flakes knows what a git repository is and will “lock” the commit hash to use, and the hash of the contents. If you have a flake.nix and corresponding flake.lock file you can usually recover the exact inputs of that flake, assuming they still exist on the web or in a cache somewhere. This ensure you get the exact same output.

Next, you need to understand some quirks of the nix language. The nix language is a domain-specific language for creating derivations. It is not designed to be general-purpose or for anything other than creating derivations. Almost any nix expression needs access to nixpkgs, since this behaves as a second standard library for nix. A nix file is a nix expression which will either be a primitive (string, number, etc), a set (dictionary from string to another nix value), a list, a function (takes one input and gives one output) or a derivation2. The language is lazy and doesn’t calculate anything until it’s needed. A file path is its own syntax in the nix language, and becomes a derivation with the contents of that file. The derivations are treated as strings of the path to the derivation.

And last, it’s important to understand that nix outputs have an expected structure. Although it seems like the output of a flake should just be the output with the inputs passed in as a set, something like NixOS doesn’t use the output itself as the configuration. Instead it looks in a specific path in the output, in this case output.nixosConfigurations."<hostname>". This allows a single flake file to include many configurations for different tools, while having the same dependencies. For instance, output.apps."<system>".default determines what gets run with nix run.

In this case the real magic is happening in these two NixOS modules in other files. I won’t go into shared.nix since that just creates the user for myself, and some basic configuration for that user. I also won’t go into hardware-configuration.nix or other mundane parts of the configuration, since those aren’t too interesting.

Since this server runs gitea we need nginx, which is usually the first thing I setup on my server.

services.nginx = {
  enable = true;
  recommendedProxySettings = true;
  recommendedTlsSettings = true;
  virtualHosts."mygitinstance.com" = {
    forceSSL = true;
    useACMEHost = "mygitinstance.com";
    locations."/" = {
      proxyPass = "http://unix:/run/gitea/gitea.sock";
    };
  };
  /* ... other virtual hosts ... */
};

This is modified somewhat (I do own a domain where these services are accessible that’s not the one listed).

I find this configuration sort of magical. This uses the nginx module in NixOS, which will generate the nginx file. The “recommended” options saves me from some very verbose syntax I’d otherwise be writing myself. This integrates seamlessly with ACME, so getting SSL certificates is trivial.

On a public server this would be enough (well, only a slight change), to automatically get SSL working. But because all my services are behind Tailscale we need an alternative method to pass ACME verification.

security.acme = {
  acceptTerms = true;
  defaults.email = "eric@thisdomain.com";
  certs."mynetwork.com" = {
    domain = "*.mynetwork.com";
    dnsProvider = "route53";
    dnsPropagationCheck = true;
    credentialsFile = "/var/credentials/aws_certbot";
  };
};

The credentials file is stored separately so it’s not int he nix store (the nix store is globally readable). That’s about 22 lines of configuration to serve a website behind a wildcard certificate. This will also setup systemd services on a timer to renew the certificates.

Again, I find this magical. I used to spend hours setting up the exact same configuration by hand.

Setting up Tailscale is as easy as services.tailscale.enable = true;. Setting up Tailscale this way does require manual intervention. When the service first starts up it will fail and give a url to complete the configuration.A Of course, there’s documented ways to automate this setup, but this is good enough for my case.

Setting up gitea is not much more in-depth. I have Postgres setup allow the gitea to authenticate and create the db automatically.

services.postgresql = {
  enable = true;
  ensureDatabases = ["gitea"];
  identMap = ''
# ArbitraryMapName systemUser DBUser
superuser_map      root      postgres
superuser_map      postgres  postgres
# Let other names login as themselves
superuser_map      /^(.*)$   \1
  '';
};

Then, configure gitea normally,

services.gitea = {
  enable = true;
  database = {
    type = "postgres";
  };
  settings = {
    server.PROTOCOL = "http+unix";
    server.ROOT_URL = "https://git.mynetwork.com/";
    server.DOMAIN = "git.mynetwork.com";
  };
};

There is a small amount of manual work for initial setup (that could be automated), but overall it really is this simple.

All together I have about 250 lines of nix code that configures two machines doing two separate things. The other server is serving you this blog.


  1. In order to finish blog posts, I’ve written this as a stream of conciousness and published it shortly after writing. As always, I’m putting this information out here in the hopes someone that was in a similar position as me will benefit. I love getting questions/comments. See the about page for my email. ↩︎

  2. Even after using nix for a while it’s still not clear to me if a derivation is just a special form of a set that nix treats differently, or if it’s actually a different type entirely. ↩︎