Files
nfs/docs/DEPLOYMENT.md
T
petrbalvin a9b8039ef7
Test / test (push) Successful in 2m4s
Release / gates (push) Successful in 2m5s
Release / build (amd64, freebsd) (push) Successful in 1m27s
Release / build (amd64, linux) (push) Successful in 1m22s
Release / build (amd64, netbsd) (push) Successful in 1m19s
Release / build (amd64, openbsd) (push) Successful in 1m20s
Release / build (arm64, darwin) (push) Successful in 1m21s
Release / build (arm64, freebsd) (push) Successful in 1m26s
Release / build (arm64, linux) (push) Successful in 1m25s
Release / build (arm64, netbsd) (push) Successful in 1m31s
Release / build (arm64, openbsd) (push) Successful in 1m27s
Release / build (loong64, linux) (push) Successful in 1m37s
Release / build (riscv64, linux) (push) Successful in 1m21s
Release / release (push) Successful in 40s
feat: full NFSv4.2 server and client in pure Go
Assisted-by: GLM 5.3 Flash
2026-09-21 18:51:17 +02:00

5.2 KiB

Deployment

How nfsd runs in production.

Topology

flowchart LR
    C1[NFS client] --> S[nfsd]
    C2[NFS client] --> S
    S --> Disk[(export tree)]
    S --> State[(state dir)]

One nfsd process serves one exported directory tree to any number of NFSv4.2 clients over the single TCP port 2049. There is no portmapper, no mountd and no separate locking protocol: a client mounts nfs://host:2049/ directly and everything rides the one connection or its successors.

Requirements

  • a Linux host, on amd64, arm64, loong64 or riscv64; the binary is static, no runtime libraries
  • port 2049 free; it is not a privileged port, so the service does not need root
  • the exported directory must exist before start; the service identity needs read access to it, and write access where clients may write
  • a writable state directory, when persistence is on, writable by the service identity alone (mode 0700)
  • write access to the export tree requires one of: the service runs as root, or the service holds CAP_CHOWN and CAP_MKNOD, or the operator accepts the identity behaviour described under Privileges

Build

just build

The binaries land in bin/nfsd and bin/nfs.

Run

bin/nfsd -config /etc/nfsd/nfsd.toml

The configuration file is described in CONFIGURATION.md; it carries the listen address, the export, the state directory, the connection cap, the TLS key pair and the root squash switch. The flags override the file.

Service unit

[Unit]
Description=NFSv4.2 server for one export
After=network-online.target
Wants=network-online.target

[Service]
Type=notify
User=nfsd
Group=nfsd
ExecStart=/usr/local/bin/nfsd -config /etc/nfsd/nfsd.toml
StateDirectory=nfsd
AmbientCapabilities=CAP_CHOWN CAP_MKNOD
CapabilityBoundingSet=CAP_CHOWN CAP_MKNOD
NoNewPrivileges=yes
ProtectSystem=strict
ReadWritePaths=/srv/export
Restart=on-failure

[Install]
WantedBy=multi-user.target

Type=notify is real readiness: the server writes READY=1 to $NOTIFY_SOCKET once the listener is up, and systemd considers the unit started at that moment, not at the fork. StateDirectory=nfsd creates /var/lib/nfsd owned by the service identity; point the configuration's state-dir at it and handles and opens survive a restart inside the grace window.

Privileges

The port needs no privilege, so the unit runs under a dedicated identity and names exactly two capabilities:

  • CAP_CHOWN lets the server hand a freshly created object to the identity the client presented. Without it the object keeps the service identity; the server still answers the client's claim as the owner attribute, but the on disk owner is the service one. This is a deliberate operator decision, documented here and not hidden: serving untrusted clients without CAP_CHOWN changes whose identity new files carry on disk.
  • CAP_MKNOD serves the special objects a CREATE may carry, character and block devices among them. Without it those creations fail.

Neither capability lets the service read a file it could not already reach.

Root squash

Serve untrusted clients with root-squash = true in the export: a client claiming uid 0 acts as nobody (65534), loses the superuser grant, and its objects carry nobody. The default is false, which keeps the trust AUTH_SYS hands to the claim; an operator who controls every client may keep it.

Production configuration

listen = ":2049"
state-dir = "/var/lib/nfsd"
max-connections = 256
log-ops = false

[tls]
cert = "/etc/nfsd/cert.pem"
key = "/etc/nfsd/key.pem"

[[export]]
path = "/srv/export"
root-squash = true

The TLS key pair enables RPC-with-TLS of RFC 9289; a client that skips the upgrade is refused. The certificate comes from the operator's PKI; no credential belongs in this repository or its configuration examples.

Firewall

One port in, no outbound requirement beyond what the clients reach the back channel on: the server calls the client back on the client's connection, so no inbound port per client is needed.

firewall-cmd --permanent --add-port=2049/tcp && firewall-cmd --reload

Upgrade

just build
install -m 755 bin/nfsd /usr/local/bin/nfsd
systemctl restart nfsd

With state-dir set the restart is a recovery, not a loss: the new process loads the handle map and the opens, and clients inside the grace window reclaim with CLAIM_PREVIOUS. Without it, clients re establish their sessions and re open; their mounted trees keep working through the file handles the backend re registers.

Rollback

Reinstall the previous binary and restart; the state directory format has one version so far, so a downgrade reloads the same state. Not rehearsed against a released predecessor yet: rehearse before relying on it.

Monitoring

  • the unit's ready state: systemctl is-active nfsd
  • the operation log, when log-ops is on: every operation, its status and its duration on standard error, journald's journalctl -u nfsd picks it up
  • the connection cap answering refusals shows up as clients reconnecting; a steady refusal rate means the cap or the client count is wrong
  • a healthy idle server logs nothing and holds no CPU: check systemctl status nfsd for a flat memory figure and ss -tnp sport = 2049 for the connected clients