I’m looking for advice on the best workflow for managing software installations on an air-gapped (offline) NixOS server shared by untrusted users.
Setup & Constraints
The server has no direct outbound internet access.
Users log in via SSH, but they are untrusted (so they cannot be added to nix.settings.trusted-users).
Users want to manage their own software environments (e.g., via Home Manager).
Core Idea
In theory, building a package offline only requires having all of its Fixed-Output Derivations (FODs)—such as source tarballs and Git repositories—already present in the store. Since FODs are content-addressed and verified by SHA-256 hashes, importing them shouldn’t pose a security risk, even when done by untrusted users.
However, we are struggling to make this a smooth, practical workflow for end users.
Scenario 1: Pre-downloading FODs on the user’s local machine
Is it possible for users to pre-fetch all required FODs on their local machine and transfer them to the server’s store?
Manually tracking down every FOD for a complex configuration is impossible. Is there an automated tool in the Nix ecosystem that can resolve, extract, and download all FODs for a given configuration?
What if the user’s local machine does not run Nix (e.g., they are using Windows)? Is there a standalone or universal tool that can handle this?
Scenario 2: Downloading FODs on the server via a user-space proxy
If we relax the constraints slightly: nix-daemon itself remains strictly offline, but the user has a user-space proxy (e.g., SOCKS/HTTP proxy) available in their SSH session.
Can the user download the FODs in user-space using their session proxy, and then hand them over to nix-daemon to continue offline compilation in the sandbox?
Has anyone built a workflow or tool for this kind of setup, or is there an established pattern we might be missing? Any insights would be greatly appreciated!
Thanks!
Update:
LLMs were used to assist in translation and optimize expression; the original text/prompt is provided here at the request of the community policy:
算了我感觉可以上 nixos 论坛问问。你帮我撰写一下英文版吧。大概就叫“在无网络情况下部署公共服务器的最佳实践是什么?”
理论上来说,只有 FOD 部分才可能联网,因此是否可以让用户在自己的设备上预先下载完整编译所需的 FOD ,并添加到 store 中?(而 FOD 是可以通过 hash 进行验证的,这个应该不会有信任问题。)我认为这是可行的,但似乎没有找到现有的工具去完成这个事情——例如我们肯定不能要求用户手动把 home-manager 引用的 FOD 列出来,必须要有一个自动化的工具去完成这个事情。我想知道 nix 环境里是否有这样的工具。此外,如果用户本地的设备不使用 nix ,我也并不想强制他们使用,因此如果这个工具是通用的,那再好不过。
我还有一个弱化的场景——在这个服务器上,nix-daemon仍然不可访问网络。但是用户可以通过特定的用户态 PROXY 去访问。这种情况下,用户能不能再用户态完成 FOD 的下载,然后交给 nix-daemon 继续之后的编译?
Thanks for the link! I’ve briefly read through that page. So overall, the workflow seems to be:
Run nix build locally first.
Run nix derivation show -r to list the full derivations, and use jq to parse out all the FODs.
Use nix-store --export and nix-store --import to transfer them across devices.
However, in actual practice, I still have a few questions and might need something more engineering-oriented / robust:
Do I necessarily have to run nix build locally first? This implies that my local machine must have Nix installed, and it might take a considerable amount of time to build (even though I won’t be using the final build outputs locally).
Is this kind of jq processing reliable and future-proof? Do we only need to check the existence of value.outputs.out.hash? Actually, I noticed that structure might have changed, and we may need to navigate through .derivations first before applying those jq filters.
Will nix-store --import reject the import because I am not a trusted-user? Or does it automatically allow FODs? Theoretically, I think this is safe, but it probably depends on the internal implementation of Nix.
Sorry, I might have rushed into replying a bit. I found that nix derivation show can actually be used directly (e.g., nix derivation show -r .#homeConfigurations.yueyinqiu.activationPackage), without necessarily having to run nix build first (though can we use nix-store --export this way?).
As for whether nix-store --import works directly, I will do some further testing on my own.
Therefore, my only remaining concerns are the reliability of the jq parsing, and whether it’s possible to get rid of Nix on the local machine entirely.
1.) If your builds don’t contain any IFD etc. you don’t have to nix build everything locally first. evaluation will be enough to get the FODs!
2.) It turned out to be very reliable. At a customer we first scripted this and resulted in relatively short scripts that haven’t changed in years.
3.) We did not have the (non-)trusted-user constraint at our customers where we built that, but to my understanding FODs should be importable and then the rest would “just build”.
I did this a few years ago but unfortunately can’t easily find my scripts.
If you don’t already have a solution for transfering FODs over the gap, NNCP is a good tool.
The jump machine sends store paths to $HOST with something like: nix-store --export $STORE_PATHS ... | nncp-exec $HOST nix-store-import
and the airgapped machine has a receive endpoint defined something like:
For sending paths that have a closure, like actual packages, the best solution I found was to transfer using an intermediate /nix/store on some portable storage.
@state-plumber Thank you very much for your suggestion on the FOD transfer part. It also gave me some inspiration for my previous issue! Perhaps I could have a single trusted-user to receive all these FODs. (However, the subsequent question is how to verify that they are indeed FODs.)
Sorry if I don’t get your threat model here, if you have any real reason to make nix-daemon offline while giving users a proxy, but you can set up a proxy for FODs too:
Or use the configurable-impure-env experimental feature and nix.settings.impure-env = [ "HTTP_PROXY=http://proxy.example:3128" ... ];:
Actual FOD outputs should not need a trusted user, but I imagine there is something wrong with your script as nix derivation show JSON is rather unstable between nix versions and implementations. If you could pastebin the JSON that nix derivation show -r .#homeConfigurations.home.activationPackage shows on your nix, as well as nix --version, that would be helpful.
Thank you for the explanation regarding the nix-daemon proxy configuration.
In our scenario, the machine isn’t supposed to have internet connectivity at all. While users might occasionally set up a proxy via SSH remote forwarding (which is currently tolerated, even though it should violate security policy), I definitely should not configure a proxy directly for nix-daemon. And that’s why, our baseline assumption is still a completely air-gapped environment. The user-space proxy path merely as a practical workaround for users who don’t run nix locally.
Regarding the previous test, here is the Nix version being used:
I noticed that nix-store --add-fixed seems to work even for untrusted users.
Based on this, I put together an extraction tool. The current workflow is:
Uses the mentioned jq rules to parse nix derivation show and identify FODs along with their hash methods (flat or nar).
Calls nix-store --realise to download the targets locally.
Bundles the realized output using nix-store --dump.
Generates an unpacking script that, once transferred to the target server, automatically unpacks the archives (nix-store --restore) and populates the target store (nix-store --add-fixed).
Once all FOD outputs are present in the target store, the build can proceed via the standard workflow (provided inputs like nixpkgs are also uploaded).
This approach seems feasible in preliminary testing. I’ve pushed the code to a separate branch in my test repo: TestAirgappedNixOS (addfixed branch).
That said, I just realized my previous approach might have had a flaw: I think I was exporting/importing the downloader .drv itself rather than its output. That indeed seems untrusted? (I’m not completely sure yet, and I’ll keep experimenting.)
Also, the resulting size was quite surprising—the FOD outputs for just nixpkgs#hello came out to 432M, which is much larger than I expected.
That said, I just realized my previous approach might have had a flaw: I think I was exporting/importing the downloader .drv itself rather than its output. That indeed seems untrusted? (I’m not completely sure yet, and I’ll keep experimenting.)
Update: I further tried restricting it to just import/export FOD output on a new branch. It doesn’t look like it works either:
Update:
LLMs were used to assist in translation and optimize expression; the original text is provided here at the request of the community policy:
我发现 nix-store --add-fixed 似乎可以在 untrusted user 下工作。目前我初步实现了一个提取工具 https://github.com/yueyinqiu/NixFodExporter 。它基于 @tfc提供的 FOD 判断规则,解析 derivation show 的结果获取 FOD 及其 hash method (flat 或 nar );调用 nix-store --realise 将目标下载到本地;然后把它的 output (例如,下载得到的代码)使用 nix-store --dump 打包。同时生成一个解包脚本,在发送到目标服务器后,可以自动解包(通过 nix-store --restore )并添加到目标服务器的 store 中(通过 nix-store --add-fixed)。这样一来所有 FOD 的输出都已经位于 store ,接下来按正常的流程编译即可(当然还得把 nixpkgs 之类的 inputs 上传上去)。
目前这个流程经过初步验证似乎是可行的。我将它放在先前测试仓库的另一个分支中:https://github.com/yueyinqiu/TestAirgappedNixOS/tree/addfixed
不过我突然意识到之前我的做法可能有点问题,我似乎是把下载代码的 drv 本身 export/import 了,而不是它的输出。这似乎确实是不可信的?(我不是很确定,我会继续做一些尝试。)
另外这个大小和我想象的差异很大。仅仅一个 nixpkgs#hello 的 FOD 输出就要占用 432M 。
It looks like the nix-store --export format does not have CA/FOD info, and your jq was returing .drvs.
With the help of ChatGPT (GPT-5.6-Sol) I got it to make this script. You may need to play around with it a little, as I have not verified the copying, although it looks OK. I have verified the jq however. It seems like nix copy should work even to a non-trusted user as long as all the paths are CA.
ChatGPT script
set -euo pipefail
nix derivation show -r \
.#homeConfigurations.home.activationPackage > derivation.json
jq -r '
.derivations[]
| . as $drv
| .outputs
| to_entries[]
| select(.value.hash? != null)
| .key as $output
| $drv.env[$output]
' derivation.json |
sort -u > fod.txt
mapfile -t fod < fod.txt
# Make sure all the FODs actually exist locally.
nix-store --realise "${fod[@]}"
# Copy ONLY those FOD outputs into the offline machine's store.
nix copy \
--no-recursive \
--to ssh-ng://server@192.168.100.10 \
"${fod[@]}"
@bitbloxhub Oh, nix copy does work! It seems to be the easiest way to copy these FODs right now (assuming SSH is available; if not, doing nix copy twice might do it as well).
Speaking of CA, if that’s the case, could I even directly upload packages downloaded from the official cache (since the server could trust the official cache)?
A practical question is: suppose I try to nix copy a fairly complex home-manager where some packages come from the official cache, some from untrusted caches (like Cachix), and some are built locally. Is there any way to distinguish between them—uploading the final cache for the former, while uploading the FODs for the latter to be compiled on the server? (Hmm… maybe I’m asking for too much, but this seems to be super useful and significantly cut down compile times on the server!)
The above text was translated with the assistance of LLM. Here is the original text.
哦 nix copy 确实可以工作!它应该是目前最简单的复制这些 FOD 方式(如果 ssh 可用。SSH不可用的话两次copy或许也可以。)。
You don’t even need to copy over all the FODs for this! If you trust cache.nixos.org you can just copy over the cache’s final prebuilt outputs and only copy the needed FODs/drvs/other CA paths.
I got ChatGPT (GPT-5.6-Sol) to start making a script and my coding agent (Pi with GPT-5.6-Luna) to finish it, see airgap.sh in:
I can polish it up into being a real program if you want. Mostly just needs some changes to have a real documented command line interface, and not assume defaults like ./ssh_config.
Well, I (mostly my coding agent) rewrote it in Python with a nicer CLI and found a bug in Cachix!
It also has a nicer README now. Have fun with this new premade solution! If you need to export the stuff to a file and get that 1 file across the airgap physically, just make an issue and I’ll find out how to do that!