All of my personal projects run on a small Hetzner cloud server with NixOS. I recently converted its configuration to use flakes, for a couple of reasons:
Many of the projects I deploy are already flakes themselves. I was using flake-compat to import them in the old setup, but had to manually update hashes on every update.
I also have some private projects in Git that I only ever sync over SSH, and aren't on any hosted Git service. Deploying these was a giant hack, basically by creating a prebuilt tarball outside Nix. I really wanted these to be simple flake inputs as well.
In this post, I'll talk about basic steps, auto-upgrades, and the challenges deploying those private projects.
A basic flake
Given an existing configuration.nix
for a machine, wrapping it in a flake is quite simple. Simply place a flake.nix
next to it containing something like:
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-22.11";
};
outputs = { nixpkgs, ... }: {
nixosConfigurations.gerbil = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [ ./configuration.nix ];
};
};
}
Now, nixos-rebuild
will simply work as before, IF you stick to two conventions:
The flake is located at:
/etc/nixos/flake.nix
(But it may also be a symlink pointing to the actual location.)The hostname matches the
nixosConfigurations
attribute name. (gerbil
in this example.)
Otherwise, you need to use the --flake
flag:
nixos-rebuild switch --flake '/path/to/flake/directory#hostname`
Automatic upgrades
Normally you'd do something like the following in your configuration to receive automatic upgrades of the base NixOS system and packages:
system.autoUpgrade.enable = true;
But this auto-upgrade system relies on channels, and channels are impure, and impurities are banned in the land of flakes. But all it takes to get auto-upgrades back is a couple extra flags:
system.autoUpgrade = {
enable = true;
flake = "/etc/nixos#gerbil";
flags = [ "--update-input" "nixpkgs" ];
};
Note that I set flake
explicitly. Even if you stick to the default location, that's necessary, because the auto-upgrade module isn't smart enough to detect yes or no flakes.
The key part to actually getting updates is adding the --update-input
flag here. You can pass this multiple times for any other inputs you want to update automatically, or you can use --recreate-lock-file
to update everything.
Remote builds
I like to manage my config not on the server, but on my laptop. One of the cool tricks of nixos-rebuild
is that you can deploy to a remote machine over SSH. This isn't even specific to flakes!
nixos-rebuild switch \
--flake '.#gerbil' \
--target-host gerbil \
--use-remote-sudo
Here, --target-host
can be anything you can SSH too. You can also add a username if necessary, but for these examples, I assume there's an alias
gerbil
in your ~/.ssh/config
.
The above works if you are on Linux (more generally: Nix on your machine knows how to build for the target). But what if you're on, say, macOS? You can tell it to build on a remote machine with --build-host
:
# If you don't have 'nixos-rebuild', for example on macOS.
nix-shell -p nixos-rebuild
nixos-rebuild switch \
--flake '.#gerbil' \
--build-host gerbil \
--target-host gerbil \
--use-remote-sudo
It's smart enough to detect when both the build and target hosts are the same, and avoids copying.
I personally put a wrapper with these flags in the devShell
of my flake:
devShells = builtins.mapAttrs (system: pkgs:
let
rebuild = pkgs.writeShellApplication {
name = "gerbil-rebuild";
runtimeInputs = [ pkgs.nixos-rebuild ];
text = ''
exec nixos-rebuild \
--flake '.#gerbil' \
--build-host gerbil \
--target-host gerbil \
--use-remote-sudo \
"$@"
'';
};
in {
default = pkgs.mkShell {
nativeBuildInputs = [ rebuild ];
};
}) nixpkgs.legacyPackages;
This allows you to do:
nix develop
gerbil-rebuild switch
But I can also highly recommend checking out direnv, which can give you
nix develop
functionality in your regular shell, as you cd
into the directory. Magic!
Fixing automatic upgrades again
So you've made some changes, done gerbil-rebuild switch
, then the next morning, your changes are gone. What the heck!
As it turns out, system.autoUpgrade.flake
still points to the old version on the server itself. In order to do auto-upgrades, the server needs to have a current version of the configuration on-hand.
Rsync to the rescue
An easy solution is to keep a copy on the server. Keeping this in the home directory of your normal (non-root) user account is perfectly fine.
rsync --recursive --delete ./ gerbil:gerbil-config/
The --delete
flag cleans up any files that shouldn't exist on the destination. Also mind that trailing /
in paths is important for rsync!
Now change your auto-upgrade configuration to point to the new directory:
system.autoUpgrade = {
enable = true;
flake = "/home/stephan/gerbil-config#gerbil";
flags = [ "--update-input" "nixpkgs" ];
};
If you end up sticking with this solution, you can create another convenient shell wrapper in your flake devShells
for the rsync command.
Git bare clone on the server
Of course, you should be keeping your config in version control. With Git, you can keep a 'bare' clone on the server that you push to.
A bare repository is essentially just the .git
directory, and no files / working tree. Not having a working tree avoids some issues when you push over SSH. (Git can't update a remote working tree, and will by default error.)
# On the server:
git init --bare gerbil-config
# Locally:
git remote add gerbil gerbil:gerbil-config
git push gerbil main
Now change your auto-upgrade configuration:
system.autoUpgrade = {
enable = true;
flake = "git+file:///home/stephan/gerbil-config";
flags = [
"--no-write-lock-file"
"--update-input" "nixpkgs"
];
};
You can see flake
is now a URL. This is because Nix supports bare Git repositories just fine, but can't detect them automatically, so we need to be explicit.
The extra --no-write-lock-file
is necessary here, or Nix will hard error when it tries to update the input, because it cannot write flake.lock
in a bare repository.
Using hosted Git
You can, of course, just use GitHub or some other hosted Git service:
system.autoUpgrade = {
enable = true;
flake = "github:stephank/gerbil-config#gerbil";
flags = [
"--no-write-lock-file"
"--update-input" "nixpkgs"
];
};
Here too we need --no-write-lock-file
, because Nix cannot write flake.lock
to a remote flake.
If the repository is private, you will have to configure credentials to access it. You can do this via the access-tokens (for GitHub or Gitlab) or
netrc-file options in nix.conf
.
Note that auto-upgrades run as root, and you probably want to limit access to those credentials to the root user. To do that, manually place them in the root user's personal config file: /root/.config/nix/nix.conf
Host your own Git
Because I have other projects that I don't want on a hosted Git service, but do want as flake inputs, I decided to build a dirt simple Git hosting myself. The only other option is to use git+file:
inputs with absolute paths that are somehow the exact same on my laptop and the server. Not impossible, but a little insane.
Fortunately, setting up Git hosting is not too far fetched. Git comes bundled with a CGI executable that can help you serve over HTTPS.
If you're using Nginx, like me, you first need to hack around the fact Nginx doesn't do CGI. I don't really blame them; process management is a finicky part of Unix. There's a tool called fcgiwrap that translates FastCGI to CGI:
services.fcgiwrap = {
enable = true;
user = "nginx";
group = "nginx";
};
With that out of the way, let's set up an Nginx virtual host:
{ config, pkgs, ... }:
let
vhost = "git.stephank.nl";
in
{
services.nginx.virtualHosts.${vhost} = {
# Configure HTTPS using a Let's Encrypt certificate.
enableACME = true;
# Force HTTP -> HTTPS upgrade.
forceSSL = true;
locations."/" = {
# Proxy to fcgiwrap.
extraConfig = ''
fastcgi_pass unix:${config.services.fcgiwrap.socketAddress};
'';
fastcgiParams = {
# Tell fcgiwrap what CGI executable to run.
SCRIPT_FILENAME = "${pkgs.git}/libexec/git-core/git-http-backend";
# Required by git-http-backend.
PATH_INFO = "$uri";
# Serve everything underneath this directory.
GIT_PROJECT_ROOT = "/srv/git";
GIT_HTTP_EXPORT_ALL = "";
};
};
# Access control.
extraConfig = ''
# Allow all traffic from the loopback interface.
allow 127.0.0.2/32;
allow ::1/128;
# Add your home IPs here.
allow 1.2.3.4/32;
# Show everyone else the door.
deny all;
'';
};
# This is so that, when the server fetches from its own Git hosting, it uses
# the loopback interface, and thus the 'allow' rules match.
networking.hosts = {
"127.0.0.2" = [ vhost ];
"::1" = [ vhost ];
};
}
So, I moved everything from my home directory to /srv/git
, but my normal user account is still owner of everything inside, allowing me to push:
# On the server:
sudo mkdir -p /srv/git
sudo chown stephan:root /srv/git
mv gerbil-config /srv/git/
# Locally:
git remote set-url gerbil gerbil:/srv/git/gerbil-config
Now change your auto-upgrade configuration:
system.autoUpgrade = {
enable = true;
flake = "git+https://git.stephank.nl/gerbil-config#gerbil";
flags = [
"--no-write-lock-file"
"--update-input" "nixpkgs"
];
};
Again, we need --no-write-lock-file
, because Nix cannot write flake.lock
to a remote flake.
Workflow
In practice, doing a remote deploy with nixos-rebuild
in this setup is only useful to iterate on changes. When you're happy with the result, you must not forget to also commit and push, or auto-upgrades will trample over your changes.