6.4 KiB
title | date | tags |
---|---|---|
Unprivileged deployments with Nix | 2025-04-17 | NixOS |
Note: this post assumes familiarity with the Nix Ecosystem.
Nix and NixOS make managing servers much easier than bespoke scripts or complex Ansible playbooks. While I don't think NixOS is ready for personal computers, it absolutely makes sense on less dynamic devices like servers or embedded-ish machines.
We can use NixOS and nixpkgs to derive a fully defined operating system and services. Then, we can:
- switch to new configurations live or on next boot
- build disk images for use as an installer or VM
- push the new configuration to a remote server using deploy-rs
The latter is interesting. With a bit of setup, we can do GitOps with rollback
and push-deployments. deploy-rs
is a glorified nix copy
with some extra magic
to "activate" the copied closure. Activation typically means running home-manager switch
or switching the NixOS profile.
When starting this blog, I wanted to have it deployed automatically like the popular SAAS options. This could be pretty simple if I wanted it to be:
npm run build
scp -r _site/ blog@my-remote-server:_site/
And then have nginx
use /home/blog/_site/
as the site root. This works, but doesn't feel
"Nix" enough.
Other users have tied their blog to the NixOS system-level configuration, like this user. While I think this is a good solution, it has the issue of relying on root access for updating the site. To me, this feels excessive. I should be able to deploy my site from a lesser-privileged user.
Scaffolding the Solution
Let's recap:
- Build the site using a nix derivation
- Copy the derivation to a server using a locked down non-root account.
- Create well-known path that points to the latest version of the site in the nix store.
- Configure
nginx
to serve from this well known path.
I'll do it manually to get an idea of how it should work.
nix build blog
I build my blog using 11ty, and I use npm run build
to generate
the static sources. Nix supports building npm-like packages with buildNpmPackage
, which
uses a Fixed-output derivation
to store the dependencies of the project. Then, for the installPhase
, we just copy the built contents
to $out
. I need to add vips
and pkg-config
as well because 11ty processes my images.
The end result looks like this:
default = pkgs.buildNpmPackage {
name = "myblog";
version = "unstable";
buildInputs = with pkgs; [
nodejs
vips
];
nativeBuildInputs = with pkgs; [
pkg-config
];
npmDepsHash = "sha256-Q7rhCjAPPn44DyUZ/uoD+7o4XH33IATfL+v1azEhuW0=";
src = ./.;
installPhase = ''
mkdir -p $out/public
cp -ar _site/ $out/public
'';
};
Then, running nix build
I get my site files in result/public
, which is what we want.
I put the files in a subdirectory so that we can use buildEnv
to merge paths later without
adding random scripts to the site contents
Copy to server
This is a little bit silly because the static site has no dependencies. We could just
use scp
to copy the output over. nix copy
goes further and also copies all the runtime
dependencies to the target, which is useful for actual programs or entire systems.
We can still use it for practice.
nix copy --to ssh://my-server '.#default'
When I log into my server, I can grep the store to find the blog:
$ ls /nix/store | grep myblog
mqhssdlmg9f03avpajwcqaah2apknl02-myblog
Now I just need a symlink to this file, and a nginx vhost. I'll create a small NixOS module that will set this up:
# TODO: write this
The last step is creating that symlink. This is where the concept of "activation" comes into play.
For NixOS, deploy-rs
activation just calls switch-to-configuration
to make the system change the profile.
We can effectively do whatever we want here.
Reading the custom activator source:
custom =
{
__functor = customSelf: base: activate:
final.buildEnv {
name = ("activatable-" + base.name);
paths =
[
base
(final.writeTextFile {
name = base.name + "-activate-path";
text = ''
#!${final.runtimeShell}
set -euo pipefail
if [[ "''${DRY_ACTIVATE:-}" == "1" ]]
then
${customSelf.dryActivate or "echo ${final.writeScript "activate" activate}"}
elif [[ "''${BOOT:-}" == "1" ]]
then
${customSelf.boot or "echo ${final.writeScript "activate" activate}"}
else
${activate}
fi
'';
executable = true;
destination = "/deploy-rs-activate";
})
(final.writeTextFile {
name = base.name + "-activate-rs";
text = ''
#!${final.runtimeShell}
exec ${final.deploy-rs.deploy-rs}/bin/activate "$@"
'';
executable = true;
destination = "/activate-rs";
})
];
};
};
This is a bit difficult to parse because there's the whole __functor
bit.
Essentially, we use buildEnv
to add some new scripts to the base
package,
which are then used to call activate
, which can be a shell script or a binary.
I'll try a simple custom activation that creates a symlink. Of note is that
the $PROFILE
variable points to the path of the buildEnv
derivation.
# remove link to old site.
rm -rf /var/lib/static-site/public
ln -sn $PROFILE/public /var/lib/static-site/public
Then when deploying with deploy-rs
:
error: cannot add path '/nix/store/2sad737aglfzmil72phv0j8s34zzmvzi-myblog' because it lacks a signature by a trusted key
Drat. This makes sense though, since it would be a bit dangerous to allow any old user write access to the nix store. We have two options:
- Make
static-site
a trusted user - Create a trusted keypair to sign our closure when it's built.
Pick your poison - the keypair mechanism is slightly more secure.
I just want to get this working, so I made static-site
a trusted user.
Note that if you wanted to use the keypair instead, deploy-rs
has a
secret environment variable
called LOCAL_KEY
which is a file that contains the signing key.