Skip to content

Configuring Your Server with NixOS

Caution

This guide is incomplete.

Declarative Servers

It is likely that in going through the manual steps in a tutorial like Configuring Your Server, you:

  • mistyped and/or skipped a step;
  • encountered an error not covered by the guide due to a change in a package since publication;
  • ran additional commands you found in other tutorials to install something, and didn't document them well

Your first instinct might be to write a script to perform all of the steps—but that misses a reality of actively maintaining a server for any amount of time.

Imagine that in an updated version of the tutorial we recommend additional steps to provide monitoring or logging. You'd either need to read through the tutorial and figure out which parts you didn't do the first time, or

A script wouldn't help here. You'd be in the same position, looking through the script to figure out which parts are new and how to run them.

For this reason, most people maintaining servers favor declarative tools: write down what you want the current state of the servers to be, and have the tool figure out how to get from here to there.

A side benefit of this is that rolling back changes simply takes keeping a git history of these files.

Two such tools are ansible and terraform:

  • Ansible configures servers using declarative "playbooks". Every installed package, created file, or other system options can be listed in a series of files. Running ansible commands ensures that all servers match their declared state.
  • Terraform has a similar interface, a set of Terraform config files describe the entire state of a cloud configuration. This operates a different level, creating VPS and block storage accounts, but with a similar philosophy and interface to Terraform.

NixOS

NixOS is a still somewhat experimental (though quite stable) Linux distribution. Like Debian, it is a set of prebuilt packages updated by a team of volunteers.

Everything else about it is very differnt from almost every Linux distribution in existence.

NixOS operates with a declarative model, a set of "nixfiles" describe the state of a Linux machine.

This guide aims to replicate the steps in the Debian Config in NixOS. You'll note that the philosophy is quite different, but the intention is to make an identical server.

It is recommended that you read the Debian guide for explanations of things like software choices, this guide will focus on bringing the NixOS machine into existence.

Warning

This tutorial assumes a fresh system, as created in Renting a Server. Do not run these commands on an existing server that has already been configured!

Creating a nixfiles Git Repository

Warning

Be sure to run this section on your laptop, not the server.

We first need to set up the nix command and your nixfiles describing the server.

This section runs on your laptop, not the server!

TODO: installl nix cmd

Next, we need to create a git repository for our nixfiles:

mkdir server-nixfiles/ && cd server-nixfiles && git init

Next we'll set up two private keys used for authorization and encryption.

mkdir -p extra/etc/ssh
ssh-keygen -t ed25519 -N "" -f extra/etc/ssh/ssh_host_ed25519_key
chmod 600 extra/etc/ssh/ssh_host_ed25519_key
echo "extra/" >> .gitignore   # do not commit the key
mkdir -p ~/.config/sops/age && nix run nixpkgs#age -- -keygen -o ~/.config/sops/age/keys.txt
nix run nixpkgs#ssh-to-age -- < extra/etc/ssh/ssh_host_ed25519_key.pub

Writing the nix files:

Within server-nixfiles, create:

flake.nix

{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
    sops-nix = { url = "github:Mic92/sops-nix"; inputs.nixpkgs.follows = "nixpkgs"; };
    disko    = { url = "github:nix-community/disko"; inputs.nixpkgs.follows = "nixpkgs"; };
  };
  outputs = { nixpkgs, sops-nix, disko, ... }: {
    nixosConfigurations.web1 = nixpkgs.lib.nixosSystem {
      system = "x86_64-linux";
      modules = [
        disko.nixosModules.disko
        sops-nix.nixosModules.sops
        ./disk.nix
        ./configuration.nix
      ];
    };
  };
}

disk.nix

{ modulesPath, ... }: {
  imports = [ (modulesPath + "/profiles/qemu-guest.nix") ];
  boot.loader.grub = { efiSupport = true; efiInstallAsRemovable = true; };
  boot.initrd.availableKernelModules = [ "virtio_pci" "virtio_blk" "virtio_scsi" "ahci" "sd_mod" ];

  disko.devices.disk.main = {
    device = "/dev/vda";
    type = "disk";
    content = {
      type = "gpt";
      partitions = {
        boot = { size = "1M"; type = "EF02"; };
        ESP  = { size = "512M"; type = "EF00";
                 content = { type = "filesystem"; format = "vfat"; mountpoint = "/boot"; }; };
        root = { size = "100%";
                 content = { type = "filesystem"; format = "ext4"; mountpoint = "/"; }; };
      };
    };
  };
}

configuration.nix

{ config, pkgs, ... }: {
  networking.hostName = "web1";
  system.stateVersion = "26.05";
  nix.settings = {
    experimental-features = [ "nix-command" "flakes" ];
    trusted-users = [ "me" ];
  };
  nix.gc = { automatic = true; options = "--delete-older-than 14d"; };

  # Access
  users.users.me = {
    isNormalUser = true;
    extraGroups = [ "wheel" ];
    openssh.authorizedKeys.keys = [ "ssh-ed25519 AAAA... me@laptop" ];
  };
  security.sudo.wheelNeedsPassword = false;
  services.openssh = {
    enable = true;
    settings = { PasswordAuthentication = false; PermitRootLogin = "no"; };
  };
  services.fail2ban.enable = true;
  networking.firewall = { enable = true; allowedTCPPorts = [ 22 80 443 ]; };

  # Secrets
  sops.defaultSopsFile = ./secrets.yaml;
  sops.age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];
  sops.secrets.app1_db_password.restartUnits = [ "podman-app1.service" ];
  sops.secrets.app1_api_key.restartUnits = [ "podman-app1.service" ];
  sops.templates."app1.env".content = ''
    DB_PASSWORD=${config.sops.placeholder.app1_db_password}
    API_KEY=${config.sops.placeholder.app1_api_key}
  '';

  # Containers
  virtualisation.oci-containers = {
    backend = "podman";
    containers = {
      app1 = {                                  # built from private repo
        image = "localhost/app1:latest";
        ports = [ "127.0.0.1:8081:8080" ];
        environmentFiles = [ config.sops.templates."app1.env".path ];
      };
      app2 = {                                  # public image
        image = "ghcr.io/me/app2:0.9.0";
        ports = [ "127.0.0.1:8082:3000" ];
      };
    };
  };

  # Build app1 from private git using the server's host key
  systemd.services.build-app1 = {
    description = "Build app1 image from private repo";
    path = [ pkgs.git pkgs.openssh pkgs.podman ];
    environment.GIT_SSH_COMMAND =
      "ssh -i /etc/ssh/ssh_host_ed25519_key -o IdentitiesOnly=yes "
      + "-o StrictHostKeyChecking=accept-new "
      + "-o UserKnownHostsFile=/var/lib/app1-build/known_hosts";
    serviceConfig = {
      Type = "oneshot";
      StateDirectory = "app1-build";
      WorkingDirectory = "/var/lib/app1-build";
    };
    script = ''
      if [ -d src/.git ]; then
        git -C src fetch origin main && git -C src reset --hard origin/main
      else
        git clone git@github.com:me/app1.git src
      fi
      podman build -t localhost/app1:latest src
    '';
  };
  # Restarting the container rebuilds it first
  systemd.services.podman-app1 = {
    requires = [ "build-app1.service" ];
    after = [ "build-app1.service" "network-online.target" ];
  };

  # Proxy
  services.caddy = {
    enable = true;
    virtualHosts."app1.example.com".extraConfig = "reverse_proxy 127.0.0.1:8081";
    virtualHosts."app2.example.com".extraConfig = "reverse_proxy 127.0.0.1:8082";
  };
}

Installing NixOS

git add -A
git commit -m "initial config"
nix run github:nix-community/nixos-anywhere -- \
  --flake .#web1 --extra-files ./extra \
  --build-on remote --target-host root@SERVER_IP

Writing a Justfile

host := "web1"
user := "me"
ip := "255.255.255.255"
target := user + "@" + ip

default:
    @just --list

# Apply config to the server
deploy:
    git add -A
    nixos-rebuild switch --flake .#{{host}} \
      --target-host {{target}} --build-host {{target}} --sudo

# Build on the server without activating
check:
    git add -A
    nixos-rebuild build --flake .#{{host}} \
      --target-host {{target}} --build-host {{target}} --sudo

# Update flake inputs, then deploy
upgrade:
    nix flake update
    just deploy

# Edit encrypted secrets
secrets:
    nix run nixpkgs#sops -- secrets.yaml

# Pull latest app1 code, rebuild image, restart
app1-release:
    ssh {{target}} sudo systemctl restart podman-app1

# Follow logs for a unit (default app1)
logs unit="podman-app1":
    ssh {{target}} sudo journalctl -u {{unit}} -f

# Show failed units and disk usage
status:
    ssh {{target}} 'systemctl --failed; df -h /'

# Roll back to the previous generation
rollback:
    ssh {{target}} sudo nixos-rebuild switch --rollback

# List generations
generations:
    ssh {{target}} sudo nix-env --list-generations -p /nix/var/nix/profiles/system

# Reboot the server
reboot:
    ssh {{target}} sudo reboot

# Garbage-collect old generations now
gc:
    ssh {{target}} sudo nix-collect-garbage --delete-older-than 14d

# First install on a fresh VPS (wipes disk!)
install ip:
    git add -A
    nix run github:nix-community/nixos-anywhere -- \
      --flake .#{{host}} --extra-files ./extra \
      --build-on remote --target-host root@{{ip}}