Skip to content

Running an unattended Mac mini fleet: a dedicated worker host, macOS 27 auto-login and deploy settings

How Diffio keeps its Mac mini inference fleet running unattended: a host that runs only the worker, disk cleanup, auto-login on macOS 27 with /etc/kcpassword, full disk access over SSH, Screen Sharing and deploy settings.

Line diagram: prod and dev schedulers hand jobs to four Mac minis, each running one worker

Diffio 4.5 restores speech on a fleet of Mac minis (M4 Pro, 24 GB of unified memory). Each mini runs one worker, which can serve our production environment, our dev environment or both. This post covers the host side of that fleet: keeping each mini a dedicated worker host with free disk, logging it in without a person, the permissions set once in System Settings, and the host and deploy settings that keep it serving through reboots, crashes and failed deploys. How the worker overlaps downloads, encoding and uploads with inference is in Mac mini serving: overlapping I/O with inference, not batching, raised throughput.

Running a Mac mini inference host unattended

  1. Worker only
  2. Console user
  3. Permissions
  4. Survive failures
What each step involves

What this post covers, in order

Worker only
No desktop apps; the worker warns on stray processes, swap and low disk
Console user
Auto-login, with /etc/kcpassword written by hand on macOS 27
Permissions
Full disk access over SSH and Screen Sharing, set in System Settings
Survive failures
LaunchDaemon, KeepAlive, smoke restore and rolling deploys
One mini used as a desktop and research machine had 4.4 GB of swap in use; after the cleanup, 0.5 GB.

A fleet mini runs the worker and nothing else

One of our minis had also served as a desktop and a research machine: it ran the ChatGPT desktop app, started at login by a launch agent, Chrome, a research restore process left from a previous day, and its research checkouts. It had 4.4 GB of swap in use; after the cleanup, 0.5 GB. Any desktop working set competes directly with the model and the running jobs.

Swap in use on one mini (lower is better)

Before the cleanup
4.4 GB
After the cleanup
0.5 GB

The worker checks every minute for processes of its user outside macOS and its own folder, launch agents and daemons other than its own and Tailscale's, and active swapping, and reports any it finds as a warning in our analytics.

Why the worker warns on the swap-out rate, and what the deploy script checks

Swap occupancy was the first signal and proved too coarse, since macOS leaves cold pages in swap long after the pressure that put them there; the worker now warns on the swap-out rate, more than 256 MB written since its previous check. The deploy script refuses a mini that fails the process and launch-item checks, and checks swap only after the old worker has exited, since the old worker's swap is freed with it; if heavy swap remains, it restarts the previous release.

Desktop software comes back on its own: after a reboot, macOS reopened Chrome from the saved login session, and Chrome reinstalled three Google updater launch agents. defaults write com.apple.loginwindow TALLogoutSavesState -bool false stops apps from reopening at login.

The worker sweeps its work and temporary directories at startup, alerts below 20 GB free without refusing jobs, and deploys keep two releases

Each job writes into its own folder under the worker's work directory, removed when the job completes or fails. A crash or power loss mid-job leaves that folder behind, so the worker empties its work directory when it starts, when nothing can be running. Temporary files escaped that rule: one mini's per-user temporary directory held 14 GB of compiled Core ML models (.mlmodelc directories) left by model builds and research runs.

How compiled Core ML models and temporary files stay inside the sweep

Core ML compiles a model package into a temporary location. Our loader copies each compiled model next to its package, so compiling happens once per machine, and it now deletes the temporary copy as soon as it has its own. The worker also points TMPDIR at a folder inside its work directory, for itself and every process it starts, so the startup sweep covers Python's and ffmpeg's temporary files.

A release's code is 6.6 MB, but the weights bundle (the checkpoints, the speech-encoder and codec models, and the converted Core ML and MLX copies) is 24 GB, so a deploy keeps only the new release and the previous one, the rollback target, and deletes weight bundles neither uses. Seeding a bundle from a folder already on the mini uses APFS copy-on-write clones (cp -c), which share blocks until a file changes.

  • 14 GBcompiled Core ML models left in one mini's temporary directory
  • 24 GBa release's weights bundle, against 6.6 MB of code
  • 20 GBfree space below which the worker warns
  • 5 GBfree space below which it reports an error

Below 20 GB free on the work volume (disk_alert_gb in the worker's configuration) the worker reports a warning to our analytics, and below 5 GB an error, repeated while it lasts; a deploy prints the warning and goes ahead. A job that actually runs out of space fails on its own. The worker used to offer no slots below the threshold, which would have taken a mini out of service with 20 GB still free.

The Neural Engine's compiled-model cache, which SSH cannot measure

/Library/Caches/com.apple.aned, where the Neural Engine service keeps the models it has compiled in a folder per macOS build, stays Operation not permitted over SSH even with full disk access and sudo, so du cannot measure it. On one of ours, a minor macOS update that changed the build took used space from about 408 GB to 50 GB, and the worker's first model load afterwards recompiled its Neural Engine graphs into the new build's folder. That cache grows as the Neural Engine compiles new models, so model experiments stay off fleet minis, and the low-disk warning reports a mini before its disk fills.

Without a logged-in user, macOS 27's audiomxd and configd together use more than a CPU core

On a mini running macOS 27.0 (26A428) with no console user, audiomxd used 70–74% of a core and configd 45%, from boot onward. Auto-login for the service user provides a console user, and both processes drop to 0% CPU.

CPU use of one core, macOS 27.0, no console user

audiomxd
70–74%
configd
45%
Both, after auto-login for the service user
0%

A sample of audiomxd shows a tight loop: Bluetooth audio routing (-[BTAudioRoutingRequest updateAudioState:withState:]) tries to start an XPC connection to the console user's session, asks configd for the console user (SCDynamicStoreCopyConsoleUser), finds none, and retries. Its log repeats UpdateAudioState failed to start XPC: kUnexpectedErr (No user logged in). Bluetooth was on, with nothing paired.

What did not stop the loop

Bluetooth cannot be turned off headlessly on macOS 27: with ControllerPowerState set to 0 in /Library/Preferences/com.apple.Bluetooth, Bluetooth is still on after restarting bluetoothd or rebooting, and blueutil -p 0 cannot reach the controller without a GUI session, even as root. launchd restarts a killed audiomxd, which loops again within seconds. Our other mini, on macOS 26.7, has always logged in automatically and never showed the loop.

On macOS 27, auto-login needs /etc/kcpassword written by hand

sudo sysadminctl -autologin set -userName <user> -password - prints SACSetAutoLoginPassword error:22 even with the correct password, then exits 0. It writes autoLoginUser but not /etc/kcpassword, so the mini still boots to the login window.

What auto-login needs, and what the script does

  1. Two piecesautoLoginUser in loginwindow preferences, and /etc/kcpassword
  2. Verifythe script checks the password with dscl -authonly
  3. Writethe password obfuscated with a fixed XOR key, mode 600
  4. Checkafter a reboot, stat -f %Su /dev/console prints the service user
What auto-login needs, and how the script handles the password

Auto-login needs both the autoLoginUser key in /Library/Preferences/com.apple.loginwindow and /etc/kcpassword, which holds the password obfuscated with a fixed XOR key. The script below verifies the password with dscl -authonly and writes both. getpass keeps the password out of shell history; it appears in dscl's arguments only while dscl runs.

#!/usr/bin/env python3
"""Turn on auto-login for USER. Run as: sudo python3 set_autologin.py USER"""
import getpass, os, subprocess, sys

KEY = bytes([0x7D, 0x89, 0x52, 0x23, 0xD2, 0xBC, 0xDD, 0xEA, 0xA3, 0xB9, 0x1F])

user = sys.argv[1]
password = getpass.getpass(f"Password for {user}: ")
if subprocess.run(["dscl", ".", "-authonly", user, password]).returncode != 0:
    sys.exit("wrong password")
data = password.encode() + bytes(12 - len(password.encode()) % 12)  # always at least one terminating zero byte
encoded = bytes(b ^ KEY[i % len(KEY)] for i, b in enumerate(data))
fd = os.open("/etc/kcpassword", os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "wb") as f:
    f.write(encoded)
os.chown("/etc/kcpassword", 0, 0)
os.chmod("/etc/kcpassword", 0o600)
subprocess.run(["defaults", "write", "/Library/Preferences/com.apple.loginwindow",
                "autoLoginUser", user], check=True)

After a reboot, stat -f %Su /dev/console prints the service user.

Full disk access over SSH and Screen Sharing are turned on in the Mac's System Settings, not from the command line

Full disk access is a macOS privacy permission (TCC, Transparency, Consent and Control, whose database System Integrity Protection guards), which the command line cannot grant, even with sudo, and Screen Sharing turned on from the command line could not capture the screen. Both are set once in System Settings on the mini when it is set up; full disk access can also come from an MDM profile.

Setting, in System Settings Why
General > Sharing > the (i) next to Remote Login > Allow full disk access for remote users on. On macOS versions without that switch: Privacy & Security > Full Disk Access > +, then Cmd-Shift-G and /usr/libexec/sshd-keygen-wrapper maintenance runs over SSH, and without it du, ls and the like get Operation not permitted in Desktop, Documents, Downloads, the Trash and other protected folders, even with sudo, so disk use cannot be fully accounted for
General > Sharing > Screen Sharing on; under its (i), Allow access for: Only these users, the account the worker runs as; "VNC viewers may control screen with password" off System Settings, and anything else that needs the desktop, can be reached without going to the mini; clients authenticate with the macOS account
Tailscale SSH: the Remote Login switch does not cover it

On a mini reached through Tailscale SSH, the session runs under tailscaled rather than macOS's sshd, so the Remote Login switch, which grants sshd, does not cover it. Granting full disk access to tailscaled itself (for the Homebrew build, /opt/homebrew/opt/tailscale/bin/tailscaled) is the likely equivalent, but we have not yet confirmed that it opens protected folders over Tailscale SSH.

Why Screen Sharing is not turned on over SSH, and Remote Management stays off

Screen Sharing turned on over SSH (launchctl enable and bootstrap of com.apple.screensharing) opened port 5900, but the client got a black screen, and after the display was woken screensharingd still logged that it could not capture the screen; we turn Screen Sharing on in System Settings instead. While Remote Management is on, the Screen Sharing switch is greyed out as controlled by the Remote Management service. Remote Management grants much broader remote administration, so we turn it off and plain Screen Sharing on.

A black screen, and connecting from Linux

If a client shows a black screen, the mini's display may be asleep; caffeinate -u -t 5 over SSH wakes it. From Linux, Remmina with its VNC plugin (sudo apt install remmina remmina-plugin-vnc) connects to the mini's Tailscale IP and logs in with the macOS account.

An unattended mini needs host and deploy settings that survive reboots, crashes and failed deploys

  • 3600 sExitTimeOut, so a deploy lets running jobs finish
  • 60 sgrace period before the scheduler re-queues a dead worker's jobs
  • 11–16 smodel load before the worker offers any slot
  • About 5.5 minutesthe Neural Engine's first compile on a new machine
Setting Why
The worker is a LaunchDaemon (system domain) with UserName set, not a LaunchAgent it starts at boot and needs no login; MLX, Metal, MPS and the Neural Engine all work in that context
FileVault off, auto-login for the service user, pmset autorestart 1 and sleep 0 an unattended mini must boot after a power loss into a logged-in session without anyone typing a password, and never sleep; auto-login requires FileVault off. Anyone with physical access gets an unencrypted disk and a logged-in session
Spotlight indexing off (sudo mdutil -a -i off) every job writes and deletes audio files under the work directory, and indexing them would spend CPU, memory and disk writes the jobs need
Wi-Fi off on a mini with Ethernet (networksetup -setairportpower en1 off) Ethernet and Wi-Fi on the same subnet gave the mini two default routes; the setting persists across reboots
KeepAlive and ProcessType Interactive in the plist launchd restarts a crashed worker and applies no background resource limits; the scheduler re-queues a dead worker's jobs after a 60 s grace period
PATH in the plist includes /opt/homebrew/bin launchd services and non-login SSH sessions do not get Homebrew's path, and ffmpeg lives there
ExitTimeOut of 3600 s, and SIGTERM drains a deploy's launchctl bootout lets running jobs finish instead of killing them mid-upload
Each remote deploy command prints its own exit status on a marker line Tailscale SSH reported exit status 0 for failed remote commands
The scheduler stores an expected release per worker a rolling deploy updates one mini at a time, and the minis not yet updated stay valid and keep taking jobs
A smoke restore runs after the release symlink switches and before the service restarts; a failure switches back a release that cannot load the model or restore a file never takes jobs
Code ships as a git archive, weights from a bundle on the deploy host the minis hold no GitHub or Hugging Face credentials
The model loads before the worker offers any slot (11–16 s; on a new machine the Neural Engine's first compile of all 4.5 graphs takes about 5.5 minutes) a job never waits on a cold model

Sources

What each source supports
  • Diffio Mac mini serving measurements: the host figures, including swap in use on one mini before and after its cleanup, the compiled Core ML models left in its temporary directory, the release code and weights-bundle sizes, FileVault and pmset settings, the model load time and the Neural Engine's first compile. The macOS 27 audiomxd sample comes from a separate run on the same minis and is not in this file; thresholds and deploy settings come from our worker's code and configuration.
  • Diffio fleet operations, October 21–22, 2026 (not published): one mini's used space before and after a macOS update changed the build, which folders stayed unreadable over SSH with and without full disk access, which process serves an SSH session on each mini, and Screen Sharing enabled over SSH, in the disk and the full disk access and Screen Sharing sections.
  • launchd.plist(5): UserName, KeepAlive, ProcessType, ExitTimeOut and EnvironmentVariables, in the settings table.
  • Core ML: Downloading and compiling a model on the user's device: compiled models land in a temporary location and should be moved to a permanent one, in the startup-sweep section.
  • How to log in automatically to a Mac user account, Apple Support: FileVault disables automatic login, in the settings table.