Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 

Repository files navigation

Apple Time Machine on Linux NAS with ZFS + Samba

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


Table of Contents

  1. How It Works
  2. Prerequisites
  3. ZFS Dataset Setup
  4. Samba Configuration
  5. Avahi mDNS Advertisement
  6. Permissions
  7. ZFS Snapshots
  8. macOS Client Setup
  9. Troubleshooting
  10. Quick Reference

How It Works

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.


Prerequisites

Packages

sudo apt update
sudo apt install samba samba-vfs-modules avahi-daemon

Create a dedicated Samba user per person

Use 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 account

Repeat for each user who needs a Time Machine share.


ZFS Dataset Setup

Create the dataset

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/username

Adjust refquota to the desired per-user backup limit. Adjust the dataset path to match your pool name and user.

Apply to an existing dataset

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/username

Why each property matters

atime=off

Access 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.

xattr=sa (system attributes)

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.

dnodesize=auto

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.

recordsize=1M

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.

compression=lz4

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.

logbias=throughput

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.

sync=disabled

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.

refquota=2T

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.

setuid=off, exec=off, devices=off

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.


Samba Configuration

Critical: vfs_fruit settings must be in [global]

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].

Full /etc/samba/smb.conf

[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.

Add further users

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 = smbotherusername

And add the corresponding entry to the Avahi service file (see below).

Validate and reload

Always validate before reloading — a syntax error prevents smbd from restarting, taking down all shares.

sudo testparm /etc/samba/smb.conf
sudo systemctl reload smbd

Avahi mDNS Advertisement

Without 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.

/etc/avahi/services/samba.service

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 icon
  • sys=0x68,0x68 — flags the server as a Time Machine-capable device
  • adVN=tm-usernamemust 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-daemon

Permissions

The 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/username

Expected 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.


ZFS Snapshots

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.

Strategy: startup + shutdown (not hourly)

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.

Scripts

/usr/local/bin/zfs-startup-snapshot.sh

#!/bin/bash
zfs snapshot -r pool@startup-$(date +%Y%m%d-%H%M%S)

Recursively snapshots the entire pool at boot.

/usr/local/bin/zfs-shutdown-snapshot.sh

#!/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
done

Checks 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.

/usr/local/bin/zfs-snapshot-cleanup.sh

#!/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
done

Retention 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

systemd units

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

/etc/systemd/system/zfs-startup-snapshot.service

[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

/etc/systemd/system/zfs-shutdown-snapshot.service

[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

/etc/systemd/system/zfs-snapshot-cleanup.service

[Unit]
Description=ZFS snapshot cleanup

[Service]
Type=oneshot
ExecStart=/usr/local/bin/zfs-snapshot-cleanup.sh

[Install]
WantedBy=multi-user.target

/etc/systemd/system/zfs-snapshot-cleanup.timer

[Unit]
Description=Run ZFS snapshot cleanup daily

[Timer]
OnCalendar=daily
Persistent=true

[Install]
WantedBy=timers.target

Enable

sudo systemctl daemon-reload
sudo systemctl enable zfs-startup-snapshot.service
sudo systemctl enable zfs-shutdown-snapshot.service
sudo systemctl enable --now zfs-snapshot-cleanup.timer

macOS Client Setup

Auto-discovery (recommended)

With 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.

Manual setup via terminal

# 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 GB

Speed up the first backup

By 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

Troubleshooting

Time Machine cannot find the NAS

# 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-daemon

"Backup disk image could not be accessed"

This 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.

Slow backup performance

# 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

Stuck or frozen backup

# 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>

Snapshot management

# 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

Quick Reference

# 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

Additional References

About

Step-by-step guide to hosting Apple Time Machine backups on Ubuntu using ZFS for storage and Samba for SMB sharing. Covers ZFS tuning for Time Machine workloads, full vfs_fruit configuration, Avahi mDNS auto-discovery, permissions, and startup/shutdown snapshots.

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors