Setting up a git server on NixOS

Over the years I’ve tried out many VPS providers, which has resulted in personal infrastructure spread far and wide. I’ve decided to start 2021 by consolidating this infrastructure, where reasonable. This also gives me an opportunity to properly document deployments, ideally making them easy to redeploy in the future. NixOS allows me to solve both goals relatively easily.

One piece of personal infrastructure is a git server, git.terinstock.com, which I use for personal configuration or smaller projects. Since I’m the only user, I don’t need the fancy interfaces provided by options like gogs, gitea, or GitLab. I’ve chosen to use cgit, which had a few more features I wanted over gitweb.

It did feel a little strange to deploy a cgi server in 2021. I think we call this “serverless” now?

cgit

NixOS already has cgit packaged, so we can start with configuring it.

let
  cgit = pkgs.cgit;
  cgitConfig = pkgs.writeText "cgitrc" (lib.generators.toKeyValue { } {
    css = "/cgit.css";
    logo = "/cgit.png";
    favicon = "/favicon.ico";
    about-filter = "${cgit}/lib/cgit/filters/about-formatting.sh";
    source-filter = "${cgit}/lib/cgit/filters/syntax-highlighting.py";
    clone-url = (lib.concatStringsSep " " [
      "https://$HTTP_HOST$SCRIPT_NAME/$CGIT_REPO_URL"
      "ssh://git@git.terinstock.com:$CGIT_REPO_URL"
    ]);
    enable-log-filecount = 1;
    enable-log-linecount = 1;
    enable-git-config = 1;
    root-title = "git.terinstock.com";
    root-desc = "Terin's Git Repositories";
    scan-path = "/srv/git";
  });

This assigns a derivation to cgitConfig that when evaluated would create the ini-like configuration file. Most of this comes down to personal preference with a few exceptions:

  • about-filter and source-filter reference the respective filters wpithin the cgit package. NixOS will expand these to full paths when creating the configuration file.
  • scan-path is the location on disk I’m using to host the git repositories.

git-shell

To allow for authenticated pushes, I use git-shell provided by the git project. This provides a minimal “shell” that an execute a prescribed list of git commands. I define a system user git and assign it this shell.

{
  users.users.git = {
    isSystemUser = true;
    description = "git user";
    home = "/srv/git";
    shell = "${pkgs.git}/bin/git-shell";
    openssh.authorizedKeys.keys = [
      "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIINNSIl3/j3KqW/x3kFj1ZvZlSxp+MDhk8LAIDlqs/9w"
    ];
  };
}

As I’m the only user that will be pushing to these repositories, I don’t need to configure anything extra for authenticating actions.

Web Server, h2o

Since cgit is a cgi application, it needs a web server to actually handle the HTTP connections. I’ve chosen to use h2o as it supports being an application proxy in addition to serving static files, while still being configurable with YAML/JSON.

let h2oFile = file: {
    "file.file" = "${file}";
    "file.send-compressed" = "ON";
  };
  h2oHeaderList = attrs: (lib.mapAttrsToList (k: v: "${k}: ${v}") attrs);
  h2oConfig = pkgs.writeText "h2o.conf" (lib.generators.toYAML { } {
    hosts."git.terinstock.com" = {
      paths = {
        "/cgit.css" = h2oFile ./cgit.css;
        "/cgit.png" = h2oFile "${cgit}/cgit/cgit.png";
        "/favicon.ico" = h2oFile "${cgit}/cgit/favicon.ico";
        "/robots.txt" = h2oFile "${cgit}/cgit/robots.txt";
        "/" = {
          "fastcgi.spawn" = "${pkgs.h2o}/share/h2o/fastcgi-cgi";
          setenv = {
            SCRIPT_FILENAME = "${cgit}/cgit/cgit.cgi";
            CGIT_CONFIG = "${cgitConfig}";
          };
          compress = "ON";
        };
      };
      "header.set" = {
        header = (h2oHeaderList {
          x-frame-options = "deny";
          x-xss-protection = "1, mode=block";
          x-content-type-options = "nosniff";
          referrer-policy = "no-referrer, strict-origin-when-cross-origin";
          cache-control = "no-transform";
          strict-transport-security = "max-age=63072000";
          content-security-policy = (lib.concatStringsSep "; " [
            "default-src 'none'"
            "style-src 'self' 'unsafe-inline'"
            "img-src 'self' data: https://img.shields.io"
            "script-src-attr 'unsafe-inline'"
          ]);
          expect-ct = "enforce, max-age=30";
        });
      };
      listen = {
        port = 443;
        ssl = {
          certificate-file = "/var/lib/acme/git.terinstock.com/fullchain.pem";
          key-file = "/var/lib/acme/git.terinstock.com/key.pem";
          min-version = "TLSv1.2";
          cipher-preference = "server";
          cipher-suite = (lib.concatStringsSep ":" [
            "TLS_AES_128_GCM_SHA256"
            "TLS_AES_256_GCM_SHA384"
            "TLS_CHACHA20_POLY1305_SHA256"
            "ECDHE-ECDSA-AES128-GCM-SHA256"
            "ECDHE-RSA-AES128-GCM-SHA256"
            "ECDHE-ECDSA-AES256-GCM-SHA384"
            "ECDHE-RSA-AES256-GCM-SHA384"
            "ECDHE-ECDSA-CHACHA20-POLY1305"
            "ECDHE-RSA-CHACHA20-POLY1305"
            "DHE-RSA-AES128-GCM-SHA256"
            "DHE-RSA-AES256-GCM-SHA384"
          ]);
        };
      };
    };
  });

