---
title: Deploying with NixOS - Site Edition
date: 2022-04-06
---
Today, I managed to make this site deployable via NixOS, as well as simplifying
the whole way I manage my NixOS servers. In this post, I want to walk you
through what I did and how I did it.
## Original flake
My [original][origflake] NixOS flakes were... a mess, to put it lightly. They were an
amalgamation of other people's flakes, and it led to things like my note-taking
laptop (which should have xournalpp on it and not much else) somehow running
libvirtd and Postgresql. Since I first wrote them, I've wanted to modularize my
home-manager configuration and allow it to be managed outside of the system
configuration.
## Modularizing
I'm pretty sure that's not a word, but it is now. The first step was to break my
`home-manager` configuration out of my NixOS system configuration. This would
let me switch around my home without needing to use `sudo`, but more
importantly, it would let me include modules properly based on the system
hostname.
To do this, I ~~stole~~ wrote a function based on one by my friend Ellie, `hmConfig`.
It's similar to `mkSystem` in that it generates a proper configuration, except
that this one is for the specific user. You can see my function
[here][hmConfig], but it's nothing too complex. It sets my home directory,
username, and home state version. It then imports a baseline `./home/home.nix`,
which sets up some other things like my shell.
The big thing is one I copied from `mkSystem`, the `++ extraImports`. This is
used further down in the flake in `homeConfigurations`, where I define the
specific modules that should be included in this home-manager configuration. For
example, my laptop (cesium), needs things like my mail setup, TeX, my X11
configuration, and mpd. The servers I run, however, don't need anything special.
This lets each system be narrowed down to exactly what it needs.
## Deploy-RS
Two days ago, I converted two servers (`kronos` and `magnesium`) to NixOS. I
quickly realized how much of a pain updating these was going to be, as they got
out of sync with my flake. I could've set up a crontab to automatically apply
the latest flake every so often, but that would be too simple (and result in too
much waiting).
Instead, I found a project called [serokell/deploy-rs][deployrs]. This let me
make a two-line script in my flake repository:
```
#!/usr/bin/env sh
set -e
nix run github:serokell/deploy-rs
```
Every time I make a change to my flake, I can run this script and have it
automatically conform every managed system. It sets up my services, and all the
stuff I expect from one of my servers. When more servers come into the NixOS
fold, I have to do a few things.
First, I need to apply a basic flake. I tend to use the `kronos` host, since
that's got nothing special attached to it. Quickly rewriting the installed
`configuration.nix` to enable flakes, I can run something like:
```
nixos-rebuild switch --flake "git+https://git.carathe.dev/muirrum/nix#kronos"
```
This will install the kronos flake (which has the unfortunate side effect of
making some interesting network decisions and setting the hostname to `kronos`).
In the future, I'll probably write a generic `server` configuration that doesn't
do anything special but enable SSH and create my user.
After that, I need to add the server to my deployment configuration. Near the
bottom of my `flake.nix` is a [`deploy`][mydeploy] output, that currently has two nodes
(the two servers I'm managing this way). It sets up the user that should be used
to SSH in as, and the nodes. I can conform this to a different configuration at
this time.
Now that this is done, I can move on to automatically configuring my services.
## Service flakes
I decided to start with my site, because it's fairly simple as a service. All it
needs to do is parse some markdown and serve it. Should be pretty simple, right?
Haha. This took two to three days of working out bugs in my site flake. The
first thing I had to do was configure some options for it and create a system
module. In my `flake.nix`, I wrote the following:
```
nixosModules.site = { config, lib, ... }: {
options = {
cara.services.carasite.enable = lib.mkEnableOption "enable cara's site";
cara.services.carasite.domain = lib.mkOption {
type = lib.types.str;
default = "devcara.com";
};
cara.services.carasite.port = lib.mkOption {
type = lib.types.port;
default = 3000;
};
};
};
```
This defines a few options that I can use in my generated configuration. Whether
or not the site should be enabled (that's the `mkEnableOption` call), and the
domain and port to use for Nginx.
I prefixed my options with "cara" just to avoid collisions with proper `nixpkgs`
modules (not that `carasite` is ever going to end up in `nixpkgs`, but you never
know).
Next up was writing the "implementation", which takes the options and generates
the way that the system should look to make the service work. My basic one looks
like this:
```
nixosModules.site = { config, lib, ... }: {
...
config = lib.mkIf config.cara.services.carasite.enable {
users.groups.cara-site = {
...
};
users.users.cara-site = {
...
};
systemd.services.cara-site = {
...
};
networking.firewall.allowedTCPPorts = [ ... ];
services.nginx = {
...
};
};
};
```
I'll go into more details on what each of those sections do in a bit. Basically,
this takes the options I'm going to set in my per-host configuration and turn it
into a workable service deployment.
### Implementation Details
#### Users & Groups
Each of these attribute sets configures a different aspect of what makes this
site run. First up are the user and group management bits, they create a service
user and group that the site will run as.
```
users.groups.cara-site = {
members = [ "cara-site" ];
}
users.users.cara-site = {
createHome = true;
isSystemUser = true;
home = "/var/lib/cara-site";
group = "cara-site";
};
```
Pretty standard stuff, and described more thoroughly in the [NixOS manual][manual-users].
#### Systemd
This is definitely the place where I had the most trouble. I was running into a
few errors related to the way I had set up my `serviceConfig`, but that was
fixed by properly setting the `WorkingDirectory`.
```
systemd.services.cara-site = {
wantedBy = [ "multi-user.target" ];
environment = {
PORT = "${toString (config.cara.services.carasite.port)}";
};
serviceConfig = {
User = "cara-site";
Group = "cara-site";
Restart = "always";
WorkingDirectory = "${defaultPackage}";
ExecStart = "${defaultPackage}/bin/carasite";
};
};
```
In the end, it's a pretty simple systemd service. It runs in the store directory
where the built site is kept, and runs as the user we configured just a moment
ago.
Here we see one of the frustrations of the Nix options system. For some reason,
the `lib.types.port` type can't be automatically made into a string, as an
integer. Even though the whole purpose of ports is to be made into strings and
then put into configuration files somehow, you still need the whole `${toString(...)}`
crap.
#### Nginx
Nginx was fairly simple. All it took was configuring the ACME settings somewhere
else in my system flake and opening the right firewall ports.
```
networking.firewall.allowedTCPPorts = [ 443 80 ];
services.nginx = {
enable = true;
recommendedProxySettings = true;
recommendedTlsSettings = true;
virtualHosts."${config.cara.services.carasite.domain}" = {
forceSSL = true;
enableACME = true;
locations."/" = {
proxyPass = "http://127.0.0.1:${toString (config.cara.services.carasite.port)}";
};
};
};
```
In the future, I should probably move the `127.0.0.1` to something that lets me
configure the bind host, but it's working for now.
## Deploying
Now that the flake is all done, it comes time to deploy it to a server. `kronos`
is my static site host, so that's where I'll be putting this.
First, I added it to my system flake's `inputs`, like this:
```
inputs = {
...
carasite = {
url = "git+https://git.carathe.dev/muirrum/site";
inputs.nixpkgs.follows = "nixpkgs";
};
};
```
This tells my flake to include `carasite` as a dependency and make its version
of `nixpkgs` follow the system flake's version.
Next, I added it to my `mkSystem` function:
```
mkSystem = conf:
nixpkgs.lib.nixosSystem rec {
modules = [
...
carasite.nixosModules.${system}.site
];
...
};
```
Note that this won't enable it for all systems, it still needs to be enabled
with the `cara.services.carasite.enable` option we defined in the site flake.
Then, I went into my `hosts/kronos/default.nix` file, and added the
configuration snippet:
```
cara.services.carasite = {
enable = true;
domain = "devcara.com";
port = 3030;
};
```
This tells the flake to enable the site only on the `kronos` server, and also
explicitly sets the defaults just in case.
Now, the only thing left to do is run the deploy script and watch the magic
work!
## Conclusion
*Wow*. This method of deploying software is so much easier and more predictable
than any other I've tried. I used to have to manually SSH in and update the site
whenever something changed, and now I can do it from my laptop.
The added convienence of being able to apply the same basic settings to all my
servers without needing to use something like ansible is also helpful.
I think a few of my Discord bots are going to be moved over next. Those should
present some added challenges in that they all require PostgreSQL to function
properly.
[origflake]: https://git.carathe.dev/muirrum/nix/src/tag/pre-modules-home
[hmConfig]: https://git.carathe.dev/muirrum/nix/src/branch/master/flake.nix#L27
[deployrs]: https://github.com/serokell/deploy-rs
[mydeploy]: https://git.carathe.dev/muirrum/nix/src/branch/master/flake.nix#L95
[manual-users]: https://nixos.org/manual/nixos/unstable/index.html#sec-user-management