Michael Stapelberg details his migration of a NAS from CoreOS/Flatcar Linux to NixOS, covering the step-by-step transition from Docker containers to native NixOS modules with practical examples.
<p>In this article, I want to show how to migrate an existing Linux server to NixOS
— in my case the CoreOS/Flatcar Linux installation on my Network Attached
Storage (NAS) PC.</p>
<p>I will show in detail how the previous CoreOS setup looked like (lots of systemd
units starting Docker containers), how I migrated it into an intermediate state
(using Docker on NixOS) just to get things going, and finally how I migrated all
units from Docker to native NixOS modules step-by-step.</p>
<p>If you haven’t heard of NixOS, I recommend you read the <a href="https://nixos.org">first page of the NixOS
website</a> to understand what NixOS is and what sort of things
it makes possible.</p>
<p>The target audience of this blog post is people interested in trying out NixOS
for the use-case of a NAS, who like seeing examples to understand how to
configure a system.</p>
<p>You can apply these examples by first following <a href="/posts/2025-06-01-nixos-installation-declarative/">my blog post “How I like to
install NixOS
(declaratively)”</a>, then
making your way through the sections that interest you. If you prefer seeing the
full configuration, <a href="#conclusion">skip to the conclusion</a>.</p>
<a href="https://michael.stapelberg.ch/posts/2025-07-13-nixos-nas-network-storage-config/IMG_5563.jpg"><img
srcset="https://michael.stapelberg.ch/posts/2025-07-13-nixos-nas-network-storage-config/IMG_5563_hu_90f92def47078a68.jpg 2x,https://michael.stapelberg.ch/posts/2025-07-13-nixos-nas-network-storage-config/IMG_5563_hu_fbda205f2b2f2fa9.jpg 3x"
src="https://michael.stapelberg.ch/posts/2025-07-13-nixos-nas-network-storage-config/IMG_5563_hu_dfffd317a819e211.jpg"
alt="PC NAS build from 2023" title="PC NAS build from 2023"
width="600"
height="479"
style="
border: 1px solid #000;
"
loading="lazy"></a>
<h2 id="history">Context/History</h2>
<p>Over the last decade, I used a number of different operating systems for my
NAS needs. Here’s an overview of the 2 NAS systems storage2 and storage3:</p>
<table>
<thead>
<tr>
<th>Year</th>
<th>storage2</th>
<th>storage3</th>
<th>Details (blog post)</th>
</tr>
</thead>
<tbody>
<tr>
<td>2013</td>
<td>Debian on qnap</td>
<td>Debian on qnap</td>
<td><a href="/posts/2014-01-28-qnap_ts119_wol/">Wake-On-LAN with Debian on a qnap TS-119P2+</a></td>
</tr>
<tr>
<td>2016</td>
<td>CoreOS on PC</td>
<td>CoreOS on PC</td>
<td><a href="/posts/2016-11-21-gigabit-nas-coreos/">Gigabit NAS (running CoreOS)</a></td>
</tr>
<tr>
<td>2023</td>
<td>CoreOS on PC</td>
<td>Ubuntu+ZFS on PC</td>
<td><a href="/posts/2023-10-25-my-all-flash-zfs-network-storage-build/">My all-flash ZFS NAS build</a></td>
</tr>
<tr>
<td>2025</td>
<td>NixOS on PC</td>
<td>Ubuntu+ZFS on PC</td>
<td>→ you are here ←</td>
</tr>
<tr>
<td>?</td>
<td>NixOS on PC</td>
<td>NixOS+ZFS on PC</td>
<td>Converting more PCs to NixOS seems inevitable ;)</td>
</tr>
</tbody>
</table>
<h2 id="software-requirements">My NAS Software Requirements</h2>
<ul>
<li>(This post is only about software! For my usage patterns and requirements
regarding hardware selection, see <a href="/posts/2023-10-25-my-all-flash-zfs-network-storage-build/#design-goals">“Design Goals” in my My all-flash ZFS NAS
build post
(2023)</a>.)</li>
<li><strong>Remote management:</strong> I really like the model of having the configuration of
my network storage builds version-controlled and managed on my main PC. It’s a
nice property that I can regain access to my backup setup by re-installing my
NAS from my PC within minutes.</li>
<li><strong>Automated updates, with easy rollback:</strong> Updating all my installations
manually is not my idea of a good time. Hence, automated updates are a must —
but when the update breaks, a quick and easy path to recovery is also a
must.
<ul>
<li>CoreOS/Flatcar achieved that with an A/B updating scheme (update failed?
boot the old partition), whereas NixOS achieves that with its concept of a
“generation” (update failed? select the old generation), which is
finer-grained.</li>
</ul>
</li>
</ul>
<h2 id="why-migrate">Why migrate from CoreOS/Flatcar to NixOS?</h2>
<p>When I started using CoreOS, Docker was pretty new technology. I liked that
using Docker containers allowed you to treat services uniformly — ultimately,
they all expose a port of some sort (speaking HTTP, or Postgres, or…), so you
got the flexibility to run much more recent versions of software on a stable OS,
or older versions in case an update broke something.</p>
<p>Over a decade later, Docker is established tech. People nowadays take for
granted the various benefits of the container approach.</p>
<p>So, here’s my list of reasons why I wasn’t satisfied with Flatcar Linux anymore.</p>
<h4 id="cloud-init">R1. cloud-init is deprecated</h4>
<p>The <a href="https://github.com/coreos/coreos-cloudinit">CoreOS cloud-init</a> project was
deprecated at some point in favor of
<a href="https://github.com/coreos/ignition">Ignition</a>, which is clearly more powerful,
but also more cumbersome to get started with as a hobbyist. As far as I can
tell, I must host my config at some URL that I then provide via a kernel
parameter. The old way of just copying a file seems to no longer be supported.</p>
<p>Ignition also seems less convenient in other ways: YAML is no longer supported,
only JSON, which I don’t enjoy writing by hand. Also, the format seems to
<a href="https://coreos.github.io/ignition/migrating-configs/">change quite a bit</a>.</p>
<p>As a result, I never made the jump from cloud-init to Ignition, and it’s not
good to be reliant on a long-deprecated way to use your OS of choice.</p>
<h4 id="container-bitrot">R2. Container Bitrot</h4>
<p>At some point, I did an audit of all my containers on the Docker Hub and noticed
that most of them were quite outdated. For a while, Docker Hub offered automated
builds based on a <code>Dockerfile</code> obtained from GitHub. However, automated builds
now require a subscription, and I will not accept a subscription just to use my
own computers.</p>
<h4 id="r3-dependency-on-a-central-service">R3. Dependency on a central service</h4>
<p>If Docker at some point ceases operation of the Docker Hub, I am unable to
deploy software to my NAS. This isn’t a very hypothetical concern: In 2023,
Docker Hub <a href="https://news.ycombinator.com/item?id=35154025">announced the end of organizations on the Free
tier</a> and then backpedaled after
community backlash.</p>
<p>Who knows how long they can still provide free services to hobbyists like myself.</p>
<h4 id="no-immich">R4. Could not try Immich on Flatcar</h4>
<p>The final nail in the coffin was when I noticed that I could not try Immich on
my NAS system! Modern web applications like Immich need multiple Docker
containers (for Postgres, Redis, etc.) and hence only offer <a href="https://immich.app/docs/install/docker-compose">Docker
Compose</a> as a supported way of
installation.</p>
<p>Unfortunately, Flatcar <a href="https://github.com/flatcar/Flatcar/issues/894">does not include Docker
Compose</a>.</p>
<p>I was not in the mood to re-package Immich for non-Docker-Compose systems on an
ongoing basis, so I decided that a system on which I can neither run software
like Immich directly, nor even run Docker Compose, is not sufficient for my
needs anymore.</p>
<h4 id="reason-summary">Reason Summary</h4>
<p>With all of the above reasons, I would have had to set up automated container
builds, run my own central registry and would still be unable to run well-known
Open Source software like Immich.</p>
<p>Instead, I decided to try NixOS again (after a 10 year break) because it seems
like the most popular declarative solution nowadays, with a large community and
large selection of packages.</p>
<p>How does NixOS compare for my situation?</p>
<ul>
<li>Same: I also need to set up an automated job to update my NixOS systems.
<ul>
<li>I already have such a job for updating my <a href="https://gokrazy.org">gokrazy</a> devices.</li>
<li>Docker push is asynchronous: After a successful push, I still need extra
automation for pulling the updated containers on the target host and
restarting the affected services, whereas NixOS includes all of that.</li>
</ul>
</li>
<li>Better: There is no central registry. With NixOS, I can push the build result
directly to the target host via SSH.</li>
<li>Better: The corpus of available software in NixOS is much larger (including
Immich, for example) and the NixOS modules generally seem to be expressed at a
higher level of abstraction than individual Docker containers, meaning you can
configure more features with fewer lines of config.</li>
</ul>
<h2 id="vm-prototyping">Prototyping in a VM</h2>
<p>My NAS setup needs to work every day, so I wanted to prototype my desired
configuration in a VM before making changes to my system. This is not only
safer, it also allows me to discover any roadblocks, and what working with NixOS
feels like without making any commitments.</p>
<p>I copied my NixOS configuration from a previous test installation (see <a href="/posts/2025-06-01-nixos-installation-declarative/">“How I
like to install NixOS
(declaratively)”</a>) and used
the following command to build a VM image and start it in QEMU:</p>
<div class="highlight"><pre tabindex="0" style="background-color:#f0f0f0;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>nix build .#nixosConfigurations.storage2.config.system.build.vm
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#007020">export</span> <span style="color:#bb60d5">QEMU_NET_OPTS</span><span style="color:#666">=</span><span style="color:#bb60d5">hostfwd</span><span style="color:#666">=</span>tcp::2222-:22
</span></span><span style="display:flex;"><span><span style="color:#007020">export</span> <span style="color:#bb60d5">QEMU_KERNEL_PARAMS</span><span style="color:#666">=</span><span style="color:#bb60d5">console</span><span style="color:#666">=</span>ttyS0
</span></span><span style="display:flex;"><span>./result/bin/run-nixplay-vm
</span></span></code></pre></div><p>The configuration instructions below can be tried out in this VM, and once
you’re happy enough with what you have, you can repeat the steps on the actual
machine to migrate.</p>
<h2 id="migrating">Migrating</h2>
<p>For the migration of my actual system, I defined the following milestones that
should be achievable within a typical session of about an hour (after
prototyping them in a VM):</p>
<ul>
<li>M1. Install NixOS</li>
<li>M2. Set up remote disk unlock</li>
<li>M3. Set up Samba for access</li>
<li>M4. Set up SSH/rsync for backups</li>
<li>Everything extra is nice-to-have and could be deferred to a future session on
another day.</li>
</ul>
<p>In practice, this worked out exactly as planned: the actual installation of
NixOS and setting up my config to milestone M4 took a little over one hour. All
the other nice-to-haves were done over the following days and weeks as time
permitted.</p>
<p><strong>Tip:</strong> After losing data due to an installer bug in the 2000s, I have adopted
the habit of physically disconnecting all data disks (= pulling out the SATA
cable) when re-installing the system disk.</p>
<h3 id="m1-install-nixos">M1. Install NixOS</h3>
<p>After following <a href="/posts/2025-06-01-nixos-installation-declarative/">“How I like to install NixOS
(declaratively)”</a>, this is
my initial <code>configuration.nix</code>:</p>
<div class="highlight"><pre tabindex="0" style="background-color:#f0f0f0;-moz-tab-size:4;-o-tab-size:4;tab-size:4;display:grid;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span>{ modulesPath<span style="color:#666">,</span> lib<span style="color:#666">,</span> pkgs<span style="color:#666">,</span> <span style="color:#666">...</span> }:
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span> imports <span style="color:#666">=</span>
</span></span><span style="display:flex;"><span> [
</span></span><span style="display:flex;"><span> (modulesPath <span style="color:#666">+</span> <span style="color:#4070a0">"/installer/scan/not-detected.nix"</span>)
</span></span><span style="display:flex;"><span> <span style="color:#235388">./hardware-configuration.nix</span>
</span></span><span style="display:flex;"><span> <span style="color:#235388">./disk-config.nix</span>
</span></span><span style="display:flex;"><span> ];
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex; background-color:#d8d8d8"><span> <span style="color:#60a0b0;font-style:italic"># Adding michael as trusted user means</span>
</span></span><span style="display:flex; background-color:#d8d8d8"><span> <span style="color:#60a0b0;font-style:italic"># we can upgrade the system via SSH (see Makefile).</span>
</span></span><span style="display:flex; background-color:#d8d8d8"><span> nix<span style="color:#666">.</span>settings<span style="color:#666">.</span>trusted-users <span style="color:#666">=</span> [ <span style="color:#4070a0">"michael"</span> <span style="color:#4070a0">"root"</span> ];
</span></span><span style="display:flex; background-color:#d8d8d8"><span> <span style="color:#60a0b0;font-style:italic"># Clean the Nix store every week.</span>
</span></span><span style="display:flex; background-color:#d8d8d8"><span> nix<span style="color:#666">.</span>gc <span style="color:#666">=</span> {
</span></span><span style="display:flex; background-color:#d8d8d8"><span> automatic <span style="color:#666">=</span> <span style="color:#60add5">true</span>;
</span></span><span style="display:flex; background-color:#d8d8d8"><span> dates <span style="color:#666">=</span> <span style="color:#4070a0">"weekly"</span>;
</span></span><span style="display:flex; background-color:#d8d8d8"><span> options <span style="color:#666">=</span> <span style="color:#4070a0">"--delete-older-than 7d"</span>;
</span></span><span style="display:flex; background-color:#d8d8d8"><span> };
</span></span><span style="display:flex; background-color:#d8d8d8"><span>
</span></span><span style="display:flex; background-color:#d8d8d8"><span> boot<span style="color:#666">.</span>loader<span style="color:#666">.</span>systemd-boot <span style="color:#666">=</span> {
</span></span><span style="display:flex; background-color:#d8d8d8"><span> enable <span style="color:#666">=</span> <span style="color:#60add5">true</span>;
</span></span><span style="display:flex; background-color:#d8d8d8"><span> configurationLimit <span style="color:#666">=</span> <span style="color:#40a070">10</span>;
</span></span><span style="display:flex; background-color:#d8d8d8"><span> };
</span></span><span style="display:flex; background-color:#d8d8d8"><span> boot<span style="color:#666">.</span>loader<span style="color:#666">.</span>efi<span style="color:#666">.</span>canTouchEfiVariables <span style="color:#666">=</span> <span style="color:#60add5">true</span>;
</span></span><span style="display:flex; background-color:#d8d8d8"><span>
</span></span><span style="display:flex; background-color:#d8d8d8"><span> networking<span style="color:#666">.</span>hostName <span style="color:#666">=</span> <span style="color:#4070a0">"storage2"</span>;
</span></span><span style="display:flex; background-color:#d8d8d8"><span> time<span style="color:#666">.</span>timeZone <span style="color:#666">=</span> <span style="color:#4070a0">"Europe/Zurich"</span>;
</span></span><span style="display:flex; background-color:#d8d8d8"><span>
</span></span><span style="display:flex; background-color:#d8d8d8"><span> <span style="color:#60a0b0;font-style:italic"># Use systemd for networking</span>
</span></span><span style="display:flex; background-color:#d8d8d8"><span> services<span style="color:#666">.</span>resolved<span style="color:#666">.</span>enable <span style="color:#666">=</span> <span style="color:#60add5">true</span>;
</span></span><span style="display:flex; background-color:#d8d8d8"><span> networking<span style="color:#666">.</span>useDHCP <span style="color:#666">=</span> <span style="color:#60add5">false</span>;
</span></span><span style="display:flex; background-color:#d8d8d8"><span> systemd<span style="color:#666">.</span>network<span style="color:#666">.</span>enable <span style="color:#666">=</span> <span style="color:#60add5">true</span>;
</span></span><span style="display:flex; background-color:#d8d8d8"><span>
</span></span><span style="display:flex; background-color:#d8d8d8"><span> systemd<span style="color:#666">.</span>network<span style="color:#666">.</span>networks<span style="color:#666">.</span><span style="color:#4070a0">"10-e"</span> <span style="color:#666">=</span> {
</span></span><span style="display:flex; background-color:#d8d8d8"><span> matchConfig<span style="color:#666">.</span>Name <span style="color:#666">=</span> <span style="color:#4070a0">"e*"</span>; <span style="color:#60a0b0;font-style:italic"># enp9s0 (10G) or enp8s0 (1G)</span>
</span></span><span style="display:flex; background-color:#d8d8d8"><span> networkConfig <span style="color:#666">=</span> {
</span></span><span style="display:flex; background-color:#d8d8d8"><span> IPv6AcceptRA <span style="color:#666">=</span> <span style="color:#60add5">true</span>;
</span></span><span style="display:flex; background-color:#d8d8d8"><span> DHCP <span style="color:#666">=</span> <span style="color:#4070a0">"yes"</span>;
</span></span><span style="display:flex; background-color:#d8d8d8"><span> };
</span></span><span style="display:flex; background-color:#d8d8d8"><span> };
</span></span><span style="display:flex; background-color:#d8d8d8"><span>
</span></span><span style="display:flex; background-color:#d8d8d8"><span> i18n<span style="color:#666">.</span>supportedLocales <span style="color:#666">=</span> [
</span></span><span style="display:flex; background-color:#d8d8d8"><span> <span style="color:#4070a0">"en_DK.UTF-8/UTF-8"</span>
</span></span><span style="display:flex; background-color:#d8d8d8"><span> <span style="color:#4070a0">"de_DE.UTF-8/UTF-8"</span>
</span></span><span style="display:flex; background-color:#d8d8d8"><span> <span style="color:#4070a0">"de_CH.UTF-8/UTF-8"</span>
</span></span><span style="display:flex; background-color:#d8d8d8"><span> <span style="color:#4070a0">"en_US.UTF-8/UTF-8"</span>
</span></span><span style="display:flex; background-color:#d8d8d8"><span> ];
</span></span><span style="display:flex; background-color:#d8d8d8"><span> i18n<span style="color:#666">.</span>defaultLocale <span style="color:#666">=</span> <span style="color:#4070a0">"en_US.UTF-8"</span>;
</span></span><span style="display:flex; background-color:#d8d8d8"><span>
</span></span><span style="display:flex; background-color:#d8d8d8"><span> users<span style="color:#666">.</span>mutableUsers <span style="color:#666">=</span> <span style="color:#60add5">false</span>;
</span></span><span style="display:flex; background-color:#d8d8d8"><span> security<span style="color:#666">.</span>sudo<span style="color:#666">.</span>wheelNeedsPassword <span style="color:#666">=</span> <span style="color:#60add5">false</span>;
</span></span><span style="display:flex; background-color:#d8d8d8"><span> users<span style="color:#666">.</span>users<span style="color:#666">.</span>michael <span style="color:#666">=</span> {
</span></span><span style="display:flex; background-color:#d8d8d8"><span> openssh<span style="color:#666">.</span>authorizedKeys<span style="color:#666">.</span>keys <span style="color:#666">=</span> [
</span></span><span style="display:flex; background-color:#d8d8d8"><span> <span style="color:#4070a0">"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5secret"</span>
</span></span><span style="display:flex; background-color:#d8d8d8"><span> <span style="color:#4070a0">"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5key"</span>
</span></span><span style="display:flex; background-color:#d8d8d8"><span> ];
</span></span><span style="display:flex; background-color:#d8d8d8"><span>
</span></span><span style="display:flex; background-color:#d8d8d8"><span> isNormalUser <span style="color:#666">=</span> <span style="color:#60add5">true</span>;
</span></span><span style="display:flex; background-color:#d8d8d8"><span> description <span style="color:#666">=</span> <span style="color:#4070a0">"Michael Stapelberg"</span>;
</span></span><span style="display:flex; background-color:#d8d8d8"><span> extraGroups <span style="color:#666">=</span> [ <span style="color:#4070a0">"networkmanager"</span> <span style="color:#4070a0">"wheel"</span> ];
</span></span><span style="display:flex; background-color:#d8d8d8"><span> initialPassword <span style="color:#666">=</span> <span style="color:#4070a0">"secret"</span>; <span style="color:#60a0b0;font-style:italic"># XXX: change!</span>
</span></span><span style="display:flex; background-color:#d8d8d8"><span> shell <span style="color:#666">=</span> pkgs<span style="color:#666">.</span>zsh;
</span></span><span style="display:flex; background-color:#d8d8d8"><span> packages <span style="color:#666">=</span> <span style="color:#007020;font-weight:bold">with</span> pkgs; [];
</span></span><span style="display:flex; background-color:#d8d8d8"><span> };
</span></span><span style="display:flex; background-color:#d8d8d8"><span>
</span></span><span style="display:flex; background-color:#d8d8d8"><span> environment<span style="color:#666">.</span>systemPackages <span style="color:#666">=</span> <span style="color:#007020;font-weight:bold">with</span> pkgs; [
</span></span><span style="display:flex; background-color:#d8d8d8"><span> git <span style="color:#60a0b0;font-style:italic"># for checking out github.com/stapelberg/configfiles</span>
</span></span><span style="display:flex; background-color:#d8d8d8"><span> rsync
</span></span><span style="display:flex; background-color:#d8d8d8"><span> zsh
</span></span><span style="display:flex; background-color:#d8d8d8"><span> vim
</span></span><span style="display:flex; background-color:#d8d8d8"><span> emacs
</span></span><span style="display:flex; background-color:#d8d8d8"><span> wget
</span></span><span style="display:flex; background-color:#d8d8d8"><span> curl
</span></span><span style="display:flex; background-color:#d8d8d8"><span> ];
</span></span><span style="display:flex; background-color:#d8d8d8"><span>
</span></span><span style="display:flex; background-color:#d8d8d8"><span> programs<span style="color:#666">.</span>zsh<span style="color:#666">.</span>enable <span style="color:#666">=</span> <span style="color:#60add5">true</span>;
</span></span><span style="display:flex; background-color:#d8d8d8"><span>
</span></span><span style="display:flex; background-color:#d8d8d8"><span> services<span style="color:#666">.</span>openssh<span style="color:#666">.</span>enable <span style="color:#666">=</span> <span style="color:#60add5">true</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#60a0b0;font-style:italic"># This value determines the NixOS release from which the default</span>
</span></span><span style="display:flex;"><span> <span style="color:#60a0b0;font-style:italic"># settings for stateful data, like file locations and database versions</span>
</span></span><span style="display:flex;"><span> <span style="color:#60a0b0;font-style:italic"># on your system were taken. It‘s perfectly fine and recommended to leave</span>
</span></span><span style="display:flex;"><span> <span style="color:#60a0b0;font-style:italic"># this value at the release version of the first install of this system.</span>
</span></span><span style="display:flex;"><span> <span style="color:#60a0b0;font-style:italic"># Before changing this value read the documentation for this option</span>
</span></span><span style="display:flex;"><span> <span style="color:#60a0b0;font-style:italic"># (e.g. man configuration.nix or on https://nixos.org/nixos/options.html).</span>
</span></span><span style="display:flex;"><span> system<span style="color:#666">.</span>stateVersion <span style="color:#666">=</span> <span style="color:#4070a0">"25.05"</span>; <span style="color:#60a0b0;font-style:italic"># Did you read the comment?</span>
</span></span><span style="display:flex;"><span>}</span></span></code></pre></div>
<p>All following sections describe changes within this <code>configuration.nix</code>.</p>
<p>All devices in my home network obtain their IP address via DHCP. If I want to
make an IP address static, I configure it accordingly on my router.</p>
<p>My NAS PCs have one specialty with regards to IP addressing: They are reachable
via IPv4 and IPv6, and the IPv6 address can be derived from the IPv4 address.</p>
<p>Hence, I changed the systemd-networkd configuration from above such that it
configures a static IPv6 address in a dynamically configured IPv6 network:</p>
<div class="highlight"><pre tabindex="0" style="background-color:#f0f0f0;-moz-tab-size:4;-o-tab-size:4;tab-size:4;display:grid;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span> systemd<span style="color:#666">.</span>network<span style="color:#666">.</span>networks<span style="color:#666">.</span><span style="color:#4070a0">"10-e"</span> <span style="color:#666">=</span> {
</span></span><span style="display:flex;"><span> matchConfig<span style="color:#666">.</span>Name <span style="color:#666">=</span> <span style="color:#4070a0">"e*"</span>; <span style="color:#60a0b0;font-style:italic"># enp9s0 (10G) or enp8s0 (1G)</span>
</span></span><span style="display:flex;"><span> networkConfig <span style="color:#666">=</span> {
</span></span><span style="display:flex;"><span> IPv6AcceptRA <span style="color:#666">=</span> <span style="color:#60add5">true</span>;
</span></span><span style="display:flex;"><span> DHCP <span style="color:#666">=</span> <span style="color:#4070a0">"yes"</span>;
</span></span><span style="display:flex;"><span> };
</span></span><span style="display:flex; background-color:#d8d8d8"><span> ipv6AcceptRAConfig <span style="color:#666">=</span> {
</span></span><span style="display:flex; background-color:#d8d8d8"><span> Token <span style="color:#666">=</span> <span style="color:#4070a0">"::10:0:0:252"</span>;
</span></span><span style="display:flex; background-color:#d8d8d8"><span> };
</span></span><span style="display:flex;"><span> };</span></span></code></pre></div>
<p>✅ This fulfills milestone M1.</p>
<h3 id="m2-set-up-remote-disk-unlock">M2. Set up remote disk unlock</h3>
<p>To unlock my encrypted disks on boot, I have a custom systemd service unit that
uses <a href="https://manpages.debian.org/wget.1"><code>wget(1)</code></a>
and <a href="https://manpages.debian.org/cryptsetup.8"><code>cryptsetup(8)</code></a>
to split the key file between the NAS and a remote server (= an
attacker needs both pieces to unlock).</p>
<p>With CoreOS/Flatcar, my <code>cloud-init</code> configuration looked as follows:</p>
<div class="highlight"><pre tabindex="0" style="background-color:#f0f0f0;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#062873;font-weight:bold">coreos</span>:<span style="color:#bbb">
</span></span></span><span style="display:flex;"><span><span style="color:#bbb"> </span><span style="color:#062873;font-weight:bold">units</span>:<span style="color:#bbb">
</span></span></span><span style="display:flex;"><span><span style="color:#bbb"> </span>- <span style="color:#062873;font-weight:bold">name</span>:<span style="color:#bbb"> </span>unlock.service<span style="color:#bbb">
</span></span></span><span style="display:flex;"><span><span style="color:#bbb"> </span><span style="color:#062873;font-weight:bold">command</span>:<span style="color:#bbb"> </span>start<span style="color:#bbb">
</span></span></span><span style="display:flex;"><span><span style="color:#bbb"> </span><span style="color:#062873;font-weight:bold">content</span>:<span style="color:#bbb"> </span>|<span style="color:#4070a0;font-style:italic">
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> [Unit]
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> Description=unlock hard drive
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> Wants=network.target
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> After=systemd-networkd-wait-online.service
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> Before=samba.service
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic">
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> [Service]
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> Type=oneshot
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> RemainAfterExit=yes
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> # Wait until the host is actually reachable.
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> ExecStart=/bin/sh -c "c=0; while [ $c -lt 5 ]; do /bin/ping6 -n -c 1 r.zekjur.net && break; c=$((c+1)); sleep 1; done"
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> ExecStart=/bin/sh -c "[ -e \"/dev/mapper/S5SSNF0T205183F_crypt\" ] || (echo -n my_local_secret && wget --retry-connrefused --ca-directory=/dev/null --ca-certificate=/etc/ssl/certs/r.zekjur.net.crt -qO - https://r.zekjur.net:8443/nascrypto) | /sbin/cryptsetup --key-file=- luksOpen /dev/disk/by-id/ata-Samsung_SSD_870_QVO_8TB_S5SSNF0T205183F S5SSNF0T205183F_crypt"
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> ExecStart=/bin/sh -c "[ -e \"/dev/mapper/S5SSNJ0T205991B_crypt\" ] || (echo -n my_local_secret && wget --retry-connrefused --ca-directory=/dev/null --ca-certificate=/etc/ssl/certs/r.zekjur.net.crt -qO - https://r.zekjur.net:8443/nascrypto) | /sbin/cryptsetup --key-file=- luksOpen /dev/disk/by-id/ata-Samsung_SSD_870_QVO_8TB_S5SSNJ0T205991B S5SSNJ0T205991B_crypt"
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> ExecStart=/bin/sh -c "vgchange -ay"
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> ExecStart=/bin/mount /dev/mapper/data-data /srv</span><span style="color:#bbb">
</span></span></span><span style="display:flex;"><span><span style="color:#bbb">
</span></span></span><span style="display:flex;"><span><span style="color:#bbb"></span><span style="color:#062873;font-weight:bold">write_files</span>:<span style="color:#bbb">
</span></span></span><span style="display:flex;"><span><span style="color:#bbb"> </span>- <span style="color:#062873;font-weight:bold">path</span>:<span style="color:#bbb"> </span>/etc/ssl/certs/r.zekjur.net.crt<span style="color:#bbb">
</span></span></span><span style="display:flex;"><span><span style="color:#bbb"> </span><span style="color:#062873;font-weight:bold">content</span>:<span style="color:#bbb"> </span>|<span style="color:#4070a0;font-style:italic">
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> -----BEGIN CERTIFICATE-----
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> MIID8TCCAlmgAwIBAgIRAPWwvYWpoH+lGKv6rxZvC4MwDQYJKoZIhvcNAQELBQAw
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> […]
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> -----END CERTIFICATE-----</span><span style="color:#bbb">
</span></span></span></code></pre></div><p>I converted it into the following NixOS configuration:</p>
<div class="highlight"><pre tabindex="0" style="background-color:#f0f0f0;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span> systemd<span style="color:#666">.</span>services<span style="color:#666">.</span>unlock <span style="color:#666">=</span> {
</span></span><span style="display:flex;"><span> wantedBy <span style="color:#666">=</span> [ <span style="color:#4070a0">"multi-user.target"</span> ];
</span></span><span style="display:flex;"><span> description <span style="color:#666">=</span> <span style="color:#4070a0">"unlock hard drive"</span>;
</span></span><span style="display:flex;"><span> wants <span style="color:#666">=</span> [ <span style="color:#4070a0">"network.target"</span> ];
</span></span><span style="display:flex;"><span> after <span style="color:#666">=</span> [ <span style="color:#4070a0">"systemd-networkd-wait-online.service"</span> ];
</span></span><span style="display:flex;"><span> serviceConfig <span style="color:#666">=</span> {
</span></span><span style="display:flex;"><span> Type <span style="color:#666">=</span> <span style="color:#4070a0">"oneshot"</span>;
</span></span><span style="display:flex;"><span> RemainAfterExit <span style="color:#666">=</span> <span style="color:#4070a0">"yes"</span>;
</span></span><span style="display:flex;"><span> ExecStart <span style="color:#666">=</span> [
</span></span><span style="display:flex;"><span> <span style="color:#60a0b0;font-style:italic"># Wait until the host is actually reachable.</span>
</span></span><span style="display:flex;"><span> <span style="color:#4070a0">''/bin/sh -c "c=0; while [ $c -lt 5 ]; do </span><span style="color:#70a0d0">${</span>pkgs<span style="color:#666">.</span>iputils<span style="color:#70a0d0">}</span><span style="color:#4070a0">/bin/ping -n -c 1 r.zekjur.net && break; c=$((c+1)); sleep 1; done"''</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#4070a0">''/bin/sh -c "[ -e \"/dev/mapper/S5SSNF0T205183F_crypt\" ] || (echo -n my_local_secret && </span><span style="color:#70a0d0">${</span>pkgs<span style="color:#666">.</span>wget<span style="color:#70a0d0">}</span><span style="color:#4070a0">/bin/wget --retry-connrefused --ca-directory=/dev/null --ca-certificate=/etc/ssl/certs/r.zekjur.net.crt -qO - https://r.zekjur.net:8443/sdb2_crypt) | </span><span style="color:#70a0d0">${</span>pkgs<span style="color:#666">.</span>cryptsetup<span style="color:#70a0d0">}</span><span style="color:#4070a0">/bin/cryptsetup --key-file=- luksOpen /dev/disk/by-id/ata-Samsung_SSD_870_QVO_8TB_S5SSNF0T205183F S5SSNF0T205183F_crypt"''</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#4070a0">''/bin/sh -c "[ -e \"/dev/mapper/S5SSNJ0T205991B_crypt\" ] || (echo -n my_local_secret && </span><span style="color:#70a0d0">${</span>pkgs<span style="color:#666">.</span>wget<span style="color:#70a0d0">}</span><span style="color:#4070a0">/bin/wget --retry-connrefused --ca-directory=/dev/null --ca-certificate=/etc/ssl/certs/r.zekjur.net.crt -qO - https://r.zekjur.net:8443/sdc2_crypt) | </span><span style="color:#70a0d0">${</span>pkgs<span style="color:#666">.</span>cryptsetup<span style="color:#70a0d0">}</span><span style="color:#4070a0">/bin/cryptsetup --key-file=- luksOpen /dev/disk/by-id/ata-Samsung_SSD_870_QVO_8TB_S5SSNJ0T205991B S5SSNJ0T205991B_crypt"''</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#4070a0">''/bin/sh -c "</span><span style="color:#70a0d0">${</span>pkgs<span style="color:#666">.</span>lvm2<span style="color:#70a0d0">}</span><span style="color:#4070a0">/bin/vgchange -ay"''</span>
</span></span><span style="display:flex;"><span> <span style="color:#4070a0">''/run/wrappers/bin/mount /dev/mapper/data-data /srv''</span>
</span></span><span style="display:flex;"><span> ];
</span></span><span style="display:flex;"><span> };
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> };
</span></span></code></pre></div><p>We’ll also need to store the custom TLS certificate file on disk. For that, we
can use the <code>environment.</code> configuration:</p>
<div class="highlight"><pre tabindex="0" style="background-color:#f0f0f0;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span> environment<span style="color:#666">.</span>etc<span style="color:#666">.</span><span style="color:#4070a0">"ssl/certs/r.zekjur.net.crt"</span><span style="color:#666">.</span>text <span style="color:#666">=</span> <span style="color:#4070a0">''
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0">-----BEGIN CERTIFICATE-----
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0">MIID8TCCAlmgAwIBAgIRAPWwvYWpoH+lGKv6rxZvC4MwDQYJKoZIhvcNAQELBQAw
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0">[…]
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0">-----END CERTIFICATE-----
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0">''</span>;
</span></span></code></pre></div><p>The references like <code>${pkgs.wget}</code> will be replaced with a path to the Nix store
(<a href="https://nix.dev/tutorials/nix-language.html#paths">→ nix.dev
documentation</a>). On
CoreOS/Flatcar, I was limited to using just the (minimal set of) software
included in the base image, or I had to reach for Docker. On NixOS, we can use
all packages available in nixpkgs.</p>
<p>After <a href="/posts/2025-06-01-nixos-installation-declarative/#making-changes">deploying</a>
and <code>reboot</code>ing, I can access my unlocked disk under <code>/srv</code>! 🎉</p>
<pre tabindex="0"><code>% df -h /srv
Filesystem Size Used Avail Use% Mounted on
/dev/mapper/data-data 15T 14T 342G 98% /srv
</code></pre><p>When listing my files, I noticed that the group id was different between my old
system and the new system. This can be fixed by explicitly specifying the
desired group id:</p>
<div class="highlight"><pre tabindex="0" style="background-color:#f0f0f0;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span> users<span style="color:#666">.</span>groups<span style="color:#666">.</span>michael <span style="color:#666">=</span> {
</span></span><span style="display:flex;"><span> gid <span style="color:#666">=</span> <span style="color:#40a070">1000</span>; <span style="color:#60a0b0;font-style:italic"># for consistency with storage3</span>
</span></span><span style="display:flex;"><span> };
</span></span></code></pre></div><p>✅ M2 is complete.</p>
<h3 id="m3-set-up-samba-for-access">M3. Set up Samba for access</h3>
<p>Whereas I want to configure remote disk unlock at the systemd service level, for
Samba I want to use Docker: I wanted to first transfer my old (working)
Docker-based setups as they are, and only later convert them to Nix.</p>
<p>We enable the <a href="https://search.nixos.org/options?query=virtualisation.docker.enable">Docker NixOS
module</a>
which sets up the daemons that Docker needs and whatever else is needed to make
it work:</p>
<div class="highlight"><pre tabindex="0" style="background-color:#f0f0f0;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span> virtualisation<span style="color:#666">.</span>docker<span style="color:#666">.</span>enable <span style="color:#666">=</span> <span style="color:#60add5">true</span>;
</span></span></code></pre></div><p>This is already sufficient for other services to use Docker, but I also want to
be able to run the <code>docker</code> command interactively for debugging. Therefore, I
added <code>docker</code> to <code>systemPackages</code>:</p>
<div class="highlight"><pre tabindex="0" style="background-color:#f0f0f0;-moz-tab-size:4;-o-tab-size:4;tab-size:4;display:grid;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span> environment<span style="color:#666">.</span>systemPackages <span style="color:#666">=</span> <span style="color:#007020;font-weight:bold">with</span> pkgs; [
</span></span><span style="display:flex;"><span> git <span style="color:#60a0b0;font-style:italic"># for checking out github.com/stapelberg/configfiles</span>
</span></span><span style="display:flex;"><span> rsync
</span></span><span style="display:flex;"><span> zsh
</span></span><span style="display:flex;"><span> vim
</span></span><span style="display:flex;"><span> emacs
</span></span><span style="display:flex;"><span> wget
</span></span><span style="display:flex;"><span> curl
</span></span><span style="display:flex; background-color:#d8d8d8"><span> docker
</span></span><span style="display:flex;"><span> ];</span></span></code></pre></div>
<p>After deploying this configuration, I can run <code>docker run -ti debian</code> to verify things work.</p>
<p>The <code>cloud-init</code> version of samba looked like this:</p>
<div class="highlight"><pre tabindex="0" style="background-color:#f0f0f0;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#062873;font-weight:bold">coreos</span>:<span style="color:#bbb">
</span></span></span><span style="display:flex;"><span><span style="color:#bbb"> </span><span style="color:#062873;font-weight:bold">units</span>:<span style="color:#bbb">
</span></span></span><span style="display:flex;"><span><span style="color:#bbb"> </span>- <span style="color:#062873;font-weight:bold">name</span>:<span style="color:#bbb"> </span>samba.service<span style="color:#bbb">
</span></span></span><span style="display:flex;"><span><span style="color:#bbb"> </span><span style="color:#062873;font-weight:bold">command</span>:<span style="color:#bbb"> </span>start<span style="color:#bbb">
</span></span></span><span style="display:flex;"><span><span style="color:#bbb"> </span><span style="color:#062873;font-weight:bold">content</span>:<span style="color:#bbb"> </span>|<span style="color:#4070a0;font-style:italic">
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> [Unit]
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> Description=samba server
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> After=docker.service unlock.mount
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> Requires=docker.service unlock.mount
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic">
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> [Service]
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> Restart=always
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> StartLimitInterval=0
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic">
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> # Always pull the latest version (bleeding edge).
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> ExecStartPre=-/usr/bin/docker pull stapelberg/docker-samba:latest
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic">
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> # Set up samba users (cannot be done in the (public) Dockerfile because
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> # users/passwords are sensitive information).
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> ExecStartPre=-/usr/bin/docker kill smb
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> ExecStartPre=-/usr/bin/docker rm smb
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> ExecStartPre=-/usr/bin/docker rm smb-prep
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> ExecStartPre=/usr/bin/docker run --name smb-prep stapelberg/docker-samba sh -c 'adduser --quiet --disabled-password --gecos "" --uid 29901 michael && sed -i "s,\\[global\\],[global]\\nserver multi channel support = yes\\naio read size = 1\\naio write size = 1,g" /etc/samba/smb.conf'
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> ExecStartPre=/usr/bin/docker commit smb-prep smb-prepared
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> ExecStartPre=/usr/bin/docker rm smb-prep
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> ExecStartPre=/usr/bin/docker run --name smb-prep smb-prepared /bin/sh -c "echo \"secret\nsecret\n" | tee - | smbpasswd -a -s michael"
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> ExecStartPre=/usr/bin/docker commit smb-prep smb-prepared
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic">
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> ExecStart=/usr/bin/docker run \
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> -p 137:137 \
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> -p 138:138 \
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> -p 139:139 \
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> -p 445:445 \
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> --tmpfs=/run \
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> -v /srv/data:/srv/data \
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> --name smb \
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> -t \
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> smb-prepared \
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> /usr/sbin/smbd --foreground --debug-stdout --no-process-group</span><span style="color:#bbb">
</span></span></span></code></pre></div><p>We can translate this 1:1 to NixOS:</p>
<div class="highlight"><pre tabindex="0" style="background-color:#f0f0f0;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span> systemd<span style="color:#666">.</span>services<span style="color:#666">.</span>samba <span style="color:#666">=</span> {
</span></span><span style="display:flex;"><span> wantedBy <span style="color:#666">=</span> [ <span style="color:#4070a0">"multi-user.target"</span> ];
</span></span><span style="display:flex;"><span> description <span style="color:#666">=</span> <span style="color:#4070a0">"samba server"</span>;
</span></span><span style="display:flex;"><span> after <span style="color:#666">=</span> [ <span style="color:#4070a0">"unlock.service"</span> ];
</span></span><span style="display:flex;"><span> requires <span style="color:#666">=</span> [ <span style="color:#4070a0">"unlock.service"</span> ];
</span></span><span style="display:flex;"><span> serviceConfig <span style="color:#666">=</span> {
</span></span><span style="display:flex;"><span> Restart <span style="color:#666">=</span> <span style="color:#4070a0">"always"</span>;
</span></span><span style="display:flex;"><span> StartLimitInterval <span style="color:#666">=</span> <span style="color:#40a070">0</span>;
</span></span><span style="display:flex;"><span> ExecStartPre <span style="color:#666">=</span> [
</span></span><span style="display:flex;"><span> <span style="color:#60a0b0;font-style:italic"># Always pull the latest version.</span>
</span></span><span style="display:flex;"><span> <span style="color:#4070a0">''-</span><span style="color:#70a0d0">${</span>pkgs<span style="color:#666">.</span>docker<span style="color:#70a0d0">}</span><span style="color:#4070a0">/bin/docker pull stapelberg/docker-samba:latest''</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#60a0b0;font-style:italic"># Set up samba users (cannot be done in the (public) Dockerfile because</span>
</span></span><span style="display:flex;"><span> <span style="color:#60a0b0;font-style:italic"># users/passwords are sensitive information).</span>
</span></span><span style="display:flex;"><span> <span style="color:#4070a0">''-</span><span style="color:#70a0d0">${</span>pkgs<span style="color:#666">.</span>docker<span style="color:#70a0d0">}</span><span style="color:#4070a0">/bin/docker kill smb''</span>
</span></span><span style="display:flex;"><span> <span style="color:#4070a0">''-</span><span style="color:#70a0d0">${</span>pkgs<span style="color:#666">.</span>docker<span style="color:#70a0d0">}</span><span style="color:#4070a0">/bin/docker rm smb''</span>
</span></span><span style="display:flex;"><span> <span style="color:#4070a0">''-</span><span style="color:#70a0d0">${</span>pkgs<span style="color:#666">.</span>docker<span style="color:#70a0d0">}</span><span style="color:#4070a0">/bin/docker rm smb-prep''</span>
</span></span><span style="display:flex;"><span> <span style="color:#4070a0">''-</span><span style="color:#70a0d0">${</span>pkgs<span style="color:#666">.</span>docker<span style="color:#70a0d0">}</span><span style="color:#4070a0">/bin/docker run --name smb-prep stapelberg/docker-samba sh -c 'adduser --quiet --disabled-password --gecos "" --uid 29901 michael && sed -i "s,\\[global\\],[global]\\nserver multi channel support = yes\\naio read size = 1\\naio write size = 1,g" /etc/samba/smb.conf' ''</span>
</span></span><span style="display:flex;"><span> <span style="color:#4070a0">''-</span><span style="color:#70a0d0">${</span>pkgs<span style="color:#666">.</span>docker<span style="color:#70a0d0">}</span><span style="color:#4070a0">/bin/docker commit smb-prep smb-prepared''</span>
</span></span><span style="display:flex;"><span> <span style="color:#4070a0">''-</span><span style="color:#70a0d0">${</span>pkgs<span style="color:#666">.</span>docker<span style="color:#70a0d0">}</span><span style="color:#4070a0">/bin/docker rm smb-prep''</span>
</span></span><span style="display:flex;"><span> <span style="color:#4070a0">''-</span><span style="color:#70a0d0">${</span>pkgs<span style="color:#666">.</span>docker<span style="color:#70a0d0">}</span><span style="color:#4070a0">/bin/docker run --name smb-prep smb-prepared /bin/sh -c "echo \"secret\nsecret\n" | tee - | smbpasswd -a -s michael"''</span>
</span></span><span style="display:flex;"><span> <span style="color:#4070a0">''-</span><span style="color:#70a0d0">${</span>pkgs<span style="color:#666">.</span>docker<span style="color:#70a0d0">}</span><span style="color:#4070a0">/bin/docker commit smb-prep smb-prepared''</span>
</span></span><span style="display:flex;"><span> ];
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> ExecStart <span style="color:#666">=</span> <span style="color:#4070a0">''-</span><span style="color:#70a0d0">${</span>pkgs<span style="color:#666">.</span>docker<span style="color:#70a0d0">}</span><span style="color:#4070a0">/bin/docker run \
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> -p 137:137 \
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> -p 138:138 \
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> -p 139:139 \
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> -p 445:445 \
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> --tmpfs=/run \
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> -v /srv/data:/srv/data \
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> --name smb \
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> -t \
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> smb-prepared \
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> /usr/sbin/smbd --foreground --debug-stdout --no-process-group
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> ''</span>;
</span></span><span style="display:flex;"><span> };
</span></span><span style="display:flex;"><span> };
</span></span><span style="display:flex;"><span><span style="">}</span>
</span></span></code></pre></div><p>✅ Now I can manage my files over the network, which completes M3!</p>
<p>See also: <a href="#samba-nixos">Nice-to-haves: N5. samba from NixOS</a></p>
<h3 id="m4-set-up-sshrsync-for-backups">M4. Set up SSH/rsync for backups</h3>
<p>For backing up data, I use rsync over SSH. I restrict this SSH access to run
only rsync commands by using <code>rrsync</code> (in a Docker container). To configure the
SSH <a href="https://manpages.debian.org/authorized_keys.5"><code>authorized_keys(5)</code></a>
, we set:</p>
<div class="highlight"><pre tabindex="0" style="background-color:#f0f0f0;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span> users<span style="color:#666">.</span>users<span style="color:#666">.</span>root<span style="color:#666">.</span>openssh<span style="color:#666">.</span>authorizedKeys<span style="color:#666">.</span>keys <span style="color:#666">=</span> [
</span></span><span style="display:flex;"><span> <span style="color:#4070a0">''command="</span><span style="color:#70a0d0">${</span>pkgs<span style="color:#666">.</span>docker<span style="color:#70a0d0">}</span><span style="color:#4070a0">/bin/docker run --log-driver none -i -e SSH_ORIGINAL_COMMAND -v /srv/backup/midna:/srv/backup/midna stapelberg/docker-rsync /srv/backup/midna" ssh-rsa AAAAB3Npublickey root@midna''</span>
</span></span><span style="display:flex;"><span> <span style="">}</span>;
</span></span></code></pre></div><p>✅ A successful test backup run completes milestone M4!</p>
<p>See also: <a href="#rrsync-nixos">Nice-to-haves: N6. rrsync from NixOS</a></p>
<h2 id="nice-to-haves">Nice-to-haves</h2>
<h3 id="prometheus-node-exporter">N1. Prometheus Node Exporter</h3>
<p>I like to monitor all my machines with <a href="https://prometheus.io">Prometheus</a> (and
Grafana). For network connectivity and authentication, I use the Tailscale mesh
VPN.</p>
<p>To install Tailscale, I <a href="https://search.nixos.org/options?query=services.tailscale.enable">enable its NixOS
module</a> and
make the <code>tailscale</code> command available:</p>
<div class="highlight"><pre tabindex="0" style="background-color:#f0f0f0;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span> services<span style="color:#666">.</span>tailscale<span style="color:#666">.</span>enable <span style="color:#666">=</span> <span style="color:#60add5">true</span>;
</span></span><span style="display:flex;"><span> environment<span style="color:#666">.</span>systemPackages <span style="color:#666">=</span> <span style="color:#007020;font-weight:bold">with</span> pkgs; [ tailscale ];
</span></span></code></pre></div><p>After deploying, I run <code>sudo tailscale up</code> and open the login link in my browser.</p>
<p>The Prometheus Node Exporter can also easily be enabled <a href="https://search.nixos.org/options?query=services.prometheus.exporters.node.enable">through its NixOS
module</a>:</p>
<div class="highlight"><pre tabindex="0" style="background-color:#f0f0f0;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span> services<span style="color:#666">.</span>prometheus<span style="color:#666">.</span>exporters<span style="color:#666">.</span>node <span style="color:#666">=</span> {
</span></span><span style="display:flex;"><span> enable <span style="color:#666">=</span> <span style="color:#60add5">true</span>;
</span></span><span style="display:flex;"><span> listenAddress <span style="color:#666">=</span> <span style="color:#4070a0">"storage2.example.ts.net"</span>;
</span></span><span style="display:flex;"><span> };
</span></span></code></pre></div><p>However, this isn’t reliable yet: When Tailscale’s startup takes a while during
system boot, the Node Exporter might burn through its entire restart budget when
it cannot listen on the Tailscale IP address yet. We can enable <a href="/posts/2024-01-17-systemd-indefinite-service-restarts/">indefinite
restarts</a> for the
service to eventually come up:</p>
<div class="highlight"><pre tabindex="0" style="background-color:#f0f0f0;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span> systemd<span style="color:#666">.</span>services<span style="color:#666">.</span><span style="color:#4070a0">"prometheus-node-exporter"</span> <span style="color:#666">=</span> {
</span></span><span style="display:flex;"><span> startLimitIntervalSec <span style="color:#666">=</span> <span style="color:#40a070">0</span>;
</span></span><span style="display:flex;"><span> serviceConfig <span style="color:#666">=</span> {
</span></span><span style="display:flex;"><span> Restart <span style="color:#666">=</span> <span style="color:#4070a0">"always"</span>;
</span></span><span style="display:flex;"><span> RestartSec <span style="color:#666">=</span> <span style="color:#40a070">1</span>;
</span></span><span style="display:flex;"><span> };
</span></span><span style="display:flex;"><span> };
</span></span></code></pre></div><h3 id="reliable-mount">N2. Reliable mounting</h3>
<p>While migrating my setup, I noticed that calling <a href="https://manpages.debian.org/mount.8"><code>mount(8)</code></a>
from <code>unlock.service</code> directly is not reliable, and it’s better
to let systemd manage the mounting:</p>
<div class="highlight"><pre tabindex="0" style="background-color:#f0f0f0;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span> fileSystems<span style="color:#666">.</span><span style="color:#4070a0">"/srv"</span> <span style="color:#666">=</span> {
</span></span><span style="display:flex;"><span> device <span style="color:#666">=</span> <span style="color:#4070a0">"/dev/mapper/data-data"</span>;
</span></span><span style="display:flex;"><span> fsType <span style="color:#666">=</span> <span style="color:#4070a0">"ext4"</span>;
</span></span><span style="display:flex;"><span> options <span style="color:#666">=</span> [
</span></span><span style="display:flex;"><span> <span style="color:#4070a0">"nofail"</span>
</span></span><span style="display:flex;"><span> <span style="color:#4070a0">"x-systemd.requires=unlock.service"</span>
</span></span><span style="display:flex;"><span> ];
</span></span><span style="display:flex;"><span> };
</span></span></code></pre></div><p>Afterwards, I could just remove the <a href="https://manpages.debian.org/mount.8"><code>mount(8)</code></a>
call
from <code>unlock.service</code>:</p>
<div class="highlight"><pre tabindex="0" style="background-color:#f0f0f0;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-diff" data-lang="diff"><span style="display:flex;"><span><span style="color:#800080;font-weight:bold">@@ -247,7 +247,10 @@ fry/U6A=
</span></span></span><span style="display:flex;"><span><span style="color:#800080;font-weight:bold"></span> ''/bin/sh -c "${pkgs.lvm2.bin}/bin/vgchange -ay"''
</span></span><span style="display:flex;"><span><span style="color:#a00000">- ''/run/wrappers/bin/mount /dev/mapper/data-data /srv''
</span></span></span><span style="display:flex;"><span><span style="color:#a00000"></span><span style="color:#00a000">+ # Let systemd mount /srv based on the fileSystems./srv
</span></span></span><span style="display:flex;"><span><span style="color:#00a000">+ # declaration to prevent race conditions: mount
</span></span></span><span style="display:flex;"><span><span style="color:#00a000">+ # might not succeed while the fsck is still in progress,
</span></span></span><span style="display:flex;"><span><span style="color:#00a000">+ # for example, which otherwise makes unlock.service fail.
</span></span></span><span style="display:flex;"><span><span style="color:#00a000"></span> ];
</span></span><span style="display:flex;"><span> };
</span></span></code></pre></div><p>In systemd services, I can now depend on the <code>/srv</code> mount unit:</p>
<div class="highlight"><pre tabindex="0" style="background-color:#f0f0f0;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span> systemd<span style="color:#666">.</span>services<span style="color:#666">.</span>jellyfin <span style="color:#666">=</span> {
</span></span><span style="display:flex;"><span> unitConfig<span style="color:#666">.</span>RequiresMountsFor <span style="color:#666">=</span> [ <span style="color:#4070a0">"/srv"</span> ];
</span></span><span style="display:flex;"><span> wantedBy <span style="color:#666">=</span> [ <span style="color:#4070a0">"srv.mount"</span> ];
</span></span><span style="display:flex;"><span> };
</span></span></code></pre></div><h3 id="nginx-healthz">N3. nginx-healthz</h3>
<p>To save power, I turn off my NAS when they are not in use.</p>
<p>My backup orchestration uses Wake-on-LAN to start a wakeup and needs to wait
until the NAS is fully booted up and has mounted its <code>/srv</code> mount before it
can start backup jobs.</p>
<p>For this purpose, I have configured a web server (without any files) that
depends on the <code>/srv</code> mount. So, once the web server responds to HTTP requests,
we know <code>/srv</code> is mounted.</p>
<p>The <code>cloud-init</code> config looked as follows:</p>
<div class="highlight"><pre tabindex="0" style="background-color:#f0f0f0;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#062873;font-weight:bold">coreos</span>:<span style="color:#bbb">
</span></span></span><span style="display:flex;"><span><span style="color:#bbb"> </span><span style="color:#062873;font-weight:bold">units</span>:<span style="color:#bbb">
</span></span></span><span style="display:flex;"><span><span style="color:#bbb"> </span>- <span style="color:#062873;font-weight:bold">name</span>:<span style="color:#bbb"> </span>healthz.service<span style="color:#bbb">
</span></span></span><span style="display:flex;"><span><span style="color:#bbb"> </span><span style="color:#062873;font-weight:bold">command</span>:<span style="color:#bbb"> </span>start<span style="color:#bbb">
</span></span></span><span style="display:flex;"><span><span style="color:#bbb"> </span><span style="color:#062873;font-weight:bold">content</span>:<span style="color:#bbb"> </span>|<span style="color:#4070a0;font-style:italic">
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> [Unit]
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> Description=nginx for /srv health check
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> Wants=network.target
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> After=srv.mount
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> Requires=srv.mount
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> StartLimitInterval=0
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic">
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> [Service]
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> Restart=always
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> ExecStartPre=/bin/sh -c 'systemctl is-active docker.service'
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> ExecStartPre=/usr/bin/docker pull nginx:1
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> ExecStartPre=-/usr/bin/docker kill nginx-healthz
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> ExecStartPre=-/usr/bin/docker rm -f nginx-healthz
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> ExecStart=/usr/bin/docker run \
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> --name nginx-healthz \
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> --publish 10.0.0.252:8200:80 \
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> --log-driver=journald \
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-style:italic"> nginx:1</span><span style="color:#bbb">
</span></span></span></code></pre></div><p>The Docker version (ported from Flatcar Linux) looks like this:</p>
<div class="highlight"><pre tabindex="0" style="background-color:#f0f0f0;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span> systemd<span style="color:#666">.</span>services<span style="color:#666">.</span>healthz <span style="color:#666">=</span> {
</span></span><span style="display:flex;"><span> description <span style="color:#666">=</span> <span style="color:#4070a0">"nginx for /srv health check"</span>;
</span></span><span style="display:flex;"><span> wants <span style="color:#666">=</span> [ <span style="color:#4070a0">"network.target"</span> ];
</span></span><span style="display:flex;"><span> unitConfig<span style="color:#666">.</span>RequiresMountsFor <span style="color:#666">=</span> [ <span style="color:#4070a0">"/srv"</span> ];
</span></span><span style="display:flex;"><span> wantedBy <span style="color:#666">=</span> [ <span style="color:#4070a0">"srv.mount"</span> ];
</span></span><span style="display:flex;"><span> startLimitIntervalSec <span style="color:#666">=</span> <span style="color:#40a070">0</span>;
</span></span><span style="display:flex;"><span> serviceConfig <span style="color:#666">=</span> {
</span></span><span style="display:flex;"><span> Restart <span style="color:#666">=</span> <span style="color:#4070a0">"always"</span>;
</span></span><span style="display:flex;"><span> ExecStartPre <span style="color:#666">=</span> [
</span></span><span style="display:flex;"><span> <span style="color:#4070a0">''/bin/sh -c 'systemctl is-active docker.service' ''</span>
</span></span><span style="display:flex;"><span> <span style="color:#4070a0">''-</span><span style="color:#70a0d0">${</span>pkgs<span style="color:#666">.</span>docker<span style="color:#70a0d0">}</span><span style="color:#4070a0">/bin/docker pull nginx:1''</span>
</span></span><span style="display:flex;"><span> <span style="color:#4070a0">''-</span><span style="color:#70a0d0">${</span>pkgs<span style="color:#666">.</span>docker<span style="color:#70a0d0">}</span><span style="color:#4070a0">/bin/docker kill nginx-healthz''</span>
</span></span><span style="display:flex;"><span> <span style="color:#4070a0">''-</span><span style="color:#70a0d0">${</span>pkgs<span style="color:#666">.</span>docker<span style="color:#70a0d0">}</span><span style="color:#4070a0">/bin/docker rm -f nginx-healthz''</span>
</span></span><span style="display:flex;"><span> ];
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> ExecStart <span style="color:#666">=</span> [
</span></span><span style="display:flex;"><span> <span style="color:#4070a0">''-</span><span style="color:#70a0d0">${</span>pkgs<span style="color:#666">.</span>docker<span style="color:#70a0d0">}</span><span style="color:#4070a0">/bin/docker run \
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> --name nginx-healthz \
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> --publish 10.0.0.252:8200:80 \
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> --log-driver=journald \
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> nginx:1
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> ''</span>
</span></span><span style="display:flex;"><span> ];
</span></span><span style="display:flex;"><span> };
</span></span><span style="display:flex;"><span> };
</span></span></code></pre></div><p>This configuration gets a lot simpler when migrating it from Docker to NixOS:</p>
<div class="highlight"><pre tabindex="0" style="background-color:#f0f0f0;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span> <span style="color:#60a0b0;font-style:italic"># Signal readiness on HTTP port 8200 once /srv is mounted:</span>
</span></span><span style="display:flex;"><span> networking<span style="color:#666">.</span>firewall<span style="color:#666">.</span>allowedTCPPorts <span style="color:#666">=</span> [ <span style="color:#40a070">8200</span> ];
</span></span><span style="display:flex;"><span> services<span style="color:#666">.</span>caddy <span style="color:#666">=</span> {
</span></span><span style="display:flex;"><span> enable <span style="color:#666">=</span> <span style="color:#60add5">true</span>;
</span></span><span style="display:flex;"><span> virtualHosts<span style="color:#666">.</span><span style="color:#4070a0">"http://10.0.0.252:8200"</span><span style="color:#666">.</span>extraConfig <span style="color:#666">=</span> <span style="color:#4070a0">''
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> respond "ok"
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> ''</span>;
</span></span><span style="display:flex;"><span> };
</span></span><span style="display:flex;"><span> systemd<span style="color:#666">.</span>services<span style="color:#666">.</span>caddy <span style="color:#666">=</span> {
</span></span><span style="display:flex;"><span> unitConfig<span style="color:#666">.</span>RequiresMountsFor <span style="color:#666">=</span> [ <span style="color:#4070a0">"/srv"</span> ];
</span></span><span style="display:flex;"><span> wantedBy <span style="color:#666">=</span> [ <span style="color:#4070a0">"srv.mount"</span> ];
</span></span><span style="display:flex;"><span> };
</span></span></code></pre></div><h3 id="jellyfin">N4. NixOS Jellyfin</h3>
<p>The Docker version (ported from Flatcar Linux) looks like this:</p>
<div class="highlight"><pre tabindex="0" style="background-color:#f0f0f0;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span> networking<span style="color:#666">.</span>firewall<span style="color:#666">.</span>allowedTCPPorts <span style="color:#666">=</span> [ <span style="color:#40a070">4414</span> <span style="color:#40a070">8096</span> ];
</span></span><span style="display:flex;"><span> systemd<span style="color:#666">.</span>services<span style="color:#666">.</span>jellyfin <span style="color:#666">=</span> {
</span></span><span style="display:flex;"><span> wantedBy <span style="color:#666">=</span> [ <span style="color:#4070a0">"multi-user.target"</span> ];
</span></span><span style="display:flex;"><span> description <span style="color:#666">=</span> <span style="color:#4070a0">"jellyfin"</span>;
</span></span><span style="display:flex;"><span> after <span style="color:#666">=</span> [ <span style="color:#4070a0">"docker.service"</span> <span style="color:#4070a0">"srv.mount"</span> ];
</span></span><span style="display:flex;"><span> requires <span style="color:#666">=</span> [ <span style="color:#4070a0">"docker.service"</span> <span style="color:#4070a0">"srv.mount"</span> ];
</span></span><span style="display:flex;"><span> startLimitIntervalSec <span style="color:#666">=</span> <span style="color:#40a070">0</span>;
</span></span><span style="display:flex;"><span> serviceConfig <span style="color:#666">=</span> {
</span></span><span style="display:flex;"><span> Restart <span style="color:#666">=</span> <span style="color:#4070a0">"always"</span>;
</span></span><span style="display:flex;"><span> ExecStartPre <span style="color:#666">=</span> [
</span></span><span style="display:flex;"><span> <span style="color:#4070a0">''-</span><span style="color:#70a0d0">${</span>pkgs<span style="color:#666">.</span>docker<span style="color:#70a0d0">}</span><span style="color:#4070a0">/bin/docker pull lscr.io/linuxserver/jellyfin:latest''</span>
</span></span><span style="display:flex;"><span> <span style="color:#4070a0">''-</span><span style="color:#70a0d0">${</span>pkgs<span style="color:#666">.</span>docker<span style="color:#70a0d0">}</span><span style="color:#4070a0">/bin/docker rm jellyfin''</span>
</span></span><span style="display:flex;"><span> ];
</span></span><span style="display:flex;"><span> ExecStart <span style="color:#666">=</span> [
</span></span><span style="display:flex;"><span> <span style="color:#4070a0">''-</span><span style="color:#70a0d0">${</span>pkgs<span style="color:#666">.</span>docker<span style="color:#70a0d0">}</span><span style="color:#4070a0">/bin/docker run \
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> --rm \
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> --net=host \
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> --name=jellyfin \
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> -e TZ=Europe/Zurich \
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> -v /srv/jellyfin/config:/config \
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> -v /srv/data/movies:/data/movies:ro \
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> -v /srv/data/series:/data/series:ro \
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> -v /srv/data/mp3:/data/mp3:ro \
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> lscr.io/linuxserver/jellyfin:latest
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> ''</span>
</span></span><span style="display:flex;"><span> ];
</span></span><span style="display:flex;"><span> };
</span></span><span style="display:flex;"><span> };
</span></span></code></pre></div><p>As before, when using jellyfin from NixOS, the configuration gets simpler:</p>
<div class="highlight"><pre tabindex="0" style="background-color:#f0f0f0;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span> services<span style="color:#666">.</span>jellyfin <span style="color:#666">=</span> {
</span></span><span style="display:flex;"><span> enable <span style="color:#666">=</span> <span style="color:#60add5">true</span>;
</span></span><span style="display:flex;"><span> openFirewall <span style="color:#666">=</span> <span style="color:#60add5">true</span>;
</span></span><span style="display:flex;"><span> };
</span></span><span style="display:flex;"><span> systemd<span style="color:#666">.</span>services<span style="color:#666">.</span>jellyfin <span style="color:#666">=</span> {
</span></span><span style="display:flex;"><span> unitConfig<span style="color:#666">.</span>RequiresMountsFor <span style="color:#666">=</span> [ <span style="color:#4070a0">"/srv"</span> ];
</span></span><span style="display:flex;"><span> wantedBy <span style="color:#666">=</span> [ <span style="color:#4070a0">"srv.mount"</span> ];
</span></span><span style="display:flex;"><span> };
</span></span></code></pre></div><p>For a while, I had also set up compatibility symlinks that map the old location
(<code>/data/movies</code>, inside the Docker container) to the new location
(<code>/srv/data/movies</code>), but I encountered strange issues in Jellyfin and ended up
just re-initializing my whole Jellyfin state. While the required configuration
had more lines, I found it neat to move it into its own file, so here is how to
do that:</p>
<p>Remove the lines above from <code>configuration.nix</code> and move them into
<code>jellyfin.nix</code>:</p>
<div class="highlight"><pre tabindex="0" style="background-color:#f0f0f0;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span> config<span style="color:#666">,</span>
</span></span><span style="display:flex;"><span> lib<span style="color:#666">,</span>
</span></span><span style="display:flex;"><span> pkgs<span style="color:#666">,</span>
</span></span><span style="display:flex;"><span> modulesPath<span style="color:#666">,</span>
</span></span><span style="display:flex;"><span> <span style="color:#666">...</span>
</span></span><span style="display:flex;"><span>}:
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span> services<span style="color:#666">.</span>jellyfin <span style="color:#666">=</span> {
</span></span><span style="display:flex;"><span> enable <span style="color:#666">=</span> <span style="color:#60add5">true</span>;
</span></span><span style="display:flex;"><span> openFirewall <span style="color:#666">=</span> <span style="color:#60add5">true</span>;
</span></span><span style="display:flex;"><span> dataDir <span style="color:#666">=</span> <span style="color:#4070a0">"/srv/jellyfin"</span>;
</span></span><span style="display:flex;"><span> cacheDir <span style="color:#666">=</span> <span style="color:#4070a0">"/srv/jellyfin/config/cache"</span>;
</span></span><span style="display:flex;"><span> };
</span></span><span style="display:flex;"><span> systemd<span style="color:#666">.</span>services<span style="color:#666">.</span>jellyfin <span style="color:#666">=</span> {
</span></span><span style="display:flex;"><span> unitConfig<span style="color:#666">.</span>RequiresMountsFor <span style="color:#666">=</span> [ <span style="color:#4070a0">"/srv"</span> ];
</span></span><span style="display:flex;"><span> wantedBy <span style="color:#666">=</span> [ <span style="color:#4070a0">"srv.mount"</span> ];
</span></span><span style="display:flex;"><span> };
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Then, in <code>configuration.nix</code>, add <code>jellyfin.nix</code> to the <code>imports</code>:</p>
<div class="highlight"><pre tabindex="0" style="background-color:#f0f0f0;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span> imports <span style="color:#666">=</span> [
</span></span><span style="display:flex;"><span> <span style="color:#235388">./hardware-configuration.nix</span>
</span></span><span style="display:flex;"><span> <span style="color:#235388">./jellyfin.nix</span>
</span></span><span style="display:flex;"><span> ];
</span></span></code></pre></div><h3 id="samba-nixos">N5. NixOS samba</h3>
<p>To use Samba from NixOS, I replaced my <code>systemd.services.samba</code> config from M3
with this:</p>
<div class="highlight"><pre tabindex="0" style="background-color:#f0f0f0;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span> services<span style="color:#666">.</span>samba <span style="color:#666">=</span> {
</span></span><span style="display:flex;"><span> enable <span style="color:#666">=</span> <span style="color:#60add5">true</span>;
</span></span><span style="display:flex;"><span> openFirewall <span style="color:#666">=</span> <span style="color:#60add5">true</span>;
</span></span><span style="display:flex;"><span> settings <span style="color:#666">=</span> {
</span></span><span style="display:flex;"><span> <span style="color:#4070a0">"global"</span> <span style="color:#666">=</span> {
</span></span><span style="display:flex;"><span> <span style="color:#4070a0">"map to guest"</span> <span style="color:#666">=</span> <span style="color:#4070a0">"bad user"</span>;
</span></span><span style="display:flex;"><span> };
</span></span><span style="display:flex;"><span> <span style="color:#4070a0">"data"</span> <span style="color:#666">=</span> {
</span></span><span style="display:flex;"><span> <span style="color:#4070a0">"path"</span> <span style="color:#666">=</span> <span style="color:#4070a0">"/srv/data"</span>;
</span></span><span style="display:flex;"><span> <span style="color:#4070a0">"comment"</span> <span style="color:#666">=</span> <span style="color:#4070a0">"public data"</span>;
</span></span><span style="display:flex;"><span> <span style="color:#4070a0">"read only"</span> <span style="color:#666">=</span> <span style="color:#4070a0">"no"</span>;
</span></span><span style="display:flex;"><span> <span style="color:#4070a0">"create mask"</span> <span style="color:#666">=</span> <span style="color:#4070a0">"0775"</span>;
</span></span><span style="display:flex;"><span> <span style="color:#4070a0">"directory mask"</span> <span style="color:#666">=</span> <span style="color:#4070a0">"0775"</span>;
</span></span><span style="display:flex;"><span> <span style="color:#4070a0">"guest ok"</span> <span style="color:#666">=</span> <span style="color:#4070a0">"yes"</span>;
</span></span><span style="display:flex;"><span> };
</span></span><span style="display:flex;"><span> };
</span></span><span style="display:flex;"><span> };
</span></span><span style="display:flex;"><span> system<span style="color:#666">.</span>activationScripts<span style="color:#666">.</span>samba_user_create <span style="color:#666">=</span> <span style="color:#4070a0">''
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> smb_password="secret"
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> echo -e "$smb_password\n$smb_password\n" | </span><span style="color:#70a0d0">${</span>lib<span style="color:#666">.</span>getExe' pkgs<span style="color:#666">.</span>samba <span style="color:#4070a0">"smbpasswd"</span><span style="color:#70a0d0">}</span><span style="color:#4070a0"> -a -s michael
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> ''</span>;
</span></span></code></pre></div><p>Note: Setting the samba password in the activation script works for small
setups, but if you want to keep your samba passwords out of the Nix store,
you’ll need to use a different approach. On a different machine, I use
<a href="https://github.com/Mic92/sops-nix">sops-nix</a> to manage secrets and found that
refactoring the <code>smbpasswd</code> call like so works reliably:</p>
<div class="highlight"><pre tabindex="0" style="background-color:#f0f0f0;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span><span style="color:#007020;font-weight:bold">let</span>
</span></span><span style="display:flex;"><span> setPasswords <span style="color:#666">=</span> pkgs<span style="color:#666">.</span>writeShellScript <span style="color:#4070a0">"samba-set-passwords"</span> <span style="color:#4070a0">''
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> set -euo pipefail
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> for user in michael; do
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> smb_password="$(cat /run/secrets/samba_passwords/$user)"
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> echo -e "$smb_password\n$smb_password\n" | </span><span style="color:#70a0d0">${</span>lib<span style="color:#666">.</span>getExe' pkgs<span style="color:#666">.</span>samba <span style="color:#4070a0">"smbpasswd"</span><span style="color:#70a0d0">}</span><span style="color:#4070a0"> -a -s $user
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> done
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> ''</span>;
</span></span><span style="display:flex;"><span><span style="color:#007020;font-weight:bold">in</span>
</span></span><span style="display:flex;"><span> {
</span></span><span style="display:flex;"><span> <span style="color:#60a0b0;font-style:italic"># …</span>
</span></span><span style="display:flex;"><span> services<span style="color:#666">.</span>samba <span style="color:#666">=</span> {
</span></span><span style="display:flex;"><span> <span style="color:#60a0b0;font-style:italic"># …as above…</span>
</span></span><span style="display:flex;"><span> }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> systemd<span style="color:#666">.</span>services<span style="color:#666">.</span>samba-smbd<span style="color:#666">.</span>serviceConfig<span style="color:#666">.</span>ExecStartPre <span style="color:#666">=</span> [
</span></span><span style="display:flex;"><span> <span style="color:#4070a0">"</span><span style="color:#70a0d0">${</span>setPasswords<span style="color:#70a0d0">}</span><span style="color:#4070a0">"</span>
</span></span><span style="display:flex;"><span> ];
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> sops<span style="color:#666">.</span>secrets<span style="color:#666">.</span><span style="color:#4070a0">"samba_passwords/michael"</span> <span style="color:#666">=</span> {
</span></span><span style="display:flex;"><span> restartUnits <span style="color:#666">=</span> [ <span style="color:#4070a0">"samba-smbd.service"</span> ];
</span></span><span style="display:flex;"><span> };
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>I also noticed that NixOS does not create a group for each user by default, but
I am used to managing my permissions like that. We can easily declare a group
like so:</p>
<div class="highlight"><pre tabindex="0" style="background-color:#f0f0f0;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span> users<span style="color:#666">.</span>groups<span style="color:#666">.</span>michael <span style="color:#666">=</span> {
</span></span><span style="display:flex;"><span> gid <span style="color:#666">=</span> <span style="color:#40a070">1000</span>; <span style="color:#60a0b0;font-style:italic"># for consistency with storage3</span>
</span></span><span style="display:flex;"><span> };
</span></span><span style="display:flex;"><span> users<span style="color:#666">.</span>users<span style="color:#666">.</span>michael <span style="color:#666">=</span> {
</span></span><span style="display:flex;"><span> extraGroups <span style="color:#666">=</span> [
</span></span><span style="display:flex;"><span> <span style="color:#4070a0">"wheel"</span> <span style="color:#60a0b0;font-style:italic"># Enable ‘sudo’ for the user.</span>
</span></span><span style="display:flex;"><span> <span style="color:#4070a0">"docker"</span>
</span></span><span style="display:flex;"><span> <span style="color:#60a0b0;font-style:italic"># By default, NixOS does not add users to their own group:</span>
</span></span><span style="display:flex;"><span> <span style="color:#60a0b0;font-style:italic"># https://github.com/NixOS/nixpkgs/issues/198296</span>
</span></span><span style="display:flex;"><span> <span style="color:#4070a0">"michael"</span>
</span></span><span style="display:flex;"><span> ];
</span></span><span style="display:flex;"><span> };
</span></span></code></pre></div><h3 id="rrsync-nixos">N6. NixOS rrsync</h3>
<p>The Docker version (ported from Flatcar Linux) looks like this:</p>
<div class="highlight"><pre tabindex="0" style="background-color:#f0f0f0;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span> users<span style="color:#666">.</span>users<span style="color:#666">.</span>root<span style="color:#666">.</span>openssh<span style="color:#666">.</span>authorizedKeys<span style="color:#666">.</span>keys <span style="color:#666">=</span> [
</span></span><span style="display:flex;"><span> <span style="color:#4070a0">''command="</span><span style="color:#70a0d0">${</span>pkgs<span style="color:#666">.</span>docker<span style="color:#70a0d0">}</span><span style="color:#4070a0">/bin/docker run --log-driver none -i -e SSH_ORIGINAL_COMMAND -v /srv/backup/midna:/srv/backup/midna stapelberg/docker-rsync /srv/backup/midna" ssh-rsa AAAAB3Npublickey root@midna''</span>
</span></span><span style="display:flex;"><span> ];
</span></span></code></pre></div><p>To use <code>rrsync</code> from NixOS, I changed the configuration like so:</p>
<div class="highlight"><pre tabindex="0" style="background-color:#f0f0f0;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span> users<span style="color:#666">.</span>users<span style="color:#666">.</span>root<span style="color:#666">.</span>openssh<span style="color:#666">.</span>authorizedKeys<span style="color:#666">.</span>keys <span style="color:#666">=</span> [
</span></span><span style="display:flex;"><span> <span style="color:#4070a0">''command="</span><span style="color:#70a0d0">${</span>pkgs<span style="color:#666">.</span>rrsync<span style="color:#70a0d0">}</span><span style="color:#4070a0">/bin/rrsync /srv/backup/midna" ssh-rsa AAAAB3Npublickey root@midna''</span>
</span></span><span style="display:flex;"><span> ];
</span></span></code></pre></div><h3 id="syncpl-nixos">N7. sync.pl script</h3>
<p>The Docker version (ported from Flatcar Linux) looks like this:</p>
<div class="highlight"><pre tabindex="0" style="background-color:#f0f0f0;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span> users<span style="color:#666">.</span>users<span style="color:#666">.</span>root<span style="color:#666">.</span>openssh<span style="color:#666">.</span>authorizedKeys<span style="color:#666">.</span>keys <span style="color:#666">=</span> [
</span></span><span style="display:flex;"><span> <span style="color:#4070a0">''command="</span><span style="color:#70a0d0">${</span>pkgs<span style="color:#666">.</span>docker<span style="color:#70a0d0">}</span><span style="color:#4070a0">/bin/docker run --log-driver none -i -e SSH_ORIGINAL_COMMAND -v /srv/data:/srv/data -v /root/.ssh:/root/.ssh:ro -v /etc/ssh:/etc/ssh:ro -v /etc/static/ssh:/etc/static/ssh:ro -v /nix/store:/nix/store:ro stapelberg/docker-sync",no-port-forwarding,no-X11-forwarding ssh-ed25519 AAAAC3Npublickey sync@dr''</span>
</span></span><span style="display:flex;"><span> ];
</span></span></code></pre></div><p>I wanted to stop managing the following <code>Dockerfile</code> to ship <code>sync.pl</code>:</p>
<div class="highlight"><pre tabindex="0" style="background-color:#f0f0f0;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-Dockerfile" data-lang="Dockerfile"><span style="display:flex;"><span><span style="color:#007020;font-weight:bold">FROM</span><span style="color:#bbb"> </span><span style="color:#4070a0">debian:stable</span><span style="">
</span></span></span><span style="display:flex;"><span><span style="">
</span></span></span><span style="display:flex;"><span><span style=""></span><span style="color:#60a0b0;font-style:italic"># Install full perl for Data::Dumper</span><span style="">
</span></span></span><span style="display:flex;"><span><span style=""></span><span style="color:#007020;font-weight:bold">RUN</span> apt-get update <span style="color:#4070a0;font-weight:bold">\
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0;font-weight:bold"></span> <span style="color:#666">&&</span> apt-get install -y rsync ssh perl<span style="">
</span></span></span><span style="display:flex;"><span><span style="">
</span></span></span><span style="display:flex;"><span><span style=""></span><span style="color:#007020;font-weight:bold">ADD</span> sync.pl /usr/bin/<span style="">
</span></span></span><span style="display:flex;"><span><span style="">
</span></span></span><span style="display:flex;"><span><span style=""></span><span style="color:#007020;font-weight:bold">ENTRYPOINT</span> [<span style="color:#4070a0">"/usr/bin/sync.pl"</span>]<span style="">
</span></span></span></code></pre></div><p>To get rid of the Docker container, I translated the <code>sync.pl</code> file into a Nix
expression that writes the <code>sync.pl</code> Perl script to the Nix store:</p>
<div class="highlight"><pre tabindex="0" style="background-color:#f0f0f0;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span>{ pkgs }:
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#60a0b0;font-style:italic"># For string literal escaping rules (''${), see:</span>
</span></span><span style="display:flex;"><span><span style="color:#60a0b0;font-style:italic"># https://nix.dev/manual/nix/2.26/language/string-literals#string-literals</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#60a0b0;font-style:italic"># For writers.writePerlBin, see https://wiki.nixos.org/wiki/Nix-writers</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>pkgs<span style="color:#666">.</span>writers<span style="color:#666">.</span>writePerlBin <span style="color:#4070a0">"syncpl"</span> { libraries <span style="color:#666">=</span> []; } <span style="color:#4070a0">''
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"># This script is run via ssh from dornröschen.
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0">use strict;
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0">use warnings;
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0">use Data::Dumper;
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0">
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0">if (my ($destination) = ($ENV{SSH_ORIGINAL_COMMAND} =~ /^([a-z0-9.]+)$/)) {
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> print STDERR "rsync version: " . `</span><span style="color:#70a0d0">${</span>pkgs<span style="color:#666">.</span>rsync<span style="color:#70a0d0">}</span><span style="color:#4070a0">/bin/rsync --version` . "\n\n";
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> my @rsync = (
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> "</span><span style="color:#70a0d0">${</span>pkgs<span style="color:#666">.</span>rsync<span style="color:#70a0d0">}</span><span style="color:#4070a0">/bin/rsync",
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> "-e",
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> "ssh",
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> "--max-delete=-1",
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> "--verbose",
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> "--stats",
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> # Intentionally not setting -X for my data sync,
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> # where there are no full system backups; mostly media files.
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> "-ax",
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> "--ignore-existing",
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> "--omit-dir-times",
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> "/srv/data/",
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> "</span><span style="color:#4070a0;font-weight:bold">''$</span><span style="color:#4070a0">{destination}:/",
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> );
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> print STDERR "running: " . Dumper(\@rsync) . "\n";
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> exec @rsync;
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0">} else {
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0"> print STDERR "Could not parse SSH_ORIGINAL_COMMAND.\n";
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0">}
</span></span></span><span style="display:flex;"><span><span style="color:#4070a0">''</span>
</span></span></code></pre></div><p>I can then reference this file by importing it in my <code>configuration.nix</code> and
pointing it to the <code>pkgs</code> expression of my NixOS configuration:</p>
<div class="highlight"><pre tabindex="0" style="background-color:#f0f0f0;-moz-tab-size:4;-o-tab-size:4;tab-size:4;display:grid;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span>{ modulesPath<span style="color:#666">,</span> lib<span style="color:#666">,</span> pkgs<span style="color:#666">,</span> <span style="color:#666">...</span> }:
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex; background-color:#d8d8d8"><span><span style="color:#007020;font-weight:bold">let</span>
</span></span><span style="display:flex; background-color:#d8d8d8"><span> syncpl <span style="color:#666">=</span> <span style="color:#007020;font-weight:bold">import</span> <span style="color:#235388">./syncpl.nix</span> { pkgs <span style="color:#666">=</span> pkgs; };
</span></span><span style="display:flex; background-color:#d8d8d8"><span><span style="color:#007020;font-weight:bold">in</span> {
</span></span><span style="display:flex;"><span> imports <span style="color:#666">=</span> [ <span style="color:#235388">./hardware-configuration.nix</span> ];
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> users<span style="color:#666">.</span>users<span style="color:#666">.</span>root<span style="color:#666">.</span>openssh<span style="color:#666">.</span>authorizedKeys<span style="color:#666">.</span>keys <span style="color:#666">=</span> [
</span></span><span style="display:flex; background-color:#d8d8d8"><span> <span style="color:#4070a0">''command="</span><span style="color:#70a0d0">${</span>syncpl<span style="color:#70a0d0">}</span><span style="color:#4070a0">/bin/syncpl",no-port-forwarding,no-X11-forwarding ssh-ed25519 AAAAC3Npublickey sync@dr''</span>
</span></span><span style="display:flex;"><span> ];
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#60a0b0;font-style:italic"># For interactive usage (when debugging):</span>
</span></span><span style="display:flex; background-color:#d8d8d8"><span> environment<span style="color:#666">.</span>systemPackages <span style="color:#666">=</span> [ syncpl ];
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#60a0b0;font-style:italic"># …</span>
</span></span><span style="display:flex;"><span>}</span></span></code></pre></div>
<p>This works, but is it the best approach? Here are some thoughts:</p>
<ul>
<li>By managing this script in a Nix expression, I can no longer use my editor’s
Perl support.
<ul>
<li>I could probably also keep <code>sync.pl</code> as a separate file and use string
interpolation in my Nix expression to inject an absolute path to the <code>rsync</code>
binary into the script.</li>
</ul>
</li>
<li>Another alternative would be to add a wrapper script to my Nix expression
which ensures that <code>$PATH</code> contains <code>rsync</code> and then the script wouldn’t need
an absolute path anymore.</li>
<li>For small glue scripts like this one, I consider it easier to manage the
contents “inline” in the Nix expression, because it means one fewer file in my
config directory.</li>
</ul>
<h3 id="flakes">N8. Sharing configs</h3>
<p>I want to configure all my NixOS systems such that my user settings are
identical everywhere.</p>
<p>To achieve that, I can extract parts of my <code>configuration.nix</code> into a
<a href="https://github.com/stapelberg/nix/blob/main/user-settings.nix"><code>user-settings.nix</code></a>
and then <a href="https://github.com/stapelberg/nix/blob/30cdd7db9e0ab4b7cc3a38b7953e1b7e1e238d75/flake.nix#L7">declare an accompanying
<code>flake.nix</code></a>
that provides this expression as an output.</p>
<p>After publishing these files in a git repository, I can reference said
repository in my <code>flake.nix</code>:</p>
<div class="highlight"><pre tabindex="0" style="background-color:#f0f0f0;-moz-tab-size:4;-o-tab-size:4;tab-size:4;display:grid;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span> inputs <span style="color:#666">=</span> {
</span></span><span style="display:flex;"><span> nixpkgs<span style="color:#666">.</span>url <span style="color:#666">=</span> <span style="color:#4070a0">"github:nixos/nixpkgs/nixos-25.05"</span>;
</span></span><span style="display:flex; background-color:#d8d8d8"><span> stapelbergnix<span style="color:#666">.</span>url <span style="color:#666">=</span> <span style="color:#4070a0">"github:stapelberg/nix"</span>;
</span></span><span style="display:flex;"><span> };
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> outputs <span style="color:#666">=</span>
</span></span><span style="display:flex;"><span> {
</span></span><span style="display:flex;"><span> self<span style="color:#666">,</span>
</span></span><span style="display:flex;"><span> nixpkgs<span style="color:#666">,</span>
</span></span><span style="display:flex; background-color:#d8d8d8"><span> stapelbergnix<span style="color:#666">,</span>
</span></span><span style="display:flex;"><span> }:
</span></span><span style="display:flex;"><span> <span style="color:#007020;font-weight:bold">let</span>
</span></span><span style="display:flex;"><span> system <span style="color:#666">=</span> <span style="color:#4070a0">"x86_64-linux"</span>;
</span></span><span style="display:flex;"><span> pkgs <span style="color:#666">=</span> <span style="color:#007020;font-weight:bold">import</span> nixpkgs {
</span></span><span style="display:flex;"><span> <span style="color:#007020;font-weight:bold">inherit</span> system;
</span></span><span style="display:flex;"><span> config<span style="color:#666">.</span>allowUnfree <span style="color:#666">=</span> <span style="color:#60add5">false</span>;
</span></span><span style="display:flex;"><span> };
</span></span><span style="display:flex;"><span> <span style="color:#007020;font-weight:bold">in</span>
</span></span><span style="display:flex;"><span> {
</span></span><span style="display:flex;"><span> nixosConfigurations<span style="color:#666">.</span>storage2 <span style="color:#666">=</span> nixpkgs<span style="color:#666">.</span>lib<span style="color:#666">.</span>nixosSystem {
</span></span><span style="display:flex;"><span> <span style="color:#007020;font-weight:bold">inherit</span> system;
</span></span><span style="display:flex;"><span> <span style="color:#007020;font-weight:bold">inherit</span> pkgs;
</span></span><span style="display:flex;"><span> modules <span style="color:#666">=</span> [
</span></span><span style="display:flex;"><span> <span style="color:#235388">./configuration.nix</span>
</span></span><span style="display:flex; background-color:#d8d8d8"><span> stapelbergnix<span style="color:#666">.</span>lib<span style="color:#666">.</span>userSettings
</span></span><span style="display:flex; background-color:#d8d8d8"><span> <span style="color:#60a0b0;font-style:italic"># Not on this machine; We have our own networking config:</span>
</span></span><span style="display:flex; background-color:#d8d8d8"><span> <span style="color:#60a0b0;font-style:italic"># stapelbergnix.lib.systemdNetwork</span>
</span></span><span style="display:flex; background-color:#d8d8d8"><span> <span style="color:#60a0b0;font-style:italic"># Use systemd-boot as bootloader</span>
</span></span><span style="display:flex; background-color:#d8d8d8"><span> stapelbergnix<span style="color:#666">.</span>lib<span style="color:#666">.</span>systemdBoot
</span></span><span style="display:flex;"><span> ];
</span></span><span style="display:flex;"><span> };
</span></span><span style="display:flex;"><span> formatter<span style="color:#666">.</span><span style="color:#70a0d0">${</span>system<span style="color:#70a0d0">}</span> <span style="color:#666">=</span> pkgs<span style="color:#666">.</span>nixfmt-tree;
</span></span><span style="display:flex;"><span> };
</span></span><span style="display:flex;"><span>}</span></span></code></pre></div>
<p>Everything <a href="https://github.com/stapelberg/nix/blob/main/user-settings.nix">declared in the
<code>user-settings.nix</code></a>
can now be removed from <code>configuration.nix</code>!</p>
<h3 id="immich-nixos">N9. Trying immich!</h3>
<p>One of the motivating reasons for switching away from CoreOS/Flatcar was that I
couldn’t try Immich, so let’s give it a shot on NixOS:</p>
<div class="highlight"><pre tabindex="0" style="background-color:#f0f0f0;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span> services<span style="color:#666">.</span>immich <span style="color:#666">=</span> {
</span></span><span style="display:flex;"><span> enable <span style="color:#666">=</span> <span style="color:#60add5">true</span>;
</span></span><span style="display:flex;"><span> host <span style="color:#666">=</span> <span style="color:#4070a0">"10.0.0.252"</span>;
</span></span><span style="display:flex;"><span> port <span style="color:#666">=</span> <span style="color:#40a070">2283</span>;
</span></span><span style="display:flex;"><span> openFirewall <span style="color:#666">=</span> <span style="color:#60add5">true</span>;
</span></span><span style="display:flex;"><span> mediaLocation <span style="color:#666">=</span> <span style="color:#4070a0">"/srv/immich"</span>;
</span></span><span style="display:flex;"><span> };
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span> <span style="color:#60a0b0;font-style:italic"># Because /srv is a separate file system, we need to declare:</span>
</span></span><span style="display:flex;"><span> systemd<span style="color:#666">.</span>services<span style="color:#666">.</span><span style="color:#4070a0">"immich-server"</span> <span style="color:#666">=</span> {
</span></span><span style="display:flex;"><span> unitConfig<span style="color:#666">.</span>RequiresMountsFor <span style="color:#666">=</span> [ <span style="color:#4070a0">"/srv"</span> ];
</span></span><span style="display:flex;"><span> wantedBy <span style="color:#666">=</span> [ <span style="color:#4070a0">"srv.mount"</span> ];
</span></span><span style="display:flex;"><span> };
</span></span></code></pre></div><h2 id="conclusion">Conclusion</h2>
<p>You can find the <a href="https://github.com/stapelberg/zkj-nas-tools/tree/master/_2025-07-nixos-nas-configs">full configuration directory on
GitHub</a>.</p>
<p>I am pretty happy with this NixOS setup! Previously (with CoreOS/Flatcar), I
could declaratively manage my base system, but had to manage tons of Docker
containers in addition. With NixOS, I can declaratively manage <em>everything</em> (or
as much as makes sense).</p>
<p>Custom configuration like my SSH+rsync-based backup infrastructure can be
expressed cleanly, in one place, and structured at the desired level of
abstraction/reuse.</p>
<p>If you’re considering managing at least one other system with NixOS, I would
recommend it! One of my follow-up projects is to convert storage3 (my other NAS
build) from Ubuntu Server to NixOS as well to cut down on manual
management. Being able to just copy the entire config to set up another system,
or try out an idea in a throwaway VM, is just such a nice workflow 🥰</p>
<p>…but if you have just a single system to manage, probably all of this is too
complicated.</p>
# Migrating my NAS from CoreOS/Flatcar Linux to NixOS
Source: [https://michael.stapelberg.ch/posts/2025-07-13-nixos-nas-network-storage-config/](https://michael.stapelberg.ch/posts/2025-07-13-nixos-nas-network-storage-config/)
Table of contents- [Context/History](https://michael.stapelberg.ch/posts/2025-07-13-nixos-nas-network-storage-config/#history)
- [My NAS Software Requirements](https://michael.stapelberg.ch/posts/2025-07-13-nixos-nas-network-storage-config/#software-requirements)
- [Why migrate from CoreOS/Flatcar to NixOS?](https://michael.stapelberg.ch/posts/2025-07-13-nixos-nas-network-storage-config/#why-migrate)
- [Prototyping in a VM](https://michael.stapelberg.ch/posts/2025-07-13-nixos-nas-network-storage-config/#vm-prototyping)
- [Migrating](https://michael.stapelberg.ch/posts/2025-07-13-nixos-nas-network-storage-config/#migrating)- [M1\. Install NixOS](https://michael.stapelberg.ch/posts/2025-07-13-nixos-nas-network-storage-config/#m1-install-nixos) - [M2\. Set up remote disk unlock](https://michael.stapelberg.ch/posts/2025-07-13-nixos-nas-network-storage-config/#m2-set-up-remote-disk-unlock) - [M3\. Set up Samba for access](https://michael.stapelberg.ch/posts/2025-07-13-nixos-nas-network-storage-config/#m3-set-up-samba-for-access) - [M4\. Set up SSH/rsync for backups](https://michael.stapelberg.ch/posts/2025-07-13-nixos-nas-network-storage-config/#m4-set-up-sshrsync-for-backups)
- [Nice\-to\-haves](https://michael.stapelberg.ch/posts/2025-07-13-nixos-nas-network-storage-config/#nice-to-haves)- [N1\. Prometheus Node Exporter](https://michael.stapelberg.ch/posts/2025-07-13-nixos-nas-network-storage-config/#prometheus-node-exporter) - [N2\. Reliable mounting](https://michael.stapelberg.ch/posts/2025-07-13-nixos-nas-network-storage-config/#reliable-mount) - [N3\. nginx\-healthz](https://michael.stapelberg.ch/posts/2025-07-13-nixos-nas-network-storage-config/#nginx-healthz) - [N4\. NixOS Jellyfin](https://michael.stapelberg.ch/posts/2025-07-13-nixos-nas-network-storage-config/#jellyfin) - [N5\. NixOS samba](https://michael.stapelberg.ch/posts/2025-07-13-nixos-nas-network-storage-config/#samba-nixos) - [N6\. NixOS rrsync](https://michael.stapelberg.ch/posts/2025-07-13-nixos-nas-network-storage-config/#rrsync-nixos) - [N7\. sync\.pl script](https://michael.stapelberg.ch/posts/2025-07-13-nixos-nas-network-storage-config/#syncpl-nixos) - [N8\. Sharing configs](https://michael.stapelberg.ch/posts/2025-07-13-nixos-nas-network-storage-config/#flakes) - [N9\. Trying immich\!](https://michael.stapelberg.ch/posts/2025-07-13-nixos-nas-network-storage-config/#immich-nixos)
- [Conclusion](https://michael.stapelberg.ch/posts/2025-07-13-nixos-nas-network-storage-config/#conclusion)
In this article, I want to show how to migrate an existing Linux server to NixOS — in my case the CoreOS/Flatcar Linux installation on my Network Attached Storage \(NAS\) PC\.
I will show in detail how the previous CoreOS setup looked like \(lots of systemd units starting Docker containers\), how I migrated it into an intermediate state \(using Docker on NixOS\) just to get things going, and finally how I migrated all units from Docker to native NixOS modules step\-by\-step\.
If you haven’t heard of NixOS, I recommend you read the[first page of the NixOS website](https://nixos.org/)to understand what NixOS is and what sort of things it makes possible\.
The target audience of this blog post is people interested in trying out NixOS for the use\-case of a NAS, who like seeing examples to understand how to configure a system\.
You can apply these examples by first following[my blog post “How I like to install NixOS \(declaratively\)”](https://michael.stapelberg.ch/posts/2025-06-01-nixos-installation-declarative/), then making your way through the sections that interest you\. If you prefer seeing the full configuration,[skip to the conclusion](https://michael.stapelberg.ch/posts/2025-07-13-nixos-nas-network-storage-config/#conclusion)\.
[](https://michael.stapelberg.ch/posts/2025-07-13-nixos-nas-network-storage-config/IMG_5563.jpg)
## Context/History
Over the last decade, I used a number of different operating systems for my NAS needs\. Here’s an overview of the 2 NAS systems storage2 and storage3:
Yearstorage2storage3Details \(blog post\)2013Debian on qnapDebian on qnap[Wake\-On\-LAN with Debian on a qnap TS\-119P2\+](https://michael.stapelberg.ch/posts/2014-01-28-qnap_ts119_wol/)2016CoreOS on PCCoreOS on PC[Gigabit NAS \(running CoreOS\)](https://michael.stapelberg.ch/posts/2016-11-21-gigabit-nas-coreos/)2023CoreOS on PCUbuntu\+ZFS on PC[My all\-flash ZFS NAS build](https://michael.stapelberg.ch/posts/2023-10-25-my-all-flash-zfs-network-storage-build/)2025NixOS on PCUbuntu\+ZFS on PC→ you are here ←?NixOS on PCNixOS\+ZFS on PCConverting more PCs to NixOS seems inevitable ;\)## My NAS Software Requirements
- \(This post is only about software\! For my usage patterns and requirements regarding hardware selection, see[“Design Goals” in my My all\-flash ZFS NAS build post \(2023\)](https://michael.stapelberg.ch/posts/2023-10-25-my-all-flash-zfs-network-storage-build/#design-goals)\.\)
- **Remote management:**I really like the model of having the configuration of my network storage builds version\-controlled and managed on my main PC\. It’s a nice property that I can regain access to my backup setup by re\-installing my NAS from my PC within minutes\.
- **Automated updates, with easy rollback:**Updating all my installations manually is not my idea of a good time\. Hence, automated updates are a must — but when the update breaks, a quick and easy path to recovery is also a must\.- CoreOS/Flatcar achieved that with an A/B updating scheme \(update failed? boot the old partition\), whereas NixOS achieves that with its concept of a “generation” \(update failed? select the old generation\), which is finer\-grained\.
## Why migrate from CoreOS/Flatcar to NixOS?
When I started using CoreOS, Docker was pretty new technology\. I liked that using Docker containers allowed you to treat services uniformly — ultimately, they all expose a port of some sort \(speaking HTTP, or Postgres, or…\), so you got the flexibility to run much more recent versions of software on a stable OS, or older versions in case an update broke something\.
Over a decade later, Docker is established tech\. People nowadays take for granted the various benefits of the container approach\.
So, here’s my list of reasons why I wasn’t satisfied with Flatcar Linux anymore\.
#### R1\. cloud\-init is deprecated
The[CoreOS cloud\-init](https://github.com/coreos/coreos-cloudinit)project was deprecated at some point in favor of[Ignition](https://github.com/coreos/ignition), which is clearly more powerful, but also more cumbersome to get started with as a hobbyist\. As far as I can tell, I must host my config at some URL that I then provide via a kernel parameter\. The old way of just copying a file seems to no longer be supported\.
Ignition also seems less convenient in other ways: YAML is no longer supported, only JSON, which I don’t enjoy writing by hand\. Also, the format seems to[change quite a bit](https://coreos.github.io/ignition/migrating-configs/)\.
As a result, I never made the jump from cloud\-init to Ignition, and it’s not good to be reliant on a long\-deprecated way to use your OS of choice\.
#### R2\. Container Bitrot
At some point, I did an audit of all my containers on the Docker Hub and noticed that most of them were quite outdated\. For a while, Docker Hub offered automated builds based on a`Dockerfile`obtained from GitHub\. However, automated builds now require a subscription, and I will not accept a subscription just to use my own computers\.
#### R3\. Dependency on a central service
If Docker at some point ceases operation of the Docker Hub, I am unable to deploy software to my NAS\. This isn’t a very hypothetical concern: In 2023, Docker Hub[announced the end of organizations on the Free tier](https://news.ycombinator.com/item?id=35154025)and then backpedaled after community backlash\.
Who knows how long they can still provide free services to hobbyists like myself\.
#### R4\. Could not try Immich on Flatcar
The final nail in the coffin was when I noticed that I could not try Immich on my NAS system\! Modern web applications like Immich need multiple Docker containers \(for Postgres, Redis, etc\.\) and hence only offer[Docker Compose](https://immich.app/docs/install/docker-compose)as a supported way of installation\.
Unfortunately, Flatcar[does not include Docker Compose](https://github.com/flatcar/Flatcar/issues/894)\.
I was not in the mood to re\-package Immich for non\-Docker\-Compose systems on an ongoing basis, so I decided that a system on which I can neither run software like Immich directly, nor even run Docker Compose, is not sufficient for my needs anymore\.
#### Reason Summary
With all of the above reasons, I would have had to set up automated container builds, run my own central registry and would still be unable to run well\-known Open Source software like Immich\.
Instead, I decided to try NixOS again \(after a 10 year break\) because it seems like the most popular declarative solution nowadays, with a large community and large selection of packages\.
How does NixOS compare for my situation?
- Same: I also need to set up an automated job to update my NixOS systems\.- I already have such a job for updating my[gokrazy](https://gokrazy.org/)devices\. - Docker push is asynchronous: After a successful push, I still need extra automation for pulling the updated containers on the target host and restarting the affected services, whereas NixOS includes all of that\.
- Better: There is no central registry\. With NixOS, I can push the build result directly to the target host via SSH\.
- Better: The corpus of available software in NixOS is much larger \(including Immich, for example\) and the NixOS modules generally seem to be expressed at a higher level of abstraction than individual Docker containers, meaning you can configure more features with fewer lines of config\.
## Prototyping in a VM
My NAS setup needs to work every day, so I wanted to prototype my desired configuration in a VM before making changes to my system\. This is not only safer, it also allows me to discover any roadblocks, and what working with NixOS feels like without making any commitments\.
I copied my NixOS configuration from a previous test installation \(see[“How I like to install NixOS \(declaratively\)”](https://michael.stapelberg.ch/posts/2025-06-01-nixos-installation-declarative/)\) and used the following command to build a VM image and start it in QEMU:
```
nix build .#nixosConfigurations.storage2.config.system.build.vm
export QEMU_NET_OPTS=hostfwd=tcp::2222-:22
export QEMU_KERNEL_PARAMS=console=ttyS0
./result/bin/run-nixplay-vm
```
The configuration instructions below can be tried out in this VM, and once you’re happy enough with what you have, you can repeat the steps on the actual machine to migrate\.
## Migrating
For the migration of my actual system, I defined the following milestones that should be achievable within a typical session of about an hour \(after prototyping them in a VM\):
- M1\. Install NixOS
- M2\. Set up remote disk unlock
- M3\. Set up Samba for access
- M4\. Set up SSH/rsync for backups
- Everything extra is nice\-to\-have and could be deferred to a future session on another day\.
In practice, this worked out exactly as planned: the actual installation of NixOS and setting up my config to milestone M4 took a little over one hour\. All the other nice\-to\-haves were done over the following days and weeks as time permitted\.
**Tip:**After losing data due to an installer bug in the 2000s, I have adopted the habit of physically disconnecting all data disks \(= pulling out the SATA cable\) when re\-installing the system disk\.
### M1\. Install NixOS
After following[“How I like to install NixOS \(declaratively\)”](https://michael.stapelberg.ch/posts/2025-06-01-nixos-installation-declarative/), this is my initial`configuration\.nix`:
```
{ modulesPath, lib, pkgs, ... }:
{
imports =
[
(modulesPath + "/installer/scan/not-detected.nix")
./hardware-configuration.nix
./disk-config.nix
];
# Adding michael as trusted user means
# we can upgrade the system via SSH (see Makefile).
nix.settings.trusted-users = [ "michael" "root" ];
# Clean the Nix store every week.
nix.gc = {
automatic = true;
dates = "weekly";
options = "--delete-older-than 7d";
};
boot.loader.systemd-boot = {
enable = true;
configurationLimit = 10;
};
boot.loader.efi.canTouchEfiVariables = true;
networking.hostName = "storage2";
time.timeZone = "Europe/Zurich";
# Use systemd for networking
services.resolved.enable = true;
networking.useDHCP = false;
systemd.network.enable = true;
systemd.network.networks."10-e" = {
matchConfig.Name = "e*"; # enp9s0 (10G) or enp8s0 (1G)
networkConfig = {
IPv6AcceptRA = true;
DHCP = "yes";
};
};
i18n.supportedLocales = [
"en_DK.UTF-8/UTF-8"
"de_DE.UTF-8/UTF-8"
"de_CH.UTF-8/UTF-8"
"en_US.UTF-8/UTF-8"
];
i18n.defaultLocale = "en_US.UTF-8";
users.mutableUsers = false;
security.sudo.wheelNeedsPassword = false;
users.users.michael = {
openssh.authorizedKeys.keys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5secret"
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5key"
];
isNormalUser = true;
description = "Michael Stapelberg";
extraGroups = [ "networkmanager" "wheel" ];
initialPassword = "secret"; # XXX: change!
shell = pkgs.zsh;
packages = with pkgs; [];
};
environment.systemPackages = with pkgs; [
git # for checking out github.com/stapelberg/configfiles
rsync
zsh
vim
emacs
wget
curl
];
programs.zsh.enable = true;
services.openssh.enable = true;
# This value determines the NixOS release from which the default
# settings for stateful data, like file locations and database versions
# on your system were taken. It‘s perfectly fine and recommended to leave
# this value at the release version of the first install of this system.
# Before changing this value read the documentation for this option
# (e.g. man configuration.nix or on https://nixos.org/nixos/options.html).
system.stateVersion = "25.05"; # Did you read the comment?
}
```
All following sections describe changes within this`configuration\.nix`\.
All devices in my home network obtain their IP address via DHCP\. If I want to make an IP address static, I configure it accordingly on my router\.
My NAS PCs have one specialty with regards to IP addressing: They are reachable via IPv4 and IPv6, and the IPv6 address can be derived from the IPv4 address\.
Hence, I changed the systemd\-networkd configuration from above such that it configures a static IPv6 address in a dynamically configured IPv6 network:
```
systemd.network.networks."10-e" = {
matchConfig.Name = "e*"; # enp9s0 (10G) or enp8s0 (1G)
networkConfig = {
IPv6AcceptRA = true;
DHCP = "yes";
};
ipv6AcceptRAConfig = {
Token = "::10:0:0:252";
};
};
```
✅ This fulfills milestone M1\.
### M2\. Set up remote disk unlock
To unlock my encrypted disks on boot, I have a custom systemd service unit that uses[`wget\(1\)`](https://manpages.debian.org/wget.1)and[`cryptsetup\(8\)`](https://manpages.debian.org/cryptsetup.8)to split the key file between the NAS and a remote server \(= an attacker needs both pieces to unlock\)\.
With CoreOS/Flatcar, my`cloud\-init`configuration looked as follows:
```
coreos:
units:
- name: unlock.service
command: start
content: |
[Unit]
Description=unlock hard drive
Wants=network.target
After=systemd-networkd-wait-online.service
Before=samba.service
[Service]
Type=oneshot
RemainAfterExit=yes
# Wait until the host is actually reachable.
ExecStart=/bin/sh -c "c=0; while [ $c -lt 5 ]; do /bin/ping6 -n -c 1 r.zekjur.net && break; c=$((c+1)); sleep 1; done"
ExecStart=/bin/sh -c "[ -e \"/dev/mapper/S5SSNF0T205183F_crypt\" ] || (echo -n my_local_secret && wget --retry-connrefused --ca-directory=/dev/null --ca-certificate=/etc/ssl/certs/r.zekjur.net.crt -qO - https://r.zekjur.net:8443/nascrypto) | /sbin/cryptsetup --key-file=- luksOpen /dev/disk/by-id/ata-Samsung_SSD_870_QVO_8TB_S5SSNF0T205183F S5SSNF0T205183F_crypt"
ExecStart=/bin/sh -c "[ -e \"/dev/mapper/S5SSNJ0T205991B_crypt\" ] || (echo -n my_local_secret && wget --retry-connrefused --ca-directory=/dev/null --ca-certificate=/etc/ssl/certs/r.zekjur.net.crt -qO - https://r.zekjur.net:8443/nascrypto) | /sbin/cryptsetup --key-file=- luksOpen /dev/disk/by-id/ata-Samsung_SSD_870_QVO_8TB_S5SSNJ0T205991B S5SSNJ0T205991B_crypt"
ExecStart=/bin/sh -c "vgchange -ay"
ExecStart=/bin/mount /dev/mapper/data-data /srv
write_files:
- path: /etc/ssl/certs/r.zekjur.net.crt
content: |
-----BEGIN CERTIFICATE-----
MIID8TCCAlmgAwIBAgIRAPWwvYWpoH+lGKv6rxZvC4MwDQYJKoZIhvcNAQELBQAw
[…]
-----END CERTIFICATE-----
```
I converted it into the following NixOS configuration:
```
systemd.services.unlock = {
wantedBy = [ "multi-user.target" ];
description = "unlock hard drive";
wants = [ "network.target" ];
after = [ "systemd-networkd-wait-online.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = "yes";
ExecStart = [
# Wait until the host is actually reachable.
''/bin/sh -c "c=0; while [ $c -lt 5 ]; do ${pkgs.iputils}/bin/ping -n -c 1 r.zekjur.net && break; c=$((c+1)); sleep 1; done"''
''/bin/sh -c "[ -e \"/dev/mapper/S5SSNF0T205183F_crypt\" ] || (echo -n my_local_secret && ${pkgs.wget}/bin/wget --retry-connrefused --ca-directory=/dev/null --ca-certificate=/etc/ssl/certs/r.zekjur.net.crt -qO - https://r.zekjur.net:8443/sdb2_crypt) | ${pkgs.cryptsetup}/bin/cryptsetup --key-file=- luksOpen /dev/disk/by-id/ata-Samsung_SSD_870_QVO_8TB_S5SSNF0T205183F S5SSNF0T205183F_crypt"''
''/bin/sh -c "[ -e \"/dev/mapper/S5SSNJ0T205991B_crypt\" ] || (echo -n my_local_secret && ${pkgs.wget}/bin/wget --retry-connrefused --ca-directory=/dev/null --ca-certificate=/etc/ssl/certs/r.zekjur.net.crt -qO - https://r.zekjur.net:8443/sdc2_crypt) | ${pkgs.cryptsetup}/bin/cryptsetup --key-file=- luksOpen /dev/disk/by-id/ata-Samsung_SSD_870_QVO_8TB_S5SSNJ0T205991B S5SSNJ0T205991B_crypt"''
''/bin/sh -c "${pkgs.lvm2}/bin/vgchange -ay"''
''/run/wrappers/bin/mount /dev/mapper/data-data /srv''
];
};
};
```
We’ll also need to store the custom TLS certificate file on disk\. For that, we can use the`environment\.`configuration:
```
environment.etc."ssl/certs/r.zekjur.net.crt".text = ''
-----BEGIN CERTIFICATE-----
MIID8TCCAlmgAwIBAgIRAPWwvYWpoH+lGKv6rxZvC4MwDQYJKoZIhvcNAQELBQAw
[…]
-----END CERTIFICATE-----
'';
```
The references like`$\{pkgs\.wget\}`will be replaced with a path to the Nix store \([→ nix\.dev documentation](https://nix.dev/tutorials/nix-language.html#paths)\)\. On CoreOS/Flatcar, I was limited to using just the \(minimal set of\) software included in the base image, or I had to reach for Docker\. On NixOS, we can use all packages available in nixpkgs\.
After[deploying](https://michael.stapelberg.ch/posts/2025-06-01-nixos-installation-declarative/#making-changes)and`reboot`ing, I can access my unlocked disk under`/srv`\! 🎉
```
% df -h /srv
Filesystem Size Used Avail Use% Mounted on
/dev/mapper/data-data 15T 14T 342G 98% /srv
```
When listing my files, I noticed that the group id was different between my old system and the new system\. This can be fixed by explicitly specifying the desired group id:
```
users.groups.michael = {
gid = 1000; # for consistency with storage3
};
```
✅ M2 is complete\.
### M3\. Set up Samba for access
Whereas I want to configure remote disk unlock at the systemd service level, for Samba I want to use Docker: I wanted to first transfer my old \(working\) Docker\-based setups as they are, and only later convert them to Nix\.
We enable the[Docker NixOS module](https://search.nixos.org/options?query=virtualisation.docker.enable)which sets up the daemons that Docker needs and whatever else is needed to make it work:
```
virtualisation.docker.enable = true;
```
This is already sufficient for other services to use Docker, but I also want to be able to run the`docker`command interactively for debugging\. Therefore, I added`docker`to`systemPackages`:
```
environment.systemPackages = with pkgs; [
git # for checking out github.com/stapelberg/configfiles
rsync
zsh
vim
emacs
wget
curl
docker
];
```
After deploying this configuration, I can run`docker run \-ti debian`to verify things work\.
The`cloud\-init`version of samba looked like this:
```
coreos:
units:
- name: samba.service
command: start
content: |
[Unit]
Description=samba server
After=docker.service unlock.mount
Requires=docker.service unlock.mount
[Service]
Restart=always
StartLimitInterval=0
# Always pull the latest version (bleeding edge).
ExecStartPre=-/usr/bin/docker pull stapelberg/docker-samba:latest
# Set up samba users (cannot be done in the (public) Dockerfile because
# users/passwords are sensitive information).
ExecStartPre=-/usr/bin/docker kill smb
ExecStartPre=-/usr/bin/docker rm smb
ExecStartPre=-/usr/bin/docker rm smb-prep
ExecStartPre=/usr/bin/docker run --name smb-prep stapelberg/docker-samba sh -c 'adduser --quiet --disabled-password --gecos "" --uid 29901 michael && sed -i "s,\\[global\\],[global]\\nserver multi channel support = yes\\naio read size = 1\\naio write size = 1,g" /etc/samba/smb.conf'
ExecStartPre=/usr/bin/docker commit smb-prep smb-prepared
ExecStartPre=/usr/bin/docker rm smb-prep
ExecStartPre=/usr/bin/docker run --name smb-prep smb-prepared /bin/sh -c "echo \"secret\nsecret\n" | tee - | smbpasswd -a -s michael"
ExecStartPre=/usr/bin/docker commit smb-prep smb-prepared
ExecStart=/usr/bin/docker run \
-p 137:137 \
-p 138:138 \
-p 139:139 \
-p 445:445 \
--tmpfs=/run \
-v /srv/data:/srv/data \
--name smb \
-t \
smb-prepared \
/usr/sbin/smbd --foreground --debug-stdout --no-process-group
```
We can translate this 1:1 to NixOS:
```
systemd.services.samba = {
wantedBy = [ "multi-user.target" ];
description = "samba server";
after = [ "unlock.service" ];
requires = [ "unlock.service" ];
serviceConfig = {
Restart = "always";
StartLimitInterval = 0;
ExecStartPre = [
# Always pull the latest version.
''-${pkgs.docker}/bin/docker pull stapelberg/docker-samba:latest''
# Set up samba users (cannot be done in the (public) Dockerfile because
# users/passwords are sensitive information).
''-${pkgs.docker}/bin/docker kill smb''
''-${pkgs.docker}/bin/docker rm smb''
''-${pkgs.docker}/bin/docker rm smb-prep''
''-${pkgs.docker}/bin/docker run --name smb-prep stapelberg/docker-samba sh -c 'adduser --quiet --disabled-password --gecos "" --uid 29901 michael && sed -i "s,\\[global\\],[global]\\nserver multi channel support = yes\\naio read size = 1\\naio write size = 1,g" /etc/samba/smb.conf' ''
''-${pkgs.docker}/bin/docker commit smb-prep smb-prepared''
''-${pkgs.docker}/bin/docker rm smb-prep''
''-${pkgs.docker}/bin/docker run --name smb-prep smb-prepared /bin/sh -c "echo \"secret\nsecret\n" | tee - | smbpasswd -a -s michael"''
''-${pkgs.docker}/bin/docker commit smb-prep smb-prepared''
];
ExecStart = ''-${pkgs.docker}/bin/docker run \
-p 137:137 \
-p 138:138 \
-p 139:139 \
-p 445:445 \
--tmpfs=/run \
-v /srv/data:/srv/data \
--name smb \
-t \
smb-prepared \
/usr/sbin/smbd --foreground --debug-stdout --no-process-group
'';
};
};
}
```
✅ Now I can manage my files over the network, which completes M3\!
See also:[Nice\-to\-haves: N5\. samba from NixOS](https://michael.stapelberg.ch/posts/2025-07-13-nixos-nas-network-storage-config/#samba-nixos)
### M4\. Set up SSH/rsync for backups
For backing up data, I use rsync over SSH\. I restrict this SSH access to run only rsync commands by using`rrsync`\(in a Docker container\)\. To configure the SSH[`authorized\_keys\(5\)`](https://manpages.debian.org/authorized_keys.5), we set:
```
users.users.root.openssh.authorizedKeys.keys = [
''command="${pkgs.docker}/bin/docker run --log-driver none -i -e SSH_ORIGINAL_COMMAND -v /srv/backup/midna:/srv/backup/midna stapelberg/docker-rsync /srv/backup/midna" ssh-rsa AAAAB3Npublickey root@midna''
};
```
✅ A successful test backup run completes milestone M4\!
See also:[Nice\-to\-haves: N6\. rrsync from NixOS](https://michael.stapelberg.ch/posts/2025-07-13-nixos-nas-network-storage-config/#rrsync-nixos)
## Nice\-to\-haves
### N1\. Prometheus Node Exporter
I like to monitor all my machines with[Prometheus](https://prometheus.io/)\(and Grafana\)\. For network connectivity and authentication, I use the Tailscale mesh VPN\.
To install Tailscale, I[enable its NixOS module](https://search.nixos.org/options?query=services.tailscale.enable)and make the`tailscale`command available:
```
services.tailscale.enable = true;
environment.systemPackages = with pkgs; [ tailscale ];
```
After deploying, I run`sudo tailscale up`and open the login link in my browser\.
The Prometheus Node Exporter can also easily be enabled[through its NixOS module](https://search.nixos.org/options?query=services.prometheus.exporters.node.enable):
```
services.prometheus.exporters.node = {
enable = true;
listenAddress = "storage2.example.ts.net";
};
```
However, this isn’t reliable yet: When Tailscale’s startup takes a while during system boot, the Node Exporter might burn through its entire restart budget when it cannot listen on the Tailscale IP address yet\. We can enable[indefinite restarts](https://michael.stapelberg.ch/posts/2024-01-17-systemd-indefinite-service-restarts/)for the service to eventually come up:
```
systemd.services."prometheus-node-exporter" = {
startLimitIntervalSec = 0;
serviceConfig = {
Restart = "always";
RestartSec = 1;
};
};
```
### N2\. Reliable mounting
While migrating my setup, I noticed that calling[`mount\(8\)`](https://manpages.debian.org/mount.8)from`unlock\.service`directly is not reliable, and it’s better to let systemd manage the mounting:
```
fileSystems."/srv" = {
device = "/dev/mapper/data-data";
fsType = "ext4";
options = [
"nofail"
"x-systemd.requires=unlock.service"
];
};
```
Afterwards, I could just remove the[`mount\(8\)`](https://manpages.debian.org/mount.8)call from`unlock\.service`:
```
@@ -247,7 +247,10 @@ fry/U6A=
''/bin/sh -c "${pkgs.lvm2.bin}/bin/vgchange -ay"''
- ''/run/wrappers/bin/mount /dev/mapper/data-data /srv''
+ # Let systemd mount /srv based on the fileSystems./srv
+ # declaration to prevent race conditions: mount
+ # might not succeed while the fsck is still in progress,
+ # for example, which otherwise makes unlock.service fail.
];
};
```
In systemd services, I can now depend on the`/srv`mount unit:
```
systemd.services.jellyfin = {
unitConfig.RequiresMountsFor = [ "/srv" ];
wantedBy = [ "srv.mount" ];
};
```
### N3\. nginx\-healthz
To save power, I turn off my NAS when they are not in use\.
My backup orchestration uses Wake\-on\-LAN to start a wakeup and needs to wait until the NAS is fully booted up and has mounted its`/srv`mount before it can start backup jobs\.
For this purpose, I have configured a web server \(without any files\) that depends on the`/srv`mount\. So, once the web server responds to HTTP requests, we know`/srv`is mounted\.
The`cloud\-init`config looked as follows:
```
coreos:
units:
- name: healthz.service
command: start
content: |
[Unit]
Description=nginx for /srv health check
Wants=network.target
After=srv.mount
Requires=srv.mount
StartLimitInterval=0
[Service]
Restart=always
ExecStartPre=/bin/sh -c 'systemctl is-active docker.service'
ExecStartPre=/usr/bin/docker pull nginx:1
ExecStartPre=-/usr/bin/docker kill nginx-healthz
ExecStartPre=-/usr/bin/docker rm -f nginx-healthz
ExecStart=/usr/bin/docker run \
--name nginx-healthz \
--publish 10.0.0.252:8200:80 \
--log-driver=journald \
nginx:1
```
The Docker version \(ported from Flatcar Linux\) looks like this:
```
systemd.services.healthz = {
description = "nginx for /srv health check";
wants = [ "network.target" ];
unitConfig.RequiresMountsFor = [ "/srv" ];
wantedBy = [ "srv.mount" ];
startLimitIntervalSec = 0;
serviceConfig = {
Restart = "always";
ExecStartPre = [
''/bin/sh -c 'systemctl is-active docker.service' ''
''-${pkgs.docker}/bin/docker pull nginx:1''
''-${pkgs.docker}/bin/docker kill nginx-healthz''
''-${pkgs.docker}/bin/docker rm -f nginx-healthz''
];
ExecStart = [
''-${pkgs.docker}/bin/docker run \
--name nginx-healthz \
--publish 10.0.0.252:8200:80 \
--log-driver=journald \
nginx:1
''
];
};
};
```
This configuration gets a lot simpler when migrating it from Docker to NixOS:
```
# Signal readiness on HTTP port 8200 once /srv is mounted:
networking.firewall.allowedTCPPorts = [ 8200 ];
services.caddy = {
enable = true;
virtualHosts."http://10.0.0.252:8200".extraConfig = ''
respond "ok"
'';
};
systemd.services.caddy = {
unitConfig.RequiresMountsFor = [ "/srv" ];
wantedBy = [ "srv.mount" ];
};
```
### N4\. NixOS Jellyfin
The Docker version \(ported from Flatcar Linux\) looks like this:
```
networking.firewall.allowedTCPPorts = [ 4414 8096 ];
systemd.services.jellyfin = {
wantedBy = [ "multi-user.target" ];
description = "jellyfin";
after = [ "docker.service" "srv.mount" ];
requires = [ "docker.service" "srv.mount" ];
startLimitIntervalSec = 0;
serviceConfig = {
Restart = "always";
ExecStartPre = [
''-${pkgs.docker}/bin/docker pull lscr.io/linuxserver/jellyfin:latest''
''-${pkgs.docker}/bin/docker rm jellyfin''
];
ExecStart = [
''-${pkgs.docker}/bin/docker run \
--rm \
--net=host \
--name=jellyfin \
-e TZ=Europe/Zurich \
-v /srv/jellyfin/config:/config \
-v /srv/data/movies:/data/movies:ro \
-v /srv/data/series:/data/series:ro \
-v /srv/data/mp3:/data/mp3:ro \
lscr.io/linuxserver/jellyfin:latest
''
];
};
};
```
As before, when using jellyfin from NixOS, the configuration gets simpler:
```
services.jellyfin = {
enable = true;
openFirewall = true;
};
systemd.services.jellyfin = {
unitConfig.RequiresMountsFor = [ "/srv" ];
wantedBy = [ "srv.mount" ];
};
```
For a while, I had also set up compatibility symlinks that map the old location \(`/data/movies`, inside the Docker container\) to the new location \(`/srv/data/movies`\), but I encountered strange issues in Jellyfin and ended up just re\-initializing my whole Jellyfin state\. While the required configuration had more lines, I found it neat to move it into its own file, so here is how to do that:
Remove the lines above from`configuration\.nix`and move them into`jellyfin\.nix`:
```
{
config,
lib,
pkgs,
modulesPath,
...
}:
{
services.jellyfin = {
enable = true;
openFirewall = true;
dataDir = "/srv/jellyfin";
cacheDir = "/srv/jellyfin/config/cache";
};
systemd.services.jellyfin = {
unitConfig.RequiresMountsFor = [ "/srv" ];
wantedBy = [ "srv.mount" ];
};
}
```
Then, in`configuration\.nix`, add`jellyfin\.nix`to the`imports`:
```
imports = [
./hardware-configuration.nix
./jellyfin.nix
];
```
### N5\. NixOS samba
To use Samba from NixOS, I replaced my`systemd\.services\.samba`config from M3 with this:
```
services.samba = {
enable = true;
openFirewall = true;
settings = {
"global" = {
"map to guest" = "bad user";
};
"data" = {
"path" = "/srv/data";
"comment" = "public data";
"read only" = "no";
"create mask" = "0775";
"directory mask" = "0775";
"guest ok" = "yes";
};
};
};
system.activationScripts.samba_user_create = ''
smb_password="secret"
echo -e "$smb_password\n$smb_password\n" | ${lib.getExe' pkgs.samba "smbpasswd"} -a -s michael
'';
```
Note: Setting the samba password in the activation script works for small setups, but if you want to keep your samba passwords out of the Nix store, you’ll need to use a different approach\. On a different machine, I use[sops\-nix](https://github.com/Mic92/sops-nix)to manage secrets and found that refactoring the`smbpasswd`call like so works reliably:
```
let
setPasswords = pkgs.writeShellScript "samba-set-passwords" ''
set -euo pipefail
for user in michael; do
smb_password="$(cat /run/secrets/samba_passwords/$user)"
echo -e "$smb_password\n$smb_password\n" | ${lib.getExe' pkgs.samba "smbpasswd"} -a -s $user
done
'';
in
{
# …
services.samba = {
# …as above…
}
systemd.services.samba-smbd.serviceConfig.ExecStartPre = [
"${setPasswords}"
];
sops.secrets."samba_passwords/michael" = {
restartUnits = [ "samba-smbd.service" ];
};
}
```
I also noticed that NixOS does not create a group for each user by default, but I am used to managing my permissions like that\. We can easily declare a group like so:
```
users.groups.michael = {
gid = 1000; # for consistency with storage3
};
users.users.michael = {
extraGroups = [
"wheel" # Enable ‘sudo’ for the user.
"docker"
# By default, NixOS does not add users to their own group:
# https://github.com/NixOS/nixpkgs/issues/198296
"michael"
];
};
```
### N6\. NixOS rrsync
The Docker version \(ported from Flatcar Linux\) looks like this:
```
users.users.root.openssh.authorizedKeys.keys = [
''command="${pkgs.docker}/bin/docker run --log-driver none -i -e SSH_ORIGINAL_COMMAND -v /srv/backup/midna:/srv/backup/midna stapelberg/docker-rsync /srv/backup/midna" ssh-rsa AAAAB3Npublickey root@midna''
];
```
To use`rrsync`from NixOS, I changed the configuration like so:
```
users.users.root.openssh.authorizedKeys.keys = [
''command="${pkgs.rrsync}/bin/rrsync /srv/backup/midna" ssh-rsa AAAAB3Npublickey root@midna''
];
```
### N7\. sync\.pl script
The Docker version \(ported from Flatcar Linux\) looks like this:
```
users.users.root.openssh.authorizedKeys.keys = [
''command="${pkgs.docker}/bin/docker run --log-driver none -i -e SSH_ORIGINAL_COMMAND -v /srv/data:/srv/data -v /root/.ssh:/root/.ssh:ro -v /etc/ssh:/etc/ssh:ro -v /etc/static/ssh:/etc/static/ssh:ro -v /nix/store:/nix/store:ro stapelberg/docker-sync",no-port-forwarding,no-X11-forwarding ssh-ed25519 AAAAC3Npublickey sync@dr''
];
```
I wanted to stop managing the following`Dockerfile`to ship`sync\.pl`:
```
FROM debian:stable
# Install full perl for Data::Dumper
RUN apt-get update \
&& apt-get install -y rsync ssh perl
ADD sync.pl /usr/bin/
ENTRYPOINT ["/usr/bin/sync.pl"]
```
To get rid of the Docker container, I translated the`sync\.pl`file into a Nix expression that writes the`sync\.pl`Perl script to the Nix store:
```
{ pkgs }:
# For string literal escaping rules (''${), see:
# https://nix.dev/manual/nix/2.26/language/string-literals#string-literals
# For writers.writePerlBin, see https://wiki.nixos.org/wiki/Nix-writers
pkgs.writers.writePerlBin "syncpl" { libraries = []; } ''
# This script is run via ssh from dornröschen.
use strict;
use warnings;
use Data::Dumper;
if (my ($destination) = ($ENV{SSH_ORIGINAL_COMMAND} =~ /^([a-z0-9.]+)$/)) {
print STDERR "rsync version: " . `${pkgs.rsync}/bin/rsync --version` . "\n\n";
my @rsync = (
"${pkgs.rsync}/bin/rsync",
"-e",
"ssh",
"--max-delete=-1",
"--verbose",
"--stats",
# Intentionally not setting -X for my data sync,
# where there are no full system backups; mostly media files.
"-ax",
"--ignore-existing",
"--omit-dir-times",
"/srv/data/",
"''${destination}:/",
);
print STDERR "running: " . Dumper(\@rsync) . "\n";
exec @rsync;
} else {
print STDERR "Could not parse SSH_ORIGINAL_COMMAND.\n";
}
''
```
I can then reference this file by importing it in my`configuration\.nix`and pointing it to the`pkgs`expression of my NixOS configuration:
```
{ modulesPath, lib, pkgs, ... }:
let
syncpl = import ./syncpl.nix { pkgs = pkgs; };
in {
imports = [ ./hardware-configuration.nix ];
users.users.root.openssh.authorizedKeys.keys = [
''command="${syncpl}/bin/syncpl",no-port-forwarding,no-X11-forwarding ssh-ed25519 AAAAC3Npublickey sync@dr''
];
# For interactive usage (when debugging):
environment.systemPackages = [ syncpl ];
# …
}
```
This works, but is it the best approach? Here are some thoughts:
- By managing this script in a Nix expression, I can no longer use my editor’s Perl support\.- I could probably also keep`sync\.pl`as a separate file and use string interpolation in my Nix expression to inject an absolute path to the`rsync`binary into the script\.
- Another alternative would be to add a wrapper script to my Nix expression which ensures that`$PATH`contains`rsync`and then the script wouldn’t need an absolute path anymore\.
- For small glue scripts like this one, I consider it easier to manage the contents “inline” in the Nix expression, because it means one fewer file in my config directory\.
### N8\. Sharing configs
I want to configure all my NixOS systems such that my user settings are identical everywhere\.
To achieve that, I can extract parts of my`configuration\.nix`into a[`user\-settings\.nix`](https://github.com/stapelberg/nix/blob/main/user-settings.nix)and then[declare an accompanying`flake\.nix`](https://github.com/stapelberg/nix/blob/30cdd7db9e0ab4b7cc3a38b7953e1b7e1e238d75/flake.nix#L7)that provides this expression as an output\.
After publishing these files in a git repository, I can reference said repository in my`flake\.nix`:
```
{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-25.05";
stapelbergnix.url = "github:stapelberg/nix";
};
outputs =
{
self,
nixpkgs,
stapelbergnix,
}:
let
system = "x86_64-linux";
pkgs = import nixpkgs {
inherit system;
config.allowUnfree = false;
};
in
{
nixosConfigurations.storage2 = nixpkgs.lib.nixosSystem {
inherit system;
inherit pkgs;
modules = [
./configuration.nix
stapelbergnix.lib.userSettings
# Not on this machine; We have our own networking config:
# stapelbergnix.lib.systemdNetwork
# Use systemd-boot as bootloader
stapelbergnix.lib.systemdBoot
];
};
formatter.${system} = pkgs.nixfmt-tree;
};
}
```
Everything[declared in the`user\-settings\.nix`](https://github.com/stapelberg/nix/blob/main/user-settings.nix)can now be removed from`configuration\.nix`\!
### N9\. Trying immich\!
One of the motivating reasons for switching away from CoreOS/Flatcar was that I couldn’t try Immich, so let’s give it a shot on NixOS:
```
services.immich = {
enable = true;
host = "10.0.0.252";
port = 2283;
openFirewall = true;
mediaLocation = "/srv/immich";
};
# Because /srv is a separate file system, we need to declare:
systemd.services."immich-server" = {
unitConfig.RequiresMountsFor = [ "/srv" ];
wantedBy = [ "srv.mount" ];
};
```
## Conclusion
You can find the[full configuration directory on GitHub](https://github.com/stapelberg/zkj-nas-tools/tree/master/_2025-07-nixos-nas-configs)\.
I am pretty happy with this NixOS setup\! Previously \(with CoreOS/Flatcar\), I could declaratively manage my base system, but had to manage tons of Docker containers in addition\. With NixOS, I can declaratively manage*everything*\(or as much as makes sense\)\.
Custom configuration like my SSH\+rsync\-based backup infrastructure can be expressed cleanly, in one place, and structured at the desired level of abstraction/reuse\.
If you’re considering managing at least one other system with NixOS, I would recommend it\! One of my follow\-up projects is to convert storage3 \(my other NAS build\) from Ubuntu Server to NixOS as well to cut down on manual management\. Being able to just copy the entire config to set up another system, or try out an idea in a throwaway VM, is just such a nice workflow 🥰
…but if you have just a single system to manage, probably all of this is too complicated\.
Did you like this post?[Subscribe to this blog’s RSS feed](https://michael.stapelberg.ch/feed.xml)to not miss any new posts\!
I run a blog since 2005, spreading knowledge and experience for over 20 years\! :\)
A guide on declaratively installing NixOS over the network using tools like nixos-anywhere, with an emphasis on managing configuration files under version control.
A tutorial demonstrating four ways to set up development shells using Nix, including interactive one-offs, config files, and hermetic Nix Flakes, using GoCV and OpenCV as an example.
A tutorial explaining secrets management options for NixOS, comparing tools like sops-nix, agenix, and ragenix, with practical examples of using sops-nix for encrypted secrets management.
A guide to setting up Immich, a self-hosted photo management tool, to replace Google Photos, covering hardware, installation on NixOS, and secure access via Tailscale.