NixOS VMs part 5: A NixOS NAS in a Proxmox LXC container
This is part 5 of a series on nixos-vm-template:
- Running code agents in an immutable NixOS VM
- Bootstrapping a Docker server with immutable NixOS on Proxmox
- Mutable VMs are cool too
- Managing VMs with home-manager and sway-home
- A NixOS NAS in a Proxmox LXC container (this post)
Every post in this series so far has been about virtual machines - libvirt on a laptop, KVM on Proxmox, immutable or mutable, but always a full guest with its own kernel. This post is about the thing a VM fundamentally can’t do well, and the new backend nixos-vm-template grew to do it: run NixOS in a Proxmox LXC container so it can bind-mount a host ZFS dataset directly, and serve it as a NAS over NFS, Samba, and a web UI - all from one declarative profile.
You could just as easily read this as a continuation of the older Proxmox series, picking up from part 8: TrueNAS Core. Running TrueNAS as a VM on top of Proxmox was an interesting design, but it had a fundamental awkwardness: the storage lived inside the TrueNAS guest, so if you wanted to use that storage for your other Proxmox VMs, you had to funnel the disks back out over NFS - Proxmox mounting a network share that was served by a VM running on Proxmox itself. A storage round-trip through a guest, just to get back to disks the host already had.
The LXC NAS turns that inside out. Proxmox keeps its native ZFS pool and uses it directly for VM disks - no NFS round-trip, no TrueNAS in the middle. The container doesn’t own the storage; it just bind-mounts whichever datasets you want to share and serves them over kernel NFS, Samba, or copyparty. The pool is the host’s; the container is only the file server sitting in front of it.
Why a container instead of a VM?
A KVM virtual machine cannot bind-mount a host directory. It can only share one over a network protocol or hand it a virtual disk. If you want a VM to serve files that live on the host’s ZFS pool, you’re stuck re-exporting them over virtiofs or 9p and hoping the semantics line up.
A Proxmox LXC container can do the real thing:
pct set <vmid> -mp0 /tank/nas,mp=/srv/nas
That’s a first-class host bind mount. The dataset appears inside the
container as a real zfs mount - same xattrs, same ZFS semantics, no
translation layer. That makes LXC the natural home for a NAS that serves
host-native ZFS, which is exactly what the new proxmox-lxc backend and
its nas profile are built around.
The tradeoff is that a container shares the host kernel, so this backend
is mutable-only: no bootloader and no read-only root, so the
immutable and semi-mutable modes from the earlier posts don’t apply. But
don’t read “mutable” as “manage it from the inside.” The container
carries no durable state of its own - all of its configuration is
injected from your workstation when it’s built, and all of its data
lives on the host ZFS dataset that’s bind-mounted in. So the right mental
model isn’t the in-container nixos-rebuild workflow from the mutable
VMs in part 3; it’s stateless. To change
the system you edit your config on the workstation and just recreate -
blow the container away and build a fresh one. The bind-mounted dataset
isn’t part of the container, so your files are untouched. That’s the
payoff for giving up the read-only root: host bind mounts, which no KVM
backend can offer.
How the backend works
Same shape as the Proxmox KVM backend from part 2: build locally, ship over SSH, no API tokens or web-UI clicking. The difference is what gets built and how it lands:
- Build: Nix builds a NixOS rootfs tarball (
vm.container = true) - no bootloader, no virtio disks. - Transfer:
rsyncsends the tarball to the PVE node. - Create:
pct create … --ostype unmanaged --rootfs <storage>:<size>with a per-machine pinned MAC. - Bind mounts: each configured host dataset is attached with
pct set -mpN <hostpath>,mp=<ctpath>- and the dataset is created on the host if it doesn’t exist yet. - Inject identity: the stopped rootfs is opened with
pct mountand the hostname, machine-id, SSH keys, firewall ports, and an/etc/nixosflake are rsynced into/etc. - Start:
pct start. The IP comes fromlxc-info -iH(no guest agent needed).
Set up the connection
Like the KVM Proxmox backend, all you need is SSH access to your PVE node
configured in ~/.ssh/config. Create a .env in the project root and
set the backend:
BACKEND=proxmox-lxc PVE_HOST=pve PVE_STORAGE=local-zfs PVE_BRIDGE=vmbr0 PVE_BACKUP_STORAGE=local
One gotcha worth calling out, because it’s the most common first-run
mistake: PVE_STORAGE is a Proxmox storage name (a zfspool or
dir storage, like local-zfs), not a bare ZFS pool path. A pool
named rust only works if a storage of that name exists - check with
ssh pve pvesm status.
Test the connection:
just test-connection
Build and create the NAS
The nas profile is LXC-only - it asserts vm.container, so trying
to build it on a KVM backend fails with a friendly message. Build the
rootfs tarball for it, then create the container:
just build nas just create mynas
The wizard opens with a profile picker. On the proxmox-lxc backend the
LXC-only nas profile shows up in the list (while the kernel-bound
nvidia, pipewire, and zram profiles are hidden) - check it and
continue:
Select profile(s) to include: [ ] claude [ ] dev [ ] docker [ ] kubernetes > [x] nas [ ] podman [ ] python [ ] rust [ ] step-ca Selected profile(s): nas Privileged: yes (required by the nas profile for kernel NFS)
From there it prompts for the usual resources (cores, memory, rootfs size), plus the two things specific to this backend:
- Privileged: the
nasprofile forces this toyesautomatically. Kernelnfsddoes not work in an unprivileged container, so the backend runs it privileged and appendslxc.apparmor.profile: unconfinedto the container config. - Host ZFS bind mounts: the wizard introspects the pools and datasets
on your PVE node and lets you pick an existing dataset or create a new
one. The
nasprofile offers a defaultrust/nas:/srv/nasmount. Each selected dataset gets mounted at/srv/<leaf>inside the container.
When it finishes, SSH in:
just ssh mynas
One dataset, three protocols
Here’s the part that makes the nas profile more than a thin wrapper
around exportfs. Every dataset bind-mounted under /srv/<name> becomes
one share named <name>, served simultaneously over three protocols:
- NFS - kernel
nfsd, NFSv4-only (everything over2049/tcp). - Samba - SMB for Windows/macOS/Linux file managers.
- copyparty - a web UI and WebDAV server on port 3923.
You don’t configure these three times. A boot service (nas-shares)
discovers the /srv/* mountpoints at runtime and generates the NFS
exports, the Samba share stanzas, and the copyparty config from the same
source of truth. Add another dataset and you automatically get another
share on all three protocols. The reason it’s a boot-time service rather
than baked into the image: the set of datasets isn’t known when the image
is built (the backend mounts them later), so the image stays generic and
the container figures out what it’s serving on the way up.
Access control
There are two access-control surfaces because the protocols authenticate
differently, and both are driven by plain text files in the machine
config (machines/proxmox-lxc/<host>/<name>/). The create wizard seeds
commented templates for all of them.
Samba and copyparty share one user database and one ACL:
nas_passwd (mode 0600) lists the users, one <user> <password> per
line:
# user password
alice s3cret
bob hunter2
nas_acl (no secrets) grants access, one <user> <share> <access> per
line, where access is r (read-only) or rw:
# user share access
alice * rw # alice: read-write on EVERY share
bob nas r # bob: read-only on share 'nas'
* media r # guest (anonymous): read-only on 'media'
The model is deny-by-default, unconditionally: a user or guest gets
only what an explicit rule grants. No rule means no access - there is
no “open when unconfigured” fallback. r maps to Samba read-only /
copyparty r; rw maps to the Samba write list / copyparty rwmd
(read + write + move + delete). Samba even uses access-based share
enumeration, so users don’t even see the names of shares they can’t
reach.
NFS is separate, because NFS (sec=sys) has no per-user
authentication - the client just asserts its own uid. So NFS access is
host-based and also deny-by-default, in nfs_clients:
10.13.0.0/16 # a LAN subnet, read-write
192.168.1.50 ro # a single host, read-only
With no entries, NFS exports nothing to anyone (Samba and copyparty are
unaffected). A fresh nas ships with an all-commented template, so NFS
is off until you add a CIDR.
The thing that ties all of this together is a single shared owner. Every
NFS client uid (via all_squash), every Samba user (via force user),
and copyparty all map to one unprivileged nas user (uid/gid 1500), and
the share directories are nas:nas mode 2775. So anyone who’s
authorized can read and write every file regardless of their own uid, and
file ownership never causes a surprise permission error. It’s flat,
shared, trusted-LAN semantics - which is what most homelab NAS setups
actually want.
A note on security. nas_passwd holds plaintext passwords (mode 0600)
both on your workstation and inside the container. Samba and copyparty
access is gated per-user, NFS is gated per-host, and everything runs as
the unprivileged nas user. This is fine for a trusted home network. It
is not a design for untrusted multi-tenant use.
Applying changes without recreating
You don’t have to rebuild the container to change users, ACLs, or NFS
clients. Edit the files in machines/proxmox-lxc/<host>/<name>/ and sync:
just sync-identity mynas
That re-injects the files and reloads the services in place. The same
goes for the firewall: the nas profile seeds its ports into the
machine’s tcp_ports/udp_ports at create time - SMB 445, NFSv4
2049, copyparty 3923, WS-Discovery 5357 (tcp); mDNS 5353,
WS-Discovery 3702 (udp). Those files are the single source of truth for
both the in-container NixOS firewall and the Proxmox CT firewall. They
are written once, visible, and editable, so you can delete any port you
don’t want exposed and just sync-identity to apply.
The web UI and WebDAV
copyparty gives the NAS a browser front end and a WebDAV endpoint on port 3923, using the same users and permissions as Samba:
http://<ip>:3923/ # web UI - log in as a nas_passwd user
http://<ip>:3923/<share> # a share; this is also its WebDAV URL
Uploads land as the nas user like everything else, so a file dropped in
through the browser is readable over NFS and Samba immediately, with no
ownership fixups.
Browsing without an IP
You shouldn’t have to memorize the container’s DHCP lease. The nas
profile advertises itself two ways:
- WS-Discovery (
wsdd) - shows up under “Network” in Windows Explorer and in modern Linux file managers. - mDNS (
avahi) - gives you<hostname>.localresolution and shows up in macOS Finder.
Legacy NetBIOS (nmbd) is disabled, because modern SMB2/3 clients ignore
it anyway. Both WSD and mDNS are link-local multicast, so the client has
to be on the same LAN segment. Test by name:
ping nas.local smbclient -L nas.local -N # list shares by name, no IP
Stateless: recreate instead of upgrade
There’s no just upgrade for this backend - it’s intentionally disabled.
The container is disposable, so “upgrading” it means rebuilding it from
your workstation:
just recreate mynas
That builds a fresh rootfs from your current Nix config and re-injects the identity files - effectively a brand new container. It’s safe to run anytime, because the bind-mounted datasets are created if missing but never destroyed on teardown - the data lives on the host’s ZFS pool, not in the container, and outlives any number of recreates. Nothing on the NAS itself is precious; the pool is.
That’s also why the just sync-identity from earlier exists: for the
config that isn’t baked into the image (users, ACLs, NFS clients,
firewall ports) you don’t even need to rebuild - sync re-injects those
files and reloads the services in place.
The rootfs is read-write, so you can ssh in and run sudo nixos-rebuild switch against the seeded /etc/nixos flake for a quick
experiment. But treat anything you change by hand that way as scratch: it
isn’t captured on your workstation and won’t survive the next recreate.
The source of truth is your config repo, not the running container.
Putting it together
From zero to a running NAS serving a host ZFS dataset over NFS, Samba, and the web:
# One-time setup on your workstation: git clone https://github.com/EnigmaCurry/nixos-vm-template ~/nixos-vm-template cd ~/nixos-vm-template cat > .env <<'EOF' BACKEND=proxmox-lxc PVE_HOST=pve PVE_STORAGE=local-zfs PVE_BRIDGE=vmbr0 EOF just test-connection # Build the nas rootfs and create the container just build nas just create mynas # wizard: nas profile, accept the rust/nas:/srv/nas mount, privileged (auto) # Add a user and grant them access, then apply $EDITOR machines/proxmox-lxc/pve/mynas/nas_passwd # alice s3cret $EDITOR machines/proxmox-lxc/pve/mynas/nas_acl # alice * rw $EDITOR machines/proxmox-lxc/pve/mynas/nfs_clients # 10.13.0.0/16 just sync-identity mynas # Mount it from a client mount <ip>:/srv/nas /mnt # NFS smbclient -L nas.local -N # SMB # or open http://<ip>:3923/ in a browser
A KVM VM could never bind-mount that ZFS dataset; a container can, and the
nas profile turns it into a real NAS - three protocols, one set of
users, deny-by-default, declared in Nix and reproducible from your git
repository. The whole thing is the natural payoff of the container
backend: the one place where giving up the immutable root buys you
something a VM simply can’t do.