Skip to content

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: pve1 has SATA SSDs, pve2 has spinning HDDs.
  • ZFS (host) — differs: /rpool-sata/{media,backups} on pve1, /tank-hdd/{media,backups} on pve2.
  • 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.hostName differs, 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.openFirewall opens the Samba ports automatically (TCP 445 and 139, plus UDP 137 and 138 when nmbd is enabled), so the nas role 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 common is shared, the pin also lands on every already-deployed host (e.g. hl02). NixOS updates the UID in /etc/passwd but does not chown existing files. In practice the first isNormalUser account is auto-allocated UID 1000 anyway, so this should be a no-op — verify with id -u ferrarimarco on 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:

  1. The host dataset (Ansible). Add the dataset to that node's zfs_datasets list in its Ansible host_vars (see §11.1) so the setup_disks role creates it and converges its mount-point ownership.
  2. 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.
  3. 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 = true in the nas role (§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/proxmox provider (through at least 0.114.0) cannot update a container's mount points in place: any added or changed mount_point block 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 with pct set on 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 to 1000, via the common role.
  • The Samba share definitions and valid users via the nas role.
  • 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 common role) 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/samba is 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:

  1. Successful boot (multi-user.target reached).
  2. 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 besides global that declares a path) and creates each share's path as a mock directory via systemd.tmpfiles rules.
  • It waits for samba-smbd.service (NixOS names the Samba units samba-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.

  1. Converge host storage (Ansible). The setup_disks role (see §11.1) asserts that the node's ZFS pool exists, creates the media and backups datasets 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
    
  2. Provision the container. Stage the image artifacts with the proxmox-images package, then apply Terraform to upload the generic nixos-lxc-bootstrap template (shared by every NAS container — no per-host template is built), create the container with hostname nas-pveN and the ZFS bind mounts, and start it:

    (cd config/nix && nix build .#proxmox-images)
    cd config/terraform/220-proxmox-workloads
    terraform apply
    
  3. 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 — with nixos-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 exec does not source a login shell (hence /bin/sh -lc, since /run/current-system/sw/bin is not on its default PATH), and flakes are not enabled until the common role lands (hence the --option flag):

    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"'
    
  4. 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 backups share 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-usb share — the first consumer of a per-host share (§5.1), backed by pve1's media-only USB pool (rpool-usb-2/media since 2026-10-10; rpool-usb-1/media before) — 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 a media/ 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 — installs cifs-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_password in group_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/backups and //nas-pve1.edge.lab.ferrari.how/media-usb, sharing the credentials file) with credentials=/etc/smb-credentials-nas-pve1,uid=1000,gid=1000,vers=3.1.1,_netdev,nofail. _netdev orders the mount after the network is up, and nofail keeps 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 create is destructive and device-specific, so pool creation stays a deliberate manual act. Each entry records the pool's actual topology (by-id device paths) and creation options (e.g. ashift) as executable documentation; the role fails with the documented zpool create command when the pool is missing.
  • zfs_datasets — created if missing (zfs create -p). The declared mount_point is 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 actual mountpoint property matches the declaration before converging the mount point's ownership (UID 1000, GID 100) — 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:

  1. The host dataset (Ansible): rpool-usb-1/syncthing in pve1's zfs_datasets list, converged by the setup_disks role (§11.1).
  2. The bind mount (Terraform): /rpool-usb-1/syncthing → /mnt/shared/syncthing in var.nas_container_bind_mounts.
  3. The folder directories (imperative, on acceptance): Syncthing creates each folder's directory under /mnt/shared/syncthing when the shared folder is accepted, running as the service user on the ferrarimarco-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 = false and overrideFolders = false are 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 gui settings 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 declared gui.useTLS wiped the imperative GUI username, leaving the GUI password-less). HTTPS and the credentials are set imperatively instead. The options section is declared deliberately — its keys plus the defaults are the intended connectivity posture — and the declared defaults.folder.path is 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/10 plus 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.
  • tailscaled on 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:

  1. /dev/net/tun device passthrough (Terraform): the TUN device node is not in the container's /dev by default (the container is privileged, §8.1, but device nodes still need passing through), so tailscaled needs the device passed through. The provider supports this declaratively via the container resource's device_passthrough block, and adding the block applies in place (verified 2026-10-03: the plan showed a plain addition with no forces replacement marker — only the mount point below forced replacement).
  2. Node-state bind mount (Ansible and Terraform): /var/lib/tailscale (the node key and preferences) is bind-mounted from /var/lib/tailscale-state/nas-pve1 on 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's directories_to_create list, owned by root with mode 0700 — tailscaled runs 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 mode 0660 in the pct arguments).

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_key resource sets key_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.