Compare commits
2 commits
65400ea651
...
b8b6e29643
Author | SHA1 | Date | |
---|---|---|---|
![]() |
b8b6e29643 | ||
![]() |
5dc6aea4a1 |
176
content/blog/nix-unprivileged-deployments.md
Normal file
176
content/blog/nix-unprivileged-deployments.md
Normal file
|
@ -0,0 +1,176 @@
|
||||||
|
---
|
||||||
|
title: Unprivileged deployments with Nix
|
||||||
|
author: me
|
||||||
|
date: 2025-04-17
|
||||||
|
tags: 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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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](https://jeancharles.quillet.org/posts/2023-08-01-Deploying-a-static-website-with-nix.html). 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](https://www.11ty.dev), 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](https://nix.dev/manual/nix/2.28/store/derivation/outputs/content-address#fixed)
|
||||||
|
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:
|
||||||
|
|
||||||
|
```nix
|
||||||
|
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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ 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:
|
||||||
|
|
||||||
|
|
||||||
|
```nix
|
||||||
|
# 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](https://github.com/serokell/deploy-rs/blob/aa07eb05537d4cd025e2310397a6adcedfe72c76/flake.nix#L58C13-L96C17) source:
|
||||||
|
```nix
|
||||||
|
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.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# remove link to old site.
|
||||||
|
rm -rf /var/lib/static-site/public
|
||||||
|
ln -sn $PROFILE/public /var/lib/static-site/public
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
96
flake.lock
96
flake.lock
|
@ -1,24 +1,110 @@
|
||||||
{
|
{
|
||||||
"nodes": {
|
"nodes": {
|
||||||
|
"deploy-rs": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-compat": "flake-compat",
|
||||||
|
"nixpkgs": "nixpkgs",
|
||||||
|
"utils": "utils"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1727447169,
|
||||||
|
"narHash": "sha256-3KyjMPUKHkiWhwR91J1YchF6zb6gvckCAY1jOE+ne0U=",
|
||||||
|
"owner": "serokell",
|
||||||
|
"repo": "deploy-rs",
|
||||||
|
"rev": "aa07eb05537d4cd025e2310397a6adcedfe72c76",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "serokell",
|
||||||
|
"repo": "deploy-rs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-compat": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1696426674,
|
||||||
|
"narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
|
||||||
|
"owner": "edolstra",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "edolstra",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1720244366,
|
"lastModified": 1702272962,
|
||||||
"narHash": "sha256-WrDV0FPMVd2Sq9hkR5LNHudS3OSMmUrs90JUTN+MXpA=",
|
"narHash": "sha256-D+zHwkwPc6oYQ4G3A1HuadopqRwUY/JkMwHz1YF7j4Q=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "49ee0e94463abada1de470c9c07bfc12b36dcf40",
|
"rev": "e97b3e4186bcadf0ef1b6be22b8558eab1cdeb5d",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"ref": "nixos-24.05",
|
"ref": "nixpkgs-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs_2": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1744463964,
|
||||||
|
"narHash": "sha256-LWqduOgLHCFxiTNYi3Uj5Lgz0SR+Xhw3kr/3Xd0GPTM=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "2631b0b7abcea6e640ce31cd78ea58910d31e650",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"nixpkgs": "nixpkgs"
|
"deploy-rs": "deploy-rs",
|
||||||
|
"nixpkgs": "nixpkgs_2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1701680307,
|
||||||
|
"narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "4022d587cbbfd70fe950c1e2083a02621806a725",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
23
flake.nix
23
flake.nix
|
@ -1,10 +1,11 @@
|
||||||
{
|
{
|
||||||
description = "Bloggy time!";
|
description = "Bloggy time!";
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
deploy-rs.url = "github:serokell/deploy-rs";
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = { self, nixpkgs }: let
|
outputs = { self, nixpkgs, deploy-rs }: let
|
||||||
systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" "x86_64-darwin" ];
|
systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" "x86_64-darwin" ];
|
||||||
forAllSystems = function:
|
forAllSystems = function:
|
||||||
nixpkgs.lib.genAttrs systems (system: function (
|
nixpkgs.lib.genAttrs systems (system: function (
|
||||||
|
@ -26,15 +27,25 @@
|
||||||
nativeBuildInputs = with pkgs; [
|
nativeBuildInputs = with pkgs; [
|
||||||
pkg-config
|
pkg-config
|
||||||
];
|
];
|
||||||
# npmDepsHash = nixpkgs.lib.fakeHash;
|
npmDepsHash = "sha256-rMU1q2BPvPihovOyjbCezd1UyEODSrtCLr9TJedOVl0=";
|
||||||
npmDepsHash = "sha256-Q7rhCjAPPn44DyUZ/uoD+7o4XH33IATfL+v1azEhuW0=";
|
|
||||||
# npmBuild = "npm run build";
|
|
||||||
src = ./.;
|
src = ./.;
|
||||||
installPhase = ''
|
installPhase = ''
|
||||||
cp -ar _site/ $out
|
mkdir -p $out/public
|
||||||
|
cp -ar _site/ $out/public
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
deploy.nodes.myblog = {
|
||||||
|
hostname = "saji.dev";
|
||||||
|
profiles.mysite = {
|
||||||
|
sshUser = "static-site";
|
||||||
|
user = "static-site";
|
||||||
|
path = deploy-rs.lib.x86_64-linux.activate.custom self.packages.x86_64-linux.default ''
|
||||||
|
rm -rf /var/lib/static-site/public
|
||||||
|
ln -sn $PROFILE/public /var/lib/static-site/public
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
devShells = forAllSystems (pkgs: {
|
devShells = forAllSystems (pkgs: {
|
||||||
default = pkgs.mkShell {
|
default = pkgs.mkShell {
|
||||||
|
|
|
@ -16,3 +16,6 @@ DIP
|
||||||
SSOP
|
SSOP
|
||||||
QF[PN]
|
QF[PN]
|
||||||
|
|
||||||
|
|
||||||
|
nixpkgs?
|
||||||
|
NixOS
|
||||||
|
|
Loading…
Reference in a new issue