A production-ready guide for hosting Apple Time Machine backups on a Linux NAS using ZFS for storage and Samba (SMB) for network access. Every setting is explained — not just what to set, but why it matters for backup reliability and restore integrity.
Tested on: Ubuntu 22.04 LTS, ZFS on Linux, Samba 4.x macOS versions: Ventura, Sonoma, Sequoia
- How It Works
- Prerequisites
- ZFS Dataset Setup
- Samba Configuration
- Avahi mDNS Advertisement
- Permissions
- ZFS Snapshots
- macOS Client Setup
- Troubleshooting
- Quick Reference
macOS (Time Machine)
│
│ SMB over TCP (port 445)
│ mDNS auto-discovery (Bonjour/Avahi)
▼
Linux NAS ── smbd with vfs_fruit (Apple SMB2 extensions)
│ └─ avahi-daemon (Bonjour advertisement)
│
▼
ZFS pool (RAIDZ1, mirror, etc.)
└── pool/timemachine/<username>
Time Machine writes a sparse bundle disk image over SMB. The sparse bundle is a directory of fixed-size band files (8 MB each by default), which means Time Machine never rewrites the whole backup — only changed bands. This makes incremental backups fast and ZFS compression effective.
The vfs_fruit Samba module implements Apple's extensions to the SMB2 protocol, ensuring macOS metadata — resource forks, Finder flags, file ACLs, creation timestamps — survives the round trip from Mac to NAS and back. Without it, a restore may silently produce files that look correct but are missing metadata, causing apps to misbehave.
sudo apt update
sudo apt install samba samba-vfs-modules avahi-daemonUse a dedicated system account for each Time Machine user. This keeps backup credentials isolated from shell accounts and prevents a compromised backup password from granting login access.
# Create a system account with no home directory and no login shell
sudo useradd -r -s /usr/sbin/nologin smbusername
# Set the Samba password (separate from the Unix password)
sudo smbpasswd -a smbusername
sudo smbpasswd -e smbusername # enable the accountRepeat for each user who needs a Time Machine share.
sudo zfs create \
-o atime=off \
-o xattr=sa \
-o dnodesize=auto \
-o recordsize=1M \
-o compression=lz4 \
-o logbias=throughput \
-o sync=disabled \
-o refquota=2T \
-o setuid=off \
-o exec=off \
-o devices=off \
pool/timemachine/usernameAdjust refquota to the desired per-user backup limit. Adjust the dataset path to match your pool name and user.
If the dataset already exists, set properties individually. Note that recordsize, xattr, and dnodesize only apply to newly written data — existing data is not rewritten.
sudo zfs set \
xattr=sa \
dnodesize=auto \
recordsize=1M \
logbias=throughput \
sync=disabled \
refquota=2T \
pool/timemachine/usernameAccess time is updated on every file read. With Time Machine's thousands of small files, this turns reads into writes — significant overhead. Disabling it has no effect on backup correctness.
By default (xattr=on), ZFS stores extended attributes as hidden files in a dedicated directory alongside the file. With sa, they are stored inline inside the inode itself.
This matters because the Samba vfs_fruit module stores all macOS metadata — resource forks, Finder info, quarantine flags — as extended attributes. With the default on mode, each piece of metadata requires a separate ZFS block allocation and a directory lookup. With sa, it's a single inode read. More importantly, inline xattrs are less likely to become orphaned or inconsistent during a crash.
ZFS dnodes (the inode equivalent) have a fixed size by default. When xattr=sa stores large extended attributes, they can overflow the default dnode and spill to a separate block anyway — defeating the purpose. auto allows dnodes to grow up to 1 KB to accommodate inline xattrs. Always set this alongside xattr=sa.
Time Machine sparse bundle bands are 8 MB files that get rewritten in large sequential chunks. ZFS's default 128 K record size would split each band into 64 records, creating 64× the metadata overhead per write. A 1 M record size reduces that to 8 records per band, dramatically cutting metadata writes and improving throughput.
lz4 is fast enough that it adds negligible latency while typically achieving 1.05–1.15× compression on Time Machine backups (which are mostly already-compressed app data and media). Worth enabling for the space saving on larger backups.
ZFS uses a ZIL (ZFS Intent Log) to guarantee synchronous write durability. The latency bias (default) optimises for small random writes by routing them through the ZIL. Time Machine writes are large and sequential — throughput tells ZFS to skip the ZIL for these writes and go directly to the main pool, which is more efficient for this workload.
This is the single biggest performance improvement. Time Machine issues an fsync() call after every file write to the server. With sync=standard, each fsync flushes the ZIL to disk synchronously — effectively serialising every write. On a pool without a dedicated NVMe ZIL device (SLOG), this causes severe write stalls.
With sync=disabled, fsync calls return immediately without waiting for disk confirmation. The risk is that a sudden power loss could cause the last writes since the previous snapshot to be lost. This is mitigated by the snapshot strategy described below — and Time Machine is designed to recover gracefully from an interrupted backup by redoing it on the next run.
Caps the dataset at the specified size, excluding snapshots. Always use refquota rather than quota for Time Machine datasets. With quota, snapshot space counts toward the limit — as snapshots accumulate over time, the available space for new backups silently shrinks, eventually causing Time Machine to fail with cryptic "not enough space" errors even though the pool has plenty of room.
Security hardening. Backup data should never be executable or contain device nodes. Disabling these prevents a compromised backup from being used to escalate privileges on the server.
A common mistake is placing vfs objects = fruit streams_xattr only in the Time Machine share definition. This causes intermittent failures because Samba loads VFS modules per-connection, and some Apple protocol negotiation happens before the share is selected. All fruit:* settings must be in [global].
[global]
workgroup = WORKGROUP
server string = HOSTNAME
security = user
map to guest = never
unix extensions = no
guest ok = no
# ── Apple/Time Machine support ─────────────────────────────────────────
# All fruit settings must be in [global] — not per-share.
fruit:aapl = yes
# Enables Apple SMB2 protocol extensions. Required for Time Machine.
fruit:nfs_aces = no
# Prevents Samba from mapping Unix ACLs to NFS ACEs.
# Enabling this causes permission errors on macOS when browsing shares.
fruit:metadata = stream
# Controls where macOS metadata (resource forks, Finder info) is stored.
# "stream" stores it as NTFS alternate data streams inside the share.
# This survives backup and restore cycles intact.
# The alternative "netatalk" stores it as AppleDouble (._file) sidecars
# which are more fragile and can be silently lost during file operations.
fruit:model = TimeCapsule
# Tells macOS the server is a Time Capsule. Affects the icon shown in
# Finder and signals to Time Machine that this is a trusted backup target.
fruit:posix_rename = yes
# Uses atomic POSIX rename instead of Windows-style delete-then-rename.
# Time Machine relies on atomic renames when finalising backup bundles.
# Without this, a crash mid-backup can permanently corrupt the bundle.
fruit:veto_appledouble = no
# Stops Samba from blocking ._sidecar files created by older macOS clients.
# Blocking them causes silent metadata loss on legacy systems.
fruit:wipe_intentionally_left_blank_rfork = yes
fruit:delete_empty_adfiles = yes
# Housekeeping: automatically removes empty resource forks and AppleDouble
# files that macOS leaves behind. Keeps the dataset clean over time.
vfs objects = catia fruit streams_xattr
# catia: maps characters that are valid on macOS/Windows but illegal in
# Unix filenames (e.g. colons in resource fork names)
# fruit: Apple SMB2 extensions
# streams_xattr: stores NTFS alternate data streams as xattrs on disk
ea support = yes
# Enables extended attribute (xattr) support over SMB2.
# Without this, the fruit module cannot read or write macOS metadata —
# all the fruit:* settings above become ineffective.
spotlight = no
# Disables Spotlight indexing integration. Samba's Spotlight support
# interferes with the sparse bundle structure, causing index corruption.
multicast dns register = no
# Prevents smbd from registering itself via mDNS.
# Avahi handles this instead — two competing mDNS registrations cause
# discovery failures on macOS.
# ───────────────────────────────────────────────────────────────────────
min protocol = SMB2
# Disables SMB1, which is insecure and incompatible with Time Machine.
# Logging
log file = /var/log/samba/log.%m
max log size = 1000
logging = file
# Authentication
server role = standalone server
obey pam restrictions = yes
unix password sync = yes
passwd program = /usr/bin/passwd %u
passwd chat = *Enter\snew\s*\spassword:* %n\n *Retype\snew\s*\spassword:* %n\n *password\supdated\ssuccessfully* .
pam password change = yes
usershare allow guests = no
# ── Time Machine share ─────────────────────────────────────────────────────
[tm-username]
comment = Time Machine username
path = /pool/timemachine/username
valid users = smbusername
writable = yes
browseable = yes
fruit:time machine = yes
# Marks this share as a Time Machine destination. Required.
fruit:time machine max size = 2T
# The backup size limit advertised to macOS. Time Machine will warn the
# user before this limit is reached. Set to match the ZFS refquota.
# ── Case sensitivity — critical for restore integrity ──────────────────
# Time Machine sparse bundle internals use GUID-named directories and
# index files that are case-sensitive. If Samba applies case folding,
# it can break the bundle's internal index, causing restores to fail
# with "the backup disk image could not be accessed" — even though the
# data is physically present on disk.
case sensitive = true
default case = lower
preserve case = no
short preserve case = no
# ───────────────────────────────────────────────────────────────────────
create mask = 0660
directory mask = 0770
force group = smbusername
# force group ensures all files written by smbd are group-owned by the
# share's group, keeping permissions consistent regardless of which
# process creates the file.For each additional Time Machine user, add another share block:
[tm-otherusername]
comment = Time Machine otherusername
path = /pool/timemachine/otherusername
valid users = smbotherusername
writable = yes
browseable = yes
fruit:time machine = yes
fruit:time machine max size = 2T
case sensitive = true
default case = lower
preserve case = no
short preserve case = no
create mask = 0660
directory mask = 0770
force group = smbotherusernameAnd add the corresponding entry to the Avahi service file (see below).
Always validate before reloading — a syntax error prevents smbd from restarting, taking down all shares.
sudo testparm /etc/samba/smb.conf
sudo systemctl reload smbdWithout Avahi, macOS cannot auto-discover the NAS as a Time Machine destination. Users would need to manually mount the share in Finder before Time Machine can see it, and some macOS versions refuse to register a manually-added SMB share as a Time Machine target at all.
Avahi implements Apple's Bonjour (mDNS/DNS-SD) protocol on Linux, advertising the NAS to the local network in a way that macOS recognises as a Time Capsule.
sudo mkdir -p /etc/avahi/services
sudo nano /etc/avahi/services/samba.service<?xml version="1.0" standalone='no'?>
<!DOCTYPE service-group SYSTEM "avahi-service.dtd">
<service-group>
<name replace-wildcards="yes">%h</name>
<!-- Advertise SMB file sharing on port 445 -->
<service>
<type>_smb._tcp</type>
<port>445</port>
</service>
<!-- Identify hardware as Time Capsule to macOS -->
<service>
<type>_device-info._tcp</type>
<port>0</port>
<txt-record>model=TimeCapsule8,119</txt-record>
</service>
<!-- Advertise Time Machine volumes — one dk entry per TM share -->
<service>
<type>_adisk._tcp</type>
<port>0</port>
<txt-record>sys=0x68,0x68</txt-record>
<txt-record>dk0=adVN=tm-username,adVF=0x82</txt-record>
<!-- Add more shares as dk1, dk2, etc.:
<txt-record>dk1=adVN=tm-otherusername,adVF=0x82</txt-record>
-->
</service>
</service-group>Key fields:
model=TimeCapsule8,119— the hardware model string macOS uses to show the Time Capsule iconsys=0x68,0x68— flags the server as a Time Machine-capable deviceadVN=tm-username— must exactly match the Samba share name (case-sensitive)adVF=0x82— marks the volume as a Time Machine destination (as opposed to a regular disk)
sudo systemctl enable --now avahi-daemon
sudo systemctl restart avahi-daemonThe Time Machine share directory must be group-writable by the Samba user's group. Root owns the directory itself to prevent the backup user from deleting or renaming it.
sudo chown root:smbusername /pool/timemachine/username
sudo chmod 770 /pool/timemachine/usernameExpected result:
drwxrwx--- 2 root smbusername ... /pool/timemachine/username
The force group = smbusername in smb.conf ensures all files created by smbd inherit the correct group ownership, so backup files remain accessible after restart.
With sync=disabled on the Time Machine datasets, snapshots are the safety net against data loss from unexpected power loss. They also provide point-in-time recovery — you can roll back a dataset to any previous snapshot if a backup becomes corrupted.
For a NAS that is not always powered on, an hourly timer is a poor fit. If the machine is off when the timer fires and Persistent=true is set, all missed runs pile up on the next boot. Instead, snapshots at natural power cycle boundaries work better:
- Startup snapshot — captures the pool state before any new writes begin
- Shutdown snapshot — captures the final state, but only if data was actually written since the last snapshot, avoiding pointless identical snapshots from short sessions
A separate daily cleanup job enforces a retention policy with Persistent=true, so it catches up on the next boot if the machine was off when it was due.
#!/bin/bash
zfs snapshot -r pool@startup-$(date +%Y%m%d-%H%M%S)Recursively snapshots the entire pool at boot.
#!/bin/bash
has_changes() {
local dataset=$1
local written=$(zfs get -H -o value written ${dataset})
local snapshots=$(zfs list -H -t snapshot -o name ${dataset} | wc -l)
if [ "$snapshots" -eq 0 ] || [ "$written" != "0" ] && [ "$written" != "0B" ]; then
return 0 # Changes detected, or no snapshots exist yet
else
return 1 # No changes since last snapshot
fi
}
for dataset in $(zfs list -H -o name -r pool); do
if has_changes ${dataset}; then
zfs snapshot ${dataset}@shutdown-$(date +%Y%m%d-%H%M%S)
echo "Created snapshot for ${dataset}"
else
echo "No changes in ${dataset}, skipping"
fi
doneChecks the written property per dataset before creating a snapshot. ZFS resets written to zero after each snapshot, so this accurately detects whether anything changed in the current session.
#!/bin/bash
get_sorted_snapshots() {
local dataset=$1
local prefix=$2
zfs list -H -t snapshot -o name,creation -S creation ${dataset} | grep "@${prefix}"
}
keep_recent() {
echo "$1" | head -n $2
}
keep_weekly() {
echo "$1" | awk '{print $1, strftime("%Y-%W", $2)}' \
| awk '!seen[$2]++' \
| head -n $2 \
| cut -d' ' -f1
}
keep_monthly() {
echo "$1" | awk '{print $1, strftime("%Y-%m", $2)}' \
| awk '!seen[$2]++' \
| head -n $2 \
| cut -d' ' -f1
}
for dataset in $(zfs list -H -o name -r pool); do
for prefix in "startup-" "shutdown-"; do
snapshots=$(get_sorted_snapshots ${dataset} ${prefix})
to_keep=$(keep_recent "$snapshots" 5)
to_keep+=$'\n'$(keep_weekly "$snapshots" 4)
to_keep+=$'\n'$(keep_monthly "$snapshots" 6)
to_keep=$(echo "$to_keep" | sort -u | grep .)
comm -23 <(echo "$snapshots" | cut -f1) <(echo "$to_keep") \
| xargs -r zfs destroy -v
done
doneRetention policy (applied independently to startup- and shutdown- snapshots across all pool datasets):
| Window | Retained |
|---|---|
| Most recent | 5 snapshots |
| Weekly | 1 per week × 4 weeks |
| Monthly | 1 per month × 6 months |
Make all three scripts executable:
sudo chmod +x /usr/local/bin/zfs-startup-snapshot.sh
sudo chmod +x /usr/local/bin/zfs-shutdown-snapshot.sh
sudo chmod +x /usr/local/bin/zfs-snapshot-cleanup.sh[Unit]
Description=ZFS startup snapshot
After=zfs-mount.service
Requires=zfs-mount.service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/zfs-startup-snapshot.sh
[Install]
WantedBy=multi-user.target[Unit]
Description=ZFS shutdown snapshot
DefaultDependencies=no
Before=shutdown.target reboot.target halt.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/zfs-shutdown-snapshot.sh
TimeoutStartSec=0
[Install]
WantedBy=shutdown.target reboot.target halt.target[Unit]
Description=ZFS snapshot cleanup
[Service]
Type=oneshot
ExecStart=/usr/local/bin/zfs-snapshot-cleanup.sh
[Install]
WantedBy=multi-user.target[Unit]
Description=Run ZFS snapshot cleanup daily
[Timer]
OnCalendar=daily
Persistent=true
[Install]
WantedBy=timers.targetsudo systemctl daemon-reload
sudo systemctl enable zfs-startup-snapshot.service
sudo systemctl enable zfs-shutdown-snapshot.service
sudo systemctl enable --now zfs-snapshot-cleanup.timerWith Avahi running, the NAS appears automatically in System Settings → General → Time Machine → Add Backup Disk. Select the share and authenticate with the Samba credentials you set up earlier.
# Add a Time Machine destination
tmutil setdestination smb://smbusername:PASSWORD@HOSTNAME/tm-username
# Verify
tmutil destinationinfo
# Optional: set a client-side quota as a belt-and-suspenders alongside the server quota
# Get the UUID from destinationinfo output
tmutil setquota <UUID> 1800 # in GBBy default, Time Machine throttles itself as a background process. Disabling this speeds up the initial backup significantly (which can otherwise take days over a network):
# On the Mac — not the server
sudo sysctl -w debug.lowpri_throttle_enabled=0
# Re-enable when the first backup completes
sudo sysctl -w debug.lowpri_throttle_enabled=1# Verify Avahi is advertising correctly
avahi-browse -at | grep -E "smb|adisk|device-info"
# Validate smb.conf
sudo testparm
# Check services are running
sudo systemctl status smbd avahi-daemonThis is almost always a case sensitivity issue with the sparse bundle index, or a xattr mode mismatch. Verify:
# Must be: sa
zfs get xattr pool/timemachine/username
# Must include: case sensitive = yes
sudo testparm -s 2>/dev/null | grep -A 20 "\[tm-"If the bundle is corrupted, delete it from the share and let Time Machine start fresh — the Mac's local snapshots (APFS) remain intact and can still be used for granular file recovery.
# Verify sync is disabled
zfs get sync pool/timemachine/username # expected: disabled
# Verify logbias
zfs get logbias pool/timemachine/username # expected: throughput
# Monitor pool I/O live
zpool iostat pool 2# Check Time Machine logs on the Mac
log show --info --style compact \
--predicate '(subsystem == "com.apple.TimeMachine") && (eventMessage like[cd] "Failed*")' \
--last 6h
# Force a new backup cycle
tmutil stopbackup
tmutil startbackup --auto --rotation --destination <UUID># List all snapshots in the pool
zfs list -t snapshot -r pool
# List only timemachine snapshots
zfs list -t snapshot -r pool/timemachine
# Roll back to a specific snapshot (destructive — discards newer data)
sudo zfs rollback pool/timemachine/username@shutdown-20240115-030000
# Trigger cleanup manually
sudo /usr/local/bin/zfs-snapshot-cleanup.sh# Reload Samba after config changes
sudo systemctl reload smbd
# Restart Avahi after service file changes
sudo systemctl restart avahi-daemon
# Check all ZFS dataset properties at once
zfs get atime,xattr,dnodesize,recordsize,compression,sync,logbias,refquota \
pool/timemachine/username
# Manual snapshot (single dataset)
sudo zfs snapshot pool/timemachine/username@manual-$(date +%Y%m%d)
# Manual snapshot (entire pool, recursive)
sudo zfs snapshot -r pool@manual-$(date +%Y%m%d)
# Pool health
zpool status pool
# Check Avahi is advertising the share
avahi-browse -at | grep adisk- Time Machine on Samba with ZFS — William Brown
- Setup Apple Time Machine Network Drive with Samba on Ubuntu 22.04 — jhnr.ch