Like with cgit’s configuration, h2oConfig is assigned a derivation that when evaluated will create a configuration file for h2o. In this configuration one host is defined and paths are defined for static assets, A fastcgi handler is configured for the root, which will be used for all paths not matched by a static file path.

h2o does not support cgi directly, but ships with a script to proxy through a fastcgi server. This wrapper is configured with the path to cgit’s and the cgit configuration file defined earlier.

This is more verbose than strictly neccessary, as I wanted to have high marks on Qualys’s SSL Labs report and Security Headers. I imagine in the future I’ll have refactored this into smaller functions and options.

The documented syntax for adding response headers involves setting the headers.set key multiple times. This is not representable in Nix, as Nix will not allow the same key to be set multiple times. An undocumented syntax using a YAML sequence instead has been available since 2.3.0-rc2.

header.set:
    header:
        - "x-frame-options: \"deny\""
        - "x-xss-protection: \"1, mode=block\""

Finally, I we need to start the h2o web server. At the time of writing, h2o does not have a NixOS module, but we can use lower-level modules ourselves.

{
  systemd.services.h2o = {
    description = "H2O web server";
    after = [ "network-online.target" "acme-git.terinstock.com.service" ];
    wantedBy = [ "multi-user.target" ];
    path = [ pkgs.perl pkgs.openssl ];
    serviceConfig = {
      ExecStart = "${pkgs.h2o}/bin/h2o --mode master --conf ${h2oConfig}";
      ExecReload = "${pkgs.coreutils}/bin/kill -s HUP $MAINPID";
      ExecStop = "${pkgs.coreutils}/bin/kill -s QUIT $MAINPID";
      User = "h2o";
      Group = "h2o";
      Type = "simple";
      Restart = "on-failure";
      AmbientCapabilities = "cap_net_bind_service";
      CapabilitiesBoundingSet = "cap_net_bind_service";
      NoNewPrivileges = true;
      LimitNPROC = 64;
      LimitNOFILE = 1048576;
      PrivateDevices = true;
      PrivetTmp = true;
      ProtectHome = true;
      ProtectSystem = "full";
      ReadOnlyPaths = "/var/lib/acme/";
      ReadWriteDirectories = "/var/lib/h2o";
    };
  };
}

This configures a systemd unit that will be enabled at startup. The ExecStart will start h2o without daemonizing, and provides the path to the evaluated h2o configuration. The PATH environment variable for the service will be updated to include Perl (for the fastcgi proxy) and OpenSSL (for OCSP stapling). I may submit a pull request to nixpkgs to fix the h2o derivation to reference these directly.

Additional settings are provided to the unit to configure systemd’s sandbox for the service.

{
  users.users.h2o = {
    group = "h2o";
    home = "/var/lib/h2o";
    createHome = true;
    isSystemUser = true;
  };

  users.groups.h2o = { };
}

Another system user is created, this time for h2o, along with a correspondng group used shortly.

ACME Certificates

NixOS ships with great support for using ACME to create TLS certificates, such as from Let’s Encrypt or your own certificate authority.

{
  security.acme = {
    # Set to true if you agree to your ACME server's Terms of Service.
    # For Let's Encrypt: https://letsencrypt.org/repository/
    acceptTerms = false; 
    email = "terinjokes@gmail.com";
    certs = {
      "git.terinstock.com" = {
        dnsProvider = "cloudflare";
        credentialsFile = "/var/lib/secrets/cloudflare.secret";
        group = "h2o";
      };
    };
  };
}

To avoiding needing to configure h2o to proxy to lego for HTTP challenges, I prefer to use the DNS ACME challenges with lego’s support for Cloudflare DNS. Configuring lego to do DNS challenges is outside the scope of this post.

Housekeeping

I configure a few more NixOS options for general housekeeping that don’t fit in the above sections.

{
  environment.systemPackages = [ pkgs.git ];
}

I install git as a system package so it’s avialable should I need to log into the box to handle something.

{
  services.openssh.passwordAuthentication = false;
  services.sshguard.enable = true;
}

I disable OpenSSH’s password authentication mechanisms, as I have a strong preference to using more secure options. I also enable sshguard to block connections that try to log in with a password anyways.

{
  nix.gc = {
    automatic = true;
    options = "--delete-older-than 30d";
  };
  nix.optimise.automatic = true;
  system.autoUpgrade = {
    enable = true;
    allowReboot = true;
  };
}

Configures NixOS to perform routine maintaince in the background. This allows for NixOS to update at a regular interval, rebooting if needed to install kernel updates, as well as optimizing and garbage collecting the nix store.

While the goal in 2021 is to have less machines to manage, this ensures I don’t forget to install security patches because I haven’t logged in for a while.