Design Spec: NAS LXC Container for SMB File Sharing
This spec builds on the NixOS LXC Containers on Proxmox
framework, which defines the proxmox-lxc role, LXC template generation, the
Terraform container-provisioning pattern, and LXC test limitations. This
document covers only the NAS-specific additions: SMB file sharing, the Syncthing
service that runs alongside it (§12), and the Tailscale connectivity the
Syncthing offsite transport depends on (§13).
Implementation Status
| Component / Feature | Status | Details |
|---|---|---|
nas Role (SMB) |
Fully Implemented | NixOS role enabling Samba with declarative share definitions. |
common Role UID Pin |
Fully Implemented | ferrarimarco UID pinned to 1000; verified a no-op on deployed hosts. |
Host Config (nas-pve1) |
Fully Implemented | NixOS host config for the pve1 instance. |
Host Config (nas-pve2) |
Fully Implemented | NixOS host config for the pve2 instance. |
Terraform LXC (pve1) |
Fully Implemented | proxmox_virtual_environment_container in containers-pve1.tf. |
Terraform LXC (pve2) |
Fully Implemented | proxmox_virtual_environment_container in containers-pve2.tf. |
| Terraform Template Upload | Fully Implemented | Provided by the framework (images-templates.tf; see the framework spec, §6.1). |
| Host Storage Prep (Ansible) | Fully Implemented | setup_disks role: pools asserted, datasets and Samba state dir converged (§11). |
| Host Integration Tests | Fully Implemented | Auto-discovered tests for nas-pve1 and nas-pve2; passing locally. |
| Flake Registration | Fully Implemented | Both NAS hosts discovered by the flake (tests and machine matrix). |
Syncthing Service (nas-pve1) |
Fully Implemented | Migrated: remote peer in sync over the tailnet, raspberrypi2 instance decommissioned (2026-10-06, §12.5). |
| Syncthing Storage (dataset, bind mounts) | Fully Implemented | Dataset and bind mounts live on pve1; Terraform plans no changes (2026-09-29). |
| Tailscale Storage (state bind mount) | Fully Implemented | State dir and bind mount live on pve1; Terraform plans no changes (2026-10-03). |
Tailscale Service (nas-pve1) |
Fully Implemented | Deployed and joined (2026-10-03); declarative key expiry is a readme todo. |
1. Goal
Deploy a NixOS-based LXC container on each Proxmox node (pve1 and pve2) that
exposes host-local ZFS datasets as network file shares over SMB (Samba). The
containers receive ZFS datasets via Proxmox bind mounts, keeping ZFS management
on the host while the container handles only the network-sharing layer.
The general reasons for using a NixOS LXC container (versus a VM) are covered in the framework spec; the rationale below is specific to the NAS workload.
2. Rationale
2.1 Why One Container Per Node
The Proxmox nodes share neither hardware nor a distributed filesystem: each has
its own local ZFS pools, with different names, layouts, and capacities (for
example rpool-sata on pve1, tank-hdd on pve2). Data lives on whichever
node owns the disks, so there is no shared backend to serve a file across nodes
and no need for VM-style live migration.
Running one NAS container per node embraces this: each container serves only its
own host's datasets over bind mounts, with no cross-node storage dependency. The
host-dependent facts — pool names, dataset paths, capacities, per-node sizing —
live entirely in the per-node Terraform definitions (§6), which normalize them
onto uniform in-container mount points (/mnt/shared/...); the NixOS
configuration is therefore identical across nodes (§5). Tracing the media and
backups datasets down the stack:
- Hardware — differs:
pve1has SATA SSDs,pve2has spinning HDDs. - ZFS (host) — differs:
/rpool-sata/{media,backups}onpve1,/tank-hdd/{media,backups}onpve2. - Terraform (bind mounts) — source differs, target is uniform: each node
binds its own host paths onto
/mnt/shared/{media,backups}. - NixOS (service) — identical: Samba serves the same in-container paths on
both nodes; only
networking.hostNamediffers, plus optional per-host shares, kept small and additive (§5.1).
2.2 Why Bind Mounts Instead of ZFS-in-Container
Passing raw ZFS device nodes into a container is fragile and requires extensive privilege escalation. Bind-mounting the pre-mounted dataset paths from the host is the standard Proxmox pattern: the host manages ZFS (scrubs, snapshots, replication), and the container sees plain directories.
2.3 Why LXC Instead of a VM
The general LXC-versus-VM trade-offs are covered in the framework rationale. For this workload the decisive factor is avoiding write amplification. Because the datasets stay local to each node and never migrate (§2.1), the container gains nothing from a VM's portability, while a VM would force the file server's data through a guest filesystem stacked on a virtual disk. The LXC bind mount is a direct passthrough onto the host ZFS dataset, so Samba writes land on ZFS with no intermediate filesystem.
The cost is weaker isolation and the UID-mapping handling this requires. That trade-off is accepted deliberately; see Privileged Container.
3. Architecture Overview
┌────────────────────────────────────────────────┐
│ Proxmox Host (shown: pve1) │
│ │
│ ZFS pools ──► /rpool-sata/media │
│ /rpool-sata/backups │
│ /rpool-sata/... │
│ │ bind mount │
│ ┌─────────────────┼─────────────────────────┐ │
│ │ NixOS LXC Container (nas-pveN) │ │
│ │ │ │ │
│ │ /mnt/shared/media ◄──────────────────┘ │ │
│ │ /mnt/shared/backups │ │
│ │ │ │
│ │ ┌──────────┐ │ │
│ │ │ Samba │ │ │
│ │ │ Server │ │ │
│ │ └────┬─────┘ │ │
│ └───────┼───────────────────────────────────┘ │
│ │ │
└──────────┼─────────────────────────────────────┘
│
SMB clients
(Windows, macOS, Linux)
The host paths shown are pve1's; on pve2 the same in-container mount points
are backed by /tank-hdd/... instead (see §2.1). Only the bind-mount source
changes per node — everything inside the container is identical.
4. nas Role (config/nix/roles/nas/default.nix)
The NAS host imports the framework's common role plus the nas service role
defined here; nas itself imports the framework's
proxmox-lxc
role. The nas role defines the default SMB configuration shared by every
NAS container: the Samba service and its global settings, the default share
definitions (media and backups), the privileged-container setting the bind
mounts require, and the hostname-management setting GitOps requires. Every node
exposes these defaults identically, so by default only networking.hostName
differs between hosts; a node that owns extra datasets can layer additional
shares on top in its own configuration.nix (see
§5.1).
The role imports proxmox-lxc directly because it references the proxmoxLXC.*
options that role's module defines; hosts therefore do not import proxmox-lxc
themselves.
{ lib, ... }:
{
imports = [
../proxmox-lxc
];
# Plain values override the proxmox-lxc role's lib.mkDefault defaults;
# identical for every NAS container.
proxmoxLXC = {
# The container is made privileged by Terraform (`unprivileged = false`,
# section 6.1) so bind-mounted ZFS datasets keep their host UID/GID
# (host 1000 = container 1000) without an idmap. This option does NOT
# control that: it only tells the NixOS guest module to expect a
# privileged environment, and it must agree with the Terraform setting.
privileged = true;
# NAS hosts pin networking.hostName: comin selects the configuration by
# hostname and asserts at build time that it is non-empty. Without this
# override, the upstream proxmox-lxc module forces networking.hostName
# to "" (expecting the PVE-written /etc/hostname to provide it), which
# would fail that assertion. See the note on hostname in the framework
# spec, section 3.
manageHostName = true;
};
# Samba Server
services.samba = {
enable = lib.mkDefault true;
openFirewall = lib.mkDefault true;
settings = {
global = {
"workgroup" = lib.mkDefault "WORKGROUP";
"server string" = lib.mkDefault "NixOS NAS";
"security" = "user";
"map to guest" = "never";
# No performance tuning: modern Samba defaults already enable AIO
# (aio read/write size = 1), and the once-popular tuning knobs
# interact badly (e.g. a non-zero "aio write size" disables the
# receivefile path that "min receivefile size" enables). Add tuning
# only with a benchmark that justifies it.
};
# Shares. Every NAS container exposes the same in-container mount points;
# each node's Terraform bind mounts map its own host datasets onto these
# paths (see section 6), so this role is identical across nodes.
"media" = {
"path" = "/mnt/shared/media";
"browseable" = "yes";
"read only" = "no";
"guest ok" = "no";
"valid users" = "ferrarimarco";
};
"backups" = {
"path" = "/mnt/shared/backups";
"browseable" = "yes";
"read only" = "no";
"guest ok" = "no";
"valid users" = "ferrarimarco";
};
};
};
}
Note on firewall ports:
services.samba.openFirewallopens the Samba ports automatically (TCP 445 and 139, plus UDP 137 and 138 whennmbdis enabled), so thenasrole manages no firewall rules directly.
5. Host Configuration
By default, both NAS hosts run an identical NixOS configuration, differing
only in networking.hostName: the role imports, the privileged-container and
hostname-management settings, and the default Samba shares all live in the
shared roles (chiefly nas). A host
that must serve datasets the others do not can additionally declare
host-specific shares (see §5.1); absent that, each
host's configuration.nix sets just the hostname:
{
imports = [
../../roles/common
../../roles/nas
../../roles/comin
];
# Required per-host value; a host may also add its own shares (see 5.1).
networking.hostName = "nas-pve1";
}
This matches the [common, platform role, comin] import shape used by
hl02; the nas role pulls in proxmox-lxc itself (§4).
This works because both nodes expose the same in-container mount points,
with the node-specific storage confined to Terraform (§2.1, §6). No ZFS runs
inside the container, so no per-host networking.hostId is needed either.
As for hl02, the comin role delivers and maintains the
configuration through the pull-based
GitOps model:
comin selects the matching nixosConfigurations output by hostname — which is
why the hostname is the one value each host pins — and no per-host template is
built.
The configuration declares no user accounts: user identity is centralized in the
common role, which every host imports. Because the container is privileged
(§6.1), UIDs are not shifted: Samba runs as ferrarimarco, and to line up with
the bind-mounted datasets (owned by host UID 1000) the common role pins the
UID rather than letting isNormalUser auto-allocate one:
# config/nix/roles/common/default.nix
users.users.ferrarimarco.uid = 1000;
The primary group already defaults to users (GID 100), matching the dataset
group ownership. Pinning the UID in common keeps it consistent across every
host in the lab, not just the NAS containers.
Migration note. Because
commonis shared, the pin also lands on every already-deployed host (e.g.hl02). NixOS updates the UID in/etc/passwdbut does not chown existing files. In practice the firstisNormalUseraccount is auto-allocated UID1000anyway, so this should be a no-op — verify withid -u ferrarimarcoon each existing host before landing the pin.
5.1 Adding Per-Host Shares
The media and backups shares are defaults that every NAS container exposes.
A node that owns datasets the others do not (for example a photos dataset only
on pve1) can serve them by adding a share on that host only. A share is
three coupled declarations, so all of them are required:
- The host dataset (Ansible). Add the dataset to that node's
zfs_datasetslist in its Ansiblehost_vars(see §11.1) so thesetup_disksrole creates it and converges its mount-point ownership. - The bind mount (Terraform). Add the host-path → container-path entry to
that node's
var.nas_container_bind_mounts(see §6.2), for example/rpool-sata/photos→/mnt/shared/photos. -
The Samba share (NixOS). Add the export to that host's
configuration.nix; it merges with the role's defaults:# config/nix/hosts/nas-pve1/configuration.nix services.samba.settings.photos = { "path" = "/mnt/shared/photos"; "browseable" = "yes"; "read only" = "no"; "guest ok" = "no"; "valid users" = "ferrarimarco"; };
All three or none. The declarations are not cross-checked: a Samba share without its bind mount exports an empty path, a bind mount without its dataset fails to start the container (Proxmox does not create bind-mount sources), and a dataset without its share holds data that is never served. Add and remove them together.
6. Infrastructure Provisioning (Terraform)
The base container resource and template upload follow the Terraform provisioning pattern in the framework spec. The NAS container adds two things on top: the privileged setting for bind-mount ownership, and the ZFS dataset bind mounts.
6.1 Privileged Container
unprivileged = false # Privileged: bind mounts keep host UID/GID (no idmap)
A privileged container maps host UID/GID directly (host 1000 = container
1000), so the bind-mounted ZFS datasets retain their ownership without an
lxc.idmap. This avoids raw LXC config keys (which the bpg/proxmox provider
does not expose and would otherwise require a provisioner) at the cost of a
weaker isolation boundary (see §8.1).
Keep both sides in agreement. This Terraform setting is what actually makes the container privileged; the
proxmoxLXC.privileged = truein thenasrole (§4) only tells the NixOS guest module what environment to expect. The two are not cross-checked — change them together.
6.2 ZFS Dataset Bind Mounts
# Bind mounts for ZFS datasets, parameterized via var.nas_container_bind_mounts
dynamic "mount_point" {
for_each = var.nas_container_bind_mounts["pve1"]
content {
volume = mount_point.value.host_path
path = mount_point.value.container_path
}
}
# Persistent Samba state to survive container recreations
mount_point {
volume = "/var/lib/samba-state/nas-pve1"
path = "/var/lib/samba"
}
Bind mounts are declared via Terraform variables and dynamically mapped, keeping
dataset details out of the resource block. /var/lib/samba is bind-mounted from
host-persistent storage so the Samba password database survives container
recreation (see §7.2). The host-side directory must exist before the container
first starts; see §11.2 for its
prerequisites and backup implications.
Mount point changes force container replacement at the Terraform layer. The
bpg/proxmoxprovider (through at least 0.114.0) cannot update a container's mount points in place: any added or changedmount_pointblock plans a destroy-and-recreate of the container, which would revert it to the bare bootstrap template and require the GitOps handoff again (§10.1). In-place management is an open upstream request (bpg/terraform-provider-proxmox#1392, scheduled for the provider's v2.0 milestone). Until it lands, roll out mount point changes by fast-forwarding reality withpct seton the owning node — replicating exactly the mount slot order Terraform renders: the dynamic dataset bind mounts first, in list order, then the static state mounts — followed by one container restart. The next Terraform run then refreshes the new layout into state and plans no changes (verified for the Syncthing mounts on 2026-09-29).
7. SMB User Management
Samba maintains its own password database (passdb.tdb) separate from the Linux
system's /etc/shadow. NixOS does not provide a fully declarative mechanism to
populate Samba passwords because storing credentials in the world-readable Nix
store would be a security risk.
7.1 Declarative Part
The NixOS configuration declares:
- The system user (
users.users.ferrarimarco), with its UID pinned to1000, via thecommonrole. - The Samba share definitions and
valid usersvia thenasrole. - The Samba service itself (
services.samba.enable = true).
7.2 Imperative Part (One-Time Setup)
After the initial container deployment, the Samba password must be set once manually, either from the owning Proxmox node:
# On the Proxmox node that owns the container.
# /bin/sh -lc is required: pct exec does not source a login shell, so
# /run/current-system/sw/bin (where NixOS puts smbpasswd) is not on PATH.
pct exec <vmid> -- /bin/sh -lc 'smbpasswd -a ferrarimarco'
or from a shell inside the container:
sudo smbpasswd -a ferrarimarco
This step is intentionally imperative. Because /var/lib/samba is mounted from
the host's persistent storage (e.g. /var/lib/samba-state/nas-pve1), the Samba
user database (passdb.tdb) is preserved across container recreations and
template updates, making it a true one-time setup step for the lifecycle of the
lab. Automating it — without committing secrets, even encrypted ones, to the
public repository — is designed but not implemented; see the Samba password
automation item in the
specifications readme.
8. Security Considerations
8.1 Privileged Container
The container runs privileged so bind-mounted ZFS datasets retain their host UID/GID without an idmap (see §6.1). This means the container's root user maps directly to the host's UID 0 inside the container namespace.
Running privileged is a deliberate trade-off, chosen over the alternatives
because they are worse under this lab's constraints: an unprivileged container
with an lxc.idmap needs raw LXC config keys the bpg/proxmox provider does
not expose (forcing a provisioner this design avoids), and chowning the host
datasets into the shifted UID range makes the data look alien to host-side
tooling (scrubs, snapshots, direct host access). Privileged 1:1 UID mapping
keeps bind-mounted data owned consistently on both sides. The isolation cost is
bounded by the following mitigations:
- The container runs only the Samba service; no user-facing shell access is expected.
- SSH is enabled (from the
commonrole) but restricted to key-based authentication with root login disabled. - The container has no direct access to ZFS administrative commands; it sees bind-mounted directories as plain filesystems.
8.2 SMB Security
- Guest access is disabled (
map to guest = never). - Only authenticated local users can access shares (
security = user). - The Samba password database (
passdb.tdb, which stores NT password hashes) does not live in the container's rootfs:/var/lib/sambais bind-mounted from the host (§6.2), so the hashes reside on the Proxmox host at/var/lib/samba-state/nas-pveN, readable only by root. Anything that backs up that host path captures the hashes; see §11.2.
9. Integration Testing
9.1 Test Auto-Discovery
Both nas-pve1 and nas-pve2 contain a configuration.nix file and are
automatically discovered by the flake's test generator (see
Testing Spec - Dynamic Discovery).
The auto-generated tests verify:
- Successful boot (
multi-user.targetreached). - SSH port 22 availability.
9.2 Samba Coverage in the Test Generator
The Samba service requires bind-mounted share paths that do not exist in the
test VM sandbox. Instead of per-host test overrides, the centralized test
generator (config/nix/tests/make-test.nix) handles Samba hosts conditionally,
following the same service-aware pattern it already uses for SSH, the QEMU guest
agent, and comin (see
Testing Spec - Centralized Test Generator):
- It derives the share list from the evaluated
services.samba.settings(every section besidesglobalthat declares apath) and creates each share's path as a mock directory viasystemd.tmpfilesrules. - It waits for
samba-smbd.service(NixOS names the Samba unitssamba-smbd.service,samba-nmbd.service, and so on). - It asserts that every declared share appears in an anonymous
(
smbclient -L localhost -N) enumeration over the null session. This checks that the shares are exported, not the authenticated access path, since no Samba password can be set declaratively (see section 7).
Because the assertions are derived from the evaluated configuration, a host that adds a per-host share (section 5.1) is covered automatically: the mock directory and the enumeration assertion follow the share definition, so the Samba configuration and its test coverage cannot drift apart.
The NAS hosts therefore ship no test-override.nix. The file remains available
for genuinely host-specific assertions (see the framework's
host structure), and the LXC boot tweaks
are applied automatically by the harness (see
LXC test limitations).
10. Deployment Workflow
10.1 Initial Deployment
The examples below use VMID 200 for nas-pve1. VMIDs are cluster-unique (the
existing VMs use 100 and 101), so nas-pve2 gets its own distinct ID (e.g.
201), and each pct command runs on the Proxmox node that owns the container.
-
Converge host storage (Ansible). The
setup_disksrole (see §11.1) asserts that the node's ZFS pool exists, creates themediaandbackupsdatasets if missing, sets their mount-point ownership, and creates the Samba state directory (§11.2):ANSIBLE_PLAYBOOK_FILE_NAME="setup-disks.yaml" \ ADDITIONAL_ANSIBLE_FLAGS="--limit home_lab_proxmox_nodes" \ scripts/run-ansible.sh -
Provision the container. Stage the image artifacts with the
proxmox-imagespackage, then apply Terraform to upload the genericnixos-lxc-bootstraptemplate (shared by every NAS container — no per-host template is built), create the container with hostnamenas-pveNand the ZFS bind mounts, and start it:(cd config/nix && nix build .#proxmox-images) cd config/terraform/220-proxmox-workloads terraform apply -
Hand off to GitOps. Run the unified bootstrap script from the repository root (see the bootstrapping spec). It discovers the container over SSH, verifies its pinned MAC address, and pushes the full host configuration — including
comin— withnixos-rebuild switch --flake --target-host; the closure is built on the control machine, so the bare bootstrap template needs no flake support or PATH setup. Thereafter the container maintains itself from the repository (see Continuous Deployment (GitOps)):scripts/bootstrap-host.sh nas-pve1 <pinned-mac-address>Fallback (no SSH path to the container). The switch can also run from the owning Proxmox node. Two wrinkles in this variant, both consequences of running against the bare bootstrap template:
pct execdoes not source a login shell (hence/bin/sh -lc, since/run/current-system/sw/binis not on its default PATH), and flakes are not enabled until thecommonrole lands (hence the--optionflag):pct exec 200 -- /bin/sh -lc 'nixos-rebuild switch \ --option extra-experimental-features "nix-command flakes" \ --flake "github:ferrarimarco/home-lab?dir=config/nix#nas-pve1"' -
Set the Samba password (one-time, imperative; see §7.2):
pct exec 200 -- /bin/sh -lc 'smbpasswd -a ferrarimarco'
10.2 Configuration Updates
Configuration changes are delivered through GitOps: edit the host
configuration.nix (or a shared role) and push to master. comin polls the
repository on each NAS container and applies the updated
nixosConfigurations.nas-pveN closure automatically — no template rebuild,
upload, or container recreation. The Samba password database persists in the
/var/lib/samba bind mount across every change.
10.3 SMB Clients
Hosts that consume the shares mount them over CIFS. How a client is configured
follows its management layer: Ansible-managed hosts (Debian) declare the mount
as data for the setup_disks role; NixOS hosts would declare fileSystems
entries (none do yet). The Proxmox nodes mount nothing: each already owns its
datasets natively, and mounting a container's export back onto its own host
would add a network filesystem loop over local data.
The first client is hl01, which mounts two nas-pve1 shares at the mount
points its former local virtual disks occupied, so every consumer keeps its
configured paths (raspberrypi2 mounts the backups share the same way since
2026-10-11, for its interim restic repository in a per-host directory, because
each host keeps its own repository password):
- the
backupsshare at/media/backup-0(workloads_backup_disk_mount_path), replacing the deleted backup virtual disk; the restic backup stack keeps its target path. - the
media-usbshare — the first consumer of a per-host share (§5.1), backed bypve1's media-only USB pool (rpool-usb-2/mediasince 2026-10-10;rpool-usb-1/mediabefore) — at/media/data0(data_disk_mount_path), replacing the deleted data virtual disk.media_directory_path(/media/data0/media) is unchanged and now resolves to amedia/subdirectory inside the share.
Its host_vars declare three pieces, all converged by the setup_disks role
(which runs before the node playbook, so the mounts' prerequisites cannot race
it):
mount_os_packages— installscifs-utils.mount_credential_files— writes the root-only (0600) SMB credentials file (/etc/smb-credentials-nas-pve1) whose username and password come from the shared Ansible vault (vault_nas_smb_user,vault_nas_smb_passwordingroup_vars/all, since the credentials may be shared across client hosts), keeping the secrets out of the repository consistent with the lab's secrets policy. The values are the Samba user and password set in §7.2.disks_to_mount— the CIFS entries themselves (//nas-pve1.edge.lab.ferrari.how/backupsand//nas-pve1.edge.lab.ferrari.how/media-usb, sharing the credentials file) withcredentials=/etc/smb-credentials-nas-pve1,uid=1000,gid=1000,vers=3.1.1,_netdev,nofail._netdevorders the mount after the network is up, andnofailkeeps the client booting when the NAS container is down. The DNS name (backed by a router DHCP reservation for the container's pinned MAC, see §11.4) keeps the source stable across leases.
11. Assumptions and Constraints
11.1 ZFS Dataset Mount Points on the Host
The Proxmox host must have the ZFS datasets mounted at known, stable paths
(specifically /rpool-sata/media and /rpool-sata/backups on pve1 and
/tank-hdd/media and /tank-hdd/backups on pve2), owned by UID 1000
(ferrarimarco) and GID 100 (users) to match container permissions.
This layout is codified in Ansible rather than assumed: the
ferrarimarco_home_lab_setup_disks role converges it from two per-node
host_vars lists, following a read-then-act pattern (query actual state with
changed_when: false commands, act only on the delta) instead of the
community.general.zfs module, whose property handling is not reliably
idempotent. No suitable Terraform provider exists for ZFS either, which is why
this lives in the Ansible layer that already manages the Proxmox nodes:
zfs_pools— asserted, never created.zpool createis destructive and device-specific, so pool creation stays a deliberate manual act. Each entry records the pool's actual topology (by-iddevice paths) and creation options (e.g.ashift) as executable documentation; the role fails with the documentedzpool createcommand when the pool is missing.zfs_datasets— created if missing (zfs create -p). The declaredmount_pointis not an input to ZFS (datasets mount at the ZFS-computed path, by default/<pool>/<dataset>), so the role then asserts that each dataset's actualmountpointproperty matches the declaration before converging the mount point's ownership (UID1000, GID100) — a drifted declaration fails loudly instead of chowning a plain directory that shadows the real dataset. The check also works in check mode: datasets that would be created are validated against their predicted default mount point, derived from the pool's actual one.
The same paths appear as the bind-mount sources in Terraform
(var.nas_container_bind_mounts, §6.2). The
Ansible and Terraform declarations are two views of the same layout and are not
cross-checked: if a dataset path changes, update both together (and the Samba
share if it is a per-host one; see §5.1).
The same role renders every disks_to_mount entry, and adds nofail to the
mount options of every entry except the root filesystem unless the entry sets
block_boot: true: a dead or absent device then degrades to a missing mount
instead of stalling the boot in the emergency shell, which on a headless host
with a locked root account is unreachable (raspberrypi2, 2026-10-09). The same
entries feed the ExpectedMountMissing alert
(Monitoring Alerting), so a mount that is missing
after such a boot is reported instead of silently replaced by a directory.
11.2 Samba State Directory on the Host
Each node must provide the persistent directory backing the /var/lib/samba
bind mount (/var/lib/samba-state/nas-pve1 on pve1,
/var/lib/samba-state/nas-pve2 on pve2). Proxmox does not create bind-mount
sources, so the directory must exist — owned by root, as Samba expects for
/var/lib/samba — before the container first starts. Like the datasets
(§11.1), this is codified in
Ansible: the directory is declared in each node's directories_to_create
host_vars list and created by the setup_disks role.
As specced, this directory lives on the Proxmox root filesystem, not on the data
pools, so passdb.tdb is not covered by ZFS snapshots or replication of the
shared datasets; losing it means re-running smbpasswd (§7.2), not data loss.
If that trade-off becomes unacceptable, move the directory onto a dedicated ZFS
dataset — and remember that any backup of it captures NT password hashes (see
§8.2).
11.3 Networking (DHCP)
The NAS containers use DHCP, consistent with the current approach for
hl02. DHCP is requested via the
Terraform initialization.ip_config block, which PVE renders into the
container's systemd-networkd configuration (see the framework spec's
Terraform section).
Static IPs or DHCP reservations should be configured in the router to ensure
stable addressing for SMB clients.
11.4 MAC Address Pinning
Each container's network interface is assigned a pinned MAC address in Terraform, matching the pattern used for VMs. This ensures stable DHCP reservations.
12. Syncthing Service
The NAS guest is also the home of Syncthing, the lab's offsite backup transport:
it receives a remote peer's backups into a local folder, sends a small subset of
replaceable media offsite, and may later send this lab's own backup data (for
example, restic repositories) to the peer. nas-pve1 runs the first instance,
migrated from raspberrypi2 (§12.5). The model generalizes to one instance per
storage-owning host — a future nas-pve2 instance would serve folders on
tank-hdd — with every instance joining the same Syncthing device mesh. What is
never done is pointing one instance at another host's storage over a network
mount (§12.1).
12.1 Placement Rationale
Syncthing folders must live on storage that is local to the instance. On a
network filesystem the change watcher receives no events for server-side changes
(Syncthing falls back to periodic full rescans of the whole tree over the
network), permission handling needs workarounds such as ignorePerms, and
Syncthing maintainers advise running the service on the machine that owns the
storage. Two alternatives were considered and rejected:
- Syncthing on hl01 with folders on a CIFS mount of the NAS shares: rejected for the network-filesystem limitations above; hl01's local disk is also too small to hold the folders itself.
- A dedicated Syncthing LXC container: rejected because the folders live on datasets the NAS guest already bind-mounts for its shares, so a second privileged container would duplicate the bind mounts and widen the UID-alignment surface for no isolation gain.
Inside the NAS container, a bind-mounted dataset is a local filesystem, so the watcher, permission handling, and atomic renames all behave normally.
12.2 Folders and Data Flow
The instance serves two folders migrated from raspberrypi2, keeping their existing Syncthing folder IDs so the remote peer reattaches them to the new device without recreating anything:
- The personal backup folder (about 196 GB), declared receive-only: the remote peer is the source of truth, and local changes are never propagated back — the correct posture for a backup destination.
- The outbound media-subset folder, declared send-only: this lab is the source, and remote changes are never accepted.
Folder types encode the backup direction declaratively. Syncthing is used strictly as a transport, not as the backup mechanism itself: sync faithfully replicates deletions and corruption, so versioning and integrity checking belong to the layer that produces the data (for example restic, when this lab's own backups are sent offsite later).
The service runs as ferrarimarco (UID 1000) rather than the module's default
syncthing user, so file ownership on the bind-mounted datasets stays aligned
with the host and with Samba (see §5).
12.3 Storage Declarations
The folders live on a dedicated rpool-usb-1/syncthing dataset, following the
per-host share pattern (§5.1) with Syncthing as the consumer instead of a Samba
export:
- The host dataset (Ansible):
rpool-usb-1/syncthingin pve1'szfs_datasetslist, converged by thesetup_disksrole (§11.1). - The bind mount (Terraform):
/rpool-usb-1/syncthing→/mnt/shared/syncthinginvar.nas_container_bind_mounts. - The folder directories (imperative, on acceptance): Syncthing creates
each folder's directory under
/mnt/shared/syncthingwhen the shared folder is accepted, running as the service user on theferrarimarco-owned dataset. They are deliberately not provisioned in the NixOS configuration: the directory names stay personal and out of the repository, like the folder objects themselves (§12.4). Rejected: declaring the directories with neutralized names — folder paths are device-local in Syncthing, so renaming bought no compatibility and cost continuity, and encoding the traffic direction in a name (for example "inbound") misleads as soon as the folder type changes — direction is configuration, not identity.
An SMB export of the dataset is deliberately not part of this design; if browsing the folders over the network becomes useful, add a per-host share for the same paths (§5.1).
12.4 Device Identity and State Persistence
The instance uses a fresh device identity, generated on the guest at first start and never tracked. Rejected: reusing raspberrypi2's identity by copying its key pair — the device key is secret material, so it cannot flow through the public repository or the declarative configuration, and preserving it would only spare the remote peer a single accept-new-device action.
Device IDs are treated as private material. Not for authentication — a device ID is the fingerprint of the device's public TLS certificate — but because Syncthing's global discovery resolves an ID to the device's current public addresses, a published ID lets anyone track that device's IP over time. Committing device IDs was therefore rejected, and the sync topology — device entries, folder definitions, and their sharing — is configured once via the GUI/API and persists in the state bind mount below, the same posture as the GUI credentials and the Samba password (§7.2). Automating the topology delivery without publishing the IDs could reuse the Samba password automation design tracked in the specifications readme. The imperative topology has two consequences in the host configuration:
overrideDevices = falseandoverrideFolders = falseare required: the module defaults both to true and deletes undeclared devices and folders as "stale" on every activation, even when the configuration declares none (observed 2026-09-29: the first activation after pairing wiped the configured devices). Rejected: declaring only the folders — declared folder objects are re-applied wholesale on every activation, stripping the imperatively added device sharing.- The
guisettings section stays undeclared: at the pinned nixpkgs the module PUTs each declared settings section on every activation, and PUT replaces the whole section (observed 2026-10-03: a declaredgui.useTLSwiped the imperative GUI username, leaving the GUI password-less). HTTPS and the credentials are set imperatively instead. Theoptionssection is declared deliberately — its keys plus the defaults are the intended connectivity posture — and the declareddefaults.folder.pathis currently inert (the pinned module targets a nonexistent endpoint; the value was set imperatively). nixpkgs master fixes both (PATCH-based merge, real defaults endpoint); redeclaring once a release ships the fixed module is tracked in the specifications readme.
The state directory (/var/lib/syncthing: the device keys and the index
database) is bind-mounted from /var/lib/syncthing-state/nas-pve1 on the host,
following the Samba state pattern (§6.2, §11.2), so the identity survives
container recreation. The host directory is declared in pve1's
directories_to_create list, owned by UID 1000 to match the service user. The
index database is rebuildable by a rescan; the key pair is the only unique
material.
The NixOS guest has no restic coverage (the restic stack manages Debian hosts only) — an accepted trade-off: the synced data is re-syncable from the remote peer, and a lost identity is regenerable at the cost of one re-acceptance. Backing up the state directories on pve1 is tracked in the specifications readme; they hold secret material (§8.2, and the device private key here).
12.5 Migration from raspberrypi2
Seeding uses Syncthing itself instead of a manual copy: the raspberrypi2
instance adds the new nas-pve1 device and shares both folders with it, the two
instances discover each other via local discovery, and the data transfers
directly over the LAN with every block hash-verified. There is no freeze window
— changes arriving mid-transfer sync normally. Pre-seeding with rsync was
considered and rejected: it requires quiescing the source, forfeits Syncthing's
built-in verification, and saves nothing. Once the folders report in sync, the
remote peer accepts the new device and drops the old one, the Syncthing blackbox
probe is re-pointed at nas-pve1, and raspberrypi2's instance is removed
(configure_syncthing: false).
Executed 2026-10-05/06: the remote peer connected over the tailnet through a static device address (the instance neither announces to nor queries global discovery, so the peer cannot resolve it dynamically) and reconciled both folders without conflicts or transfers; the personal-data folder was set to receive only on nas-pve1 beforehand, making the remote's authority structural. The raspberrypi2 instance was then stopped and its configuration and device identity removed. The old folder data on the raspberrypi2 data disk is untouched and awaits a separately approved deletion (tracked in the specs index).
12.6 Monitoring
The monitoring backend scrapes the instance's native metrics endpoint and alerts on folder errors, stuck syncs, and unseen peers; the monitoring-alerting spec (§6.8) records the job, the rules, and the privacy constraints on the metric labels. The existing Syncthing HTTP endpoint blackbox probe moves to the nas-pve1 GUI/API endpoint as part of the migration cutover.
13. Tailscale Connectivity
13.1 Purpose and Scope
The Syncthing offsite transport (§12) connects to its remote peer over the tailnet only: global discovery, relays, and NAT traversal are disabled on both instances (§12.4), and the peer addresses are static tailnet addresses. The remote peer is a device on this lab's own tailnet (Tailscale's free Personal plan: six users, unlimited devices), so no cross-tailnet node sharing is involved. This section covers tailnet connectivity for the nas-pve1 guest and nothing else: a subnet router for general remote access to the LAN and an exit node are tracked as separate items in the specifications readme.
13.2 Placement Rationale
Tailscale runs as a direct node inside the nas-pve1 guest, giving the Syncthing instance an end-to-end WireGuard path and a stable, collision-free tailnet address that the remote peer dials directly. Rejected alternatives:
- A subnet router on another host: outbound dialing from the guest to the
peer's tailnet address would need a static route for
100.64.0.0/10plus source NAT on the router — imperative site-to-site plumbing; the peer would dial the guest at its LAN address, which breaks if the remote network ever uses the same RFC1918 range; the candidate router (raspberrypi2) is being retired; and high-availability route failover is a paid feature, so redundant routers would not fail over on the free plan. tailscaledon the pve1 host: the Proxmox host stays lean, and terminating the tunnel in the guest keeps the service and its transport in the same failure and security domain. Redundancy for this path is a non-goal: the endpoint lives with the service, so a tunnel that survives the guest buys nothing.- Fleet-wide installs: no other host terminates a tailnet-facing service, and the free plan does not constrain device count, so the only effect would be a larger operational surface. Nodes are added when a host starts terminating a tailnet-facing service of its own.
13.3 Infrastructure Provisioning
Two host-level changes provision the guest, following the existing patterns:
/dev/net/tundevice passthrough (Terraform): the TUN device node is not in the container's/devby default (the container is privileged, §8.1, but device nodes still need passing through), sotailscaledneeds the device passed through. The provider supports this declaratively via the container resource'sdevice_passthroughblock, and adding the block applies in place (verified 2026-10-03: the plan showed a plain addition with noforces replacementmarker — only the mount point below forced replacement).- Node-state bind mount (Ansible and Terraform):
/var/lib/tailscale(the node key and preferences) is bind-mounted from/var/lib/tailscale-state/nas-pve1on the host, following the Samba and Syncthing state pattern (§6.2, §11.2), so the node identity survives container recreation. The host directory is declared in pve1'sdirectories_to_createlist, owned byrootwith mode0700—tailscaledruns as root, and the directory holds the node's private key. Adding the mount point hits the known in-place mount update limitation and requires the §6.2 fast-forward (performed and converged 2026-10-03, including the provider-default device mode0660in thepctarguments).
13.4 Node Identity and Joining
The guest enables the service declaratively (services.tailscale.enable) and
joins the tailnet imperatively, once, via an interactive tailscale up
login; the node state persists in the bind mount (§13.3), mirroring the
Syncthing pairing posture (§12.4). Delivering a pre-authentication key through
the repository was rejected (nothing secret is ever committed), and automating
the delivery out-of-band — including minting the key with the Tailscale
Terraform provider (tailscale_tailnet_key) — was deferred: keys expire within
90 days, so automation only pays off when nodes join regularly, this design adds
one node once, and a provider-minted key would land in the Terraform state as a
second secret location while its delivery into the comin-managed guest stays
unsolved. The Samba password automation design remains the candidate mechanism
if that changes
(specifications readme).
Tailnet device names and addresses stay out of the repository, like the Syncthing device IDs (§12.4): they are not authentication material, but they map the lab and its peers to network locations.
13.5 Operational Requirements
- Disable node key expiry for nas-pve1 declaratively: the default expiry
(about 180 days) would silently take the backup transport offline. The
Tailscale Terraform provider's
tailscale_device_keyresource setskey_expiry_disabled; the admin-console toggle was rejected as click-ops. The provider authenticates with an API access token through the untracked tfvars files, like the Proxmox credentials, and adds a Tailscale control-plane dependency to Terraform runs; the device must exist in the tailnet before the first apply that references it. - Access control stays on the tailnet's default allow-all policy for now: the free plan's ACL capacity (three groups) is not a constraint at this scale, and the tailnet has a single administrator. Tightening the policy to port 22000 between the peers is possible later without touching the guest.
- Peer swap: once the guest is on the tailnet, the migration's peer swap (§12.5) proceeds — the remote peer adds the new device ID and the guest's tailnet address, and drops raspberrypi2.