Time Machine backups on a Samba share in Docker

How do I back up a Mac to a Samba share with Time Machine, and know when it silently stops?

I found out my Time Machine backups were broken right around my upgrade to macOS 27: every attempt was failing. That one is on me. I set the backup up in April, saw the first one complete, and never tested it again.

My homelab server is also my NAS, so the Mac backs up to it over the LAN. Samba runs in Docker on that server and exports a Time Machine share that lives on a USB disk. This post covers the share, what it took for the Mac to find it, the two faults that stopped it in September, and the alerts I run now.

The share

The image is ghcr.io/servercontainers/samba. It runs Samba, Avahi and wsdd2 in one container and builds smb.conf from environment variables. A Time Machine share needs the fruit VFS module, which speaks Apple’s SMB extensions, stacked with streams_xattr, plus fruit:time machine = yes to advertise it as a backup target.

These are the lines of the compose file that matter for Time Machine:

roles/samba/templates/docker-compose.yml.j2
services:
samba:
image: ghcr.io/servercontainers/samba:latest
environment:
MODEL: "TimeCapsule"
SAMBA_GLOBAL_CONFIG_interfaces: "wan0"
SAMBA_GLOBAL_CONFIG_bind_SPACE_interfaces_SPACE_only: "yes"
SAMBA_VOLUME_CONFIG_timemachine: |
[TimeMachine]
path = /shares/timemachine
read only = no
valid users = {{ samba_user }}
vfs objects = fruit streams_xattr
fruit:time machine = yes
fruit:time machine max size = {{ samba_timemachine_max_size }}
cap_add:
- NET_ADMIN
- NET_RAW
volumes:
- "{{ samba_timemachine_path }}:/shares/timemachine"
- "{{ samba_config_dir }}/avahi-daemon.conf:/etc/avahi/avahi-daemon.conf:ro"
network_mode: host

Two details of the image cost me a fix each. Option names with a space are written with _SPACE_: my first version used double underscores, and the day after I corrected them. And network_mode: host is not optional: Avahi and wsdd2 announce the share with multicast on the LAN, and from a bridged container nobody hears them. MODEL is only the model Avahi announces, which decides the icon Finder shows.

Making the Mac find it

Samba was up on March 30, but the Mac listed the share only on April 18. Three problems, in the order I hit them:

  1. Avahi was off. The image disables it when AVAHI_DISABLE is set, whatever the value. I had the variable in the compose file, so mDNS never started, and without mDNS the share never shows up in Time Machine’s list of disks.
  2. The dbus socket. To get Avahi running I bind-mounted the host’s /var/run/dbus into the container. Avahi started, the Mac found the share on April 18, and the first full backup ran that same day. Five days later something else on the host broke: hostnamectl failed, and so did the Ansible tasks that go through dbus. Sharing that directory had let the container delete the host’s system bus socket.
  3. Docker interfaces. With host networking Avahi sees every veth interface Docker creates and removes. Every new container brought a new interface, Avahi reconfigured itself each time, and the announcement on the LAN never settled.

The fix for the last two is a small avahi-daemon.conf, mounted over the image’s own:

roles/samba/templates/avahi-daemon.conf.j2
[server]
use-ipv4=yes
use-ipv6=yes
allow-interfaces={{ router_lan_nic | default('wan0') }}
enable-dbus=no
ratelimit-interval-usec=1000000
ratelimit-burst=1000
[publish]
publish-hinfo=no
publish-workstation=no

allow-interfaces pins Avahi to the LAN interface, enable-dbus=no lets it run without the host’s dbus. The two capabilities in the compose file belong to wsdd2, the Windows discovery daemon: without NET_ADMIN and NET_RAW it cannot set up its multicast sockets.

What broke in September

DateWhat happened
2026-04-18First full backup. macOS verifies the new bundle.
April to SeptemberHourly incrementals succeed.
2026-09-14, 04:03 UTCLast successful backup.
2026-09-15I notice that every backup fails.

backupd stayed in PreparingSourceVolumes: no CPU use, nothing in the logs. Two separate problems were behind it.

The verification came due. macOS re-verifies network backups now and then. The last verification of my bundle dated from April 18, almost five months earlier, and the bundle held 720G of history. Running that check over SMB, on a USB disk with shingled (SMR) recording, never finished, and no backup could start until it did.

The full copy did not fit. The bundle held 720 GiB and the cap was 1 TiB, which left the Mac about 326 GB of free space (macOS counts in decimal units). The full copy backupd had planned was 413 GB. Instead of copying, it kept deleting old snapshots to make room, at about 700 KB/s on that disk, and the copy never started.

One wrong turn is worth telling. When I wrote the monitoring, I first read VerificationState = 1 in the bundle’s MachineID.plist as “this bundle is damaged” and planned to alert on it. Then the collector ran on the real share, and the bundle created that morning reported 1 too. What separates a healthy bundle from mine is the date of the last verification, not the flag. The check on the empty replacement bundle finished in 25 seconds. So the old bundle was intact. At 720G, verifying it over SMB on this disk never finished.

Raising the cap

The cap does not live in the bundle. Samba uses it to report a smaller disk to the Mac (the vfs_fruit man page says it “limits the reported disksize”), so changing it needs no work on the bundle: the Mac reads the new size the next time it mounts the share.

inventory/group_vars/all/common.yml
samba_timemachine_max_size: 1T
samba_timemachine_max_size: 2T

I stopped at 2T because of the disk: it had 1.9T free with the bundle already taking 720G, so 2T is a cap the disk can actually honour. The man page also warns that the cap is an estimate built from the bundle’s band files only. Anything else stored on the same disk is invisible to it, so keep the cap below the space the disk really has.

I deleted the old bundle and let the Mac start a new one.

Alerts that would have caught it

Everything else on the server had monitoring. The Time Machine share had none. Now a systemd timer runs a Python collector every five minutes:

roles/host-jobs/templates/timemachine-metrics.timer.j2
[Unit]
Description=Refresh Time Machine backup metrics every 5 minutes
[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
AccuracySec=30s
[Install]
WantedBy=timers.target

The collector reads the plists inside each sparsebundle and writes a file for node-exporter’s textfile collector. The completion dates of the snapshots sit at different key paths depending on the macOS release, so it walks the whole plist instead of trusting one path:

roles/host-jobs/templates/timemachine-metrics.py.j2
def collect_dates(node, out):
if isinstance(node, dict):
for key, value in node.items():
if key.endswith("SnapshotCompletionDate") and hasattr(value, "timestamp"):
out.append(value.timestamp())
else:
collect_dates(value, out)
elif isinstance(node, list):
for item in node:
collect_dates(item, out)

The bundle size comes from st_blocks, not st_size: band files are sparse, and their size would overcount.

One trap in this script. Ansible renders it through Jinja before copying it to the server, to fill in the share path and the cap. Two opening braces in a row, anywhere in the file and even in a comment, start a Jinja expression and break the deploy, and doubled braces are what str.format uses to print a literal brace. The script sticks to %-formatting, and a test rejects any {{ other than the variables Ansible is meant to fill.

Prometheus turns the metrics into six alerts, each aimed at one of the ways this went wrong:

AlertFires whenWhat it would have caught
TimeMachineBackupMissedno completed snapshot for 48 hoursthe September outage, without me finding it by hand
TimeMachineNeverCompleteda bundle holds no snapshot for 24 hoursa first backup that never finishes
TimeMachineVerificationOverduethe last verification is older than 90 daysthe real cause, about two months before it stalled anything
TimeMachineBundleNearCapthe bundle passes 70% of the capthe 720G-of-1T situation
TimeMachineNoBundlethe share holds no bundle at allan empty share
TimeMachineMetricsStalethe collector’s last run is over 30 minutes old, for another 30 minutesa collector that stopped, leaving numbers that look fine forever

The last one matters more than it looks. The collector replaces its file atomically, and node-exporter keeps serving the last file it finds, so a stopped timer makes nothing disappear: the numbers freeze. The rules built on time() still fire on a frozen timestamp, but a frozen size or snapshot count looks healthy forever. The freshness check catches that case, and absent() covers a file that was never written:

roles/prometheus/templates/alerts.yml.j2
- alert: TimeMachineVerificationOverdue
expr: time() - timemachine_verification_timestamp_seconds > 7776000
for: 12h
labels:
severity: warning
- alert: TimeMachineMetricsStale
expr: absent(timemachine_collector_last_run_timestamp_seconds) or time() - timemachine_collector_last_run_timestamp_seconds > 1800
for: 30m
labels:
severity: warning

The test I skipped in April

I recently formatted the Mac and restored it from this share, and it went smoothly. That restore is the test I should have run in April, right after the first backup completed. The alerts tell me when backups stop. Only a restore tells me they are usable.