1
0
Fork 0
blog/content/blog/nix-unprivileged-deployments.md
saji 3c76eccdd1
Some checks failed
Build Blog / Build (push) Has been cancelled
content update
2025-04-17 16:45:39 -05:00

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:

  1. Build the site using a nix derivation
  2. Copy the derivation to a server using a locked down non-root account.
  3. Create well-known path that points to the latest version of the site in the nix store.
  4. 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:

  1. Make static-site a trusted user
  2. 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.