Guide for converting a Mac into a headless always-on server. Written against a MacBook Air M4 running macOS Tahoe (26.x), but most steps apply to any Apple Silicon Mac.
- Physical or Screen Sharing access to the Mac during setup
- SSH access from another machine for post-reboot verification
- Admin account credentials
sudo systemsetup -setremotelogin onVerify with sudo systemsetup -getremotelogin.
macOS ships with a default sshd_config at /etc/ssh/sshd_config that includes drop-in configs
from /etc/ssh/sshd_config.d/. Don't edit the main config directly. Create a drop-in file
instead:
sudo tee /etc/ssh/sshd_config.d/headless.conf <<'EOF'
PermitRootLogin no
AllowUsers jarrodchung
PasswordAuthentication no
EOFThis does three things:
- PermitRootLogin no: blocks root login entirely (not even with keys)
- AllowUsers: whitelist only your user account; all other usernames are rejected
- PasswordAuthentication no: forces key-based auth; password brute-force is impossible
Note: macOS also creates a
/etc/ssh/sshd_config.d/100-macos.confwith its own defaults (PAM, SFTP subsystem, LANG env passthrough). Yourheadless.confwill load alongside it. Options that appear multiple times use the first value, so if there's a conflict, whichever file sorts first alphabetically wins. Name your file accordingly.
Note: After editing sshd config, restart the daemon with
sudo launchctl kickstart -k system/com.openssh.sshd. Keep your current SSH session open while testing from a second terminal. If the config is broken, you'll still have a way in.
Use Ed25519 (faster, shorter, more secure than RSA):
ssh-keygen -t ed25519 -C "you@hostname"This creates ~/.ssh/id_ed25519 (private) and ~/.ssh/id_ed25519.pub (public). You can also create
named keys for specific purposes:
ssh-keygen -t ed25519 -f ~/.ssh/myserver -C "you@myserver"On the server, add public keys (one per line) to ~/.ssh/authorized_keys:
# From the client machine:
ssh-copy-id -i ~/.ssh/id_ed25519.pub user@server.local
# Or manually on the server:
cat >> ~/.ssh/authorized_keys <<'EOF'
ssh-ed25519 AAAA... you@clientmachine
EOFEnsure correct permissions. SSH is strict about this:
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keysOn machines that SSH into the server, create ~/.ssh/config to avoid typing full connection
details:
Host m4
HostName m4.local
User jarrodchung
IdentityFile ~/.ssh/m4
IdentitiesOnly yes
Then connect with just ssh m4. Key fields:
- HostName: the actual address (FQDN, Bonjour
.local, or IP) - IdentityFile: which private key to offer (avoids sending all keys)
- IdentitiesOnly yes: only use the specified key, don't try others from the agent
For multiple servers, add a block per host. You can also use wildcards:
Host *.local
User jarrodchung
IdentityFile ~/.ssh/m4
IdentitiesOnly yes
Enable via System Settings > General > Sharing > Screen Sharing. This lets you manage the GUI remotely after going headless.
Note: Screen Sharing (
screensharingd) will appear inpmset -gas a process preventing sleep. This is expected and desirable; it keeps the machine responsive.
macOS has three hostname values that should be set consistently:
sudo scutil --set HostName m4.local
sudo scutil --set LocalHostName m4
sudo scutil --set ComputerName m4HostName is the FQDN used by SSH and DNS. LocalHostName is the Bonjour name (.local).
ComputerName is what appears in Finder and System Settings.
Note:
sudo systemsetup -setcomputernamealso works but only setsComputerName. Usescutilto control all three independently.
The goal is: never sleep, survive power loss, stay network-accessible.
sudo pmset -a sleep 0 # Never sleep
sudo pmset -a disablesleep 1 # Set SleepDisabled flag
sudo pmset -a standby 0 # Don't enter standby
sudo pmset -a hibernatemode 0 # Don't hibernate
sudo pmset -a disksleep 0 # Don't sleep the disk
sudo pmset -a displaysleep 0 # Don't sleep the display (set higher if preferred)
sudo pmset -a womp 1 # Wake on LAN
sudo pmset -a tcpkeepalive 1 # Maintain network connections during sleep
sudo pmset -a powernap 0 # No Power Nap (wakes unpredictably)Verify with pmset -g. The key fields to check:
SleepDisabled 1
sleep 0
standby 0
hibernatemode 0
womp 1
tcpkeepalive 1
Note:
sleep 0andSleepDisabled 1are different settings.sleep 0sets the idle timer to "never" but macOS can still initiate sleep under other conditions.SleepDisabled 1(set viasudo pmset -a disablesleep 1) is the hard kill switch. Set both.
Note:
autopoweroffandautorestartflags are deprecated on Apple Silicon + macOS Tahoe. Thepmsetcommands will accept them without error, but the settings silently don't apply and won't appear inpmset -goutput. Don't rely on them.
If apps like Amphetamine are managing sleep, quit them. pmset settings are now handling it
natively:
osascript -e 'tell application "Amphetamine" to quit'Check for sleep assertions with pmset -g. Processes listed after "sleep prevented by" are holding
wake locks.
Enable auto-login so the machine reaches the desktop after a reboot without keyboard/mouse input:
System Settings > Users & Groups > Automatic Login and select your user account.
Note: FileVault must be off for auto-login to work. If FileVault is on, the machine will hang at the pre-boot unlock screen after a reboot with no one present.
Even with auto-login enabled, the login keychain is not automatically unlocked for SSH sessions. CLI
tools that use the keychain for auth (e.g., gcloud, gh, npm with registry tokens) will fail
with credential errors.
To auto-unlock the keychain on SSH login, store your keychain password in a file and source it from
.bashrc:
# Create the secret file (chmod 600)
echo "your-keychain-password" > ~/.keychain_secret
chmod 600 ~/.keychain_secret
# Add to ~/.bashrc
if [ -n "$SSH_CONNECTION" ]; then
security unlock-keychain -p "$(cat ~/.keychain_secret)" ~/Library/Keychains/login.keychain-db 2>/dev/null
fiNote: This stores your keychain password in plaintext on disk. The
chmod 600restricts it to your user, but anyone with root or physical access can read it. This is acceptable when SSH is hardened with key-only auth (PasswordAuthentication no), a user whitelist (AllowUsers), and no root login, so an attacker would need to compromise a specific authorized private key to reach the file. Without those controls in place, don't do this.
Set System Settings > Privacy & Security > Allow accessories to connect to Always. Without this, macOS blocks new USB/Thunderbolt devices until approved through the GUI, so any device plugged in while managing the machine over SSH won't be recognized.
Remove GUI apps that auto-launch but aren't needed for server operation:
osascript -e 'tell application "System Events" to delete login item "App Name"'List current login items:
osascript -e 'tell application "System Events" to get the name of every login item'Keep items that are part of the server stack (e.g., OrbStack for Docker).
Note: If you still access this machine via Screen Sharing, think twice before removing GUI apps. A headless server can still double as a remote desktop. Only remove apps you're sure you won't need through the GUI.
brew leaves # All top-level formulae
brew uses --installed <formula> # Check if anything depends on a formula
brew deps --installed --tree <formula> # Show dependency treeUninstall in bulk, then clean orphans:
brew uninstall formula1 formula2 ...
brew autoremove
brew cleanup --prune=allList installed casks with brew list --cask. Remove desktop apps that aren't needed headless.
Note: If both
rust(Homebrew formula) andrustupare installed, remove the Homebrewrust.rustupis the proper toolchain manager; having both causes conflicts.
ls ~/Library/LaunchAgents/Remove plists for uninstalled services:
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.example.plist
rm ~/Library/LaunchAgents/com.example.plistls /Library/LaunchDaemons/Same pattern with sudo launchctl bootout system/ and sudo rm.
Note: Always
bootoutbefore deleting the plist. Deleting the file alone won't stop a running agent, andlaunchctlwill error on next boot trying to find the missing plist.
sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setglobalstate on
sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setstealthmode onOver time the firewall accumulates exceptions for apps that have been uninstalled. List them:
sudo /usr/libexec/ApplicationFirewall/socketfilterfw --listappsRemove exceptions for apps whose binaries no longer exist on disk:
sudo /usr/libexec/ApplicationFirewall/socketfilterfw --remove /path/to/binaryNote:
socketfilterfw --removeonly works with file paths. The firewall also stores exceptions by bundle ID (e.g.,com.example.app), and those cannot be removed individually. The only way to clear bundle-ID entries is a full firewall reset (--setglobalstate offthen--setglobalstate on, or resetting via System Settings), after which you must re-add your legitimate exceptions (e.g.,sshd-auth).
Note: App Translocation entries (paths under
/private/var/folders/.../AppTranslocation/) are always stale. They're temporary paths macOS creates when running unsigned apps from Downloads. Safe to remove unconditionally.
After uninstalling apps, their support files often remain:
ls /Library/Application\ Support/
ls ~/Library/Application\ Support/Check for running processes from the vendor before deleting:
ps aux | grep -i vendornameNote: Some vendor software (e.g., Logitech LogiOptionsPlus) spawns background agents that survive even after the application support directory is deleted. These persist until the next reboot. Kill them with
pkill -fif needed, but expect them to reappear until reboot clears the cached launch state.
Dragging an app to the Trash only removes the .app bundle. Most apps scatter files across the
system. This checklist covers a thorough removal.
osascript -e 'tell application "AppName" to quit' 2>/dev/null
pkill -f "AppName"
ps aux | grep -i appname # Confirm nothing remainssudo rm -rf "/Applications/AppName.app"
# Also check non-standard locations:
ls ~/Applications/
ls /Applications/Utilities/# User agents
ls ~/Library/LaunchAgents/ | grep -i appname
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.appname.plist
rm ~/Library/LaunchAgents/com.appname.plist
# System agents (rare, but some apps install here)
ls /Library/LaunchAgents/ | grep -i appname
sudo launchctl bootout system/ /Library/LaunchAgents/com.appname.plist
sudo rm /Library/LaunchAgents/com.appname.plist
# System daemons
ls /Library/LaunchDaemons/ | grep -i appname
sudo launchctl bootout system/ /Library/LaunchDaemons/com.appname.plist
sudo rm /Library/LaunchDaemons/com.appname.plistNote: Always
bootoutbefore deleting the plist. If you delete first, the agent/daemon stays running until reboot andlaunchctlwill throw errors on next boot looking for the missing file.
# System extensions (kernel extensions are deprecated; modern apps use system extensions)
systemextensionsctl list # See what's installed
# Uninstall is typically triggered by removing the app, but verify they're gone
# Login items (background services registered via the modern API)
sfltool dumpbtm | grep -i appname # List managed login items
# Preference panes
ls /Library/PreferencePanes/ | grep -i appname
ls ~/Library/PreferencePanes/ | grep -i appname
sudo rm -rf "/Library/PreferencePanes/AppName.prefPane"Note: System extensions installed via
SystemExtensions.frameworkare supposed to auto-uninstall when the parent.appis removed. In practice this doesn't always happen. Check withsystemextensionsctl listafter removing the app. If a stale extension remains, a reboot usually triggers the cleanup.
# Application Support
rm -rf ~/Library/Application\ Support/AppName
sudo rm -rf /Library/Application\ Support/AppName
# Caches
rm -rf ~/Library/Caches/com.appname.*
rm -rf ~/Library/Caches/AppName
# Saved state
rm -rf ~/Library/Saved\ Application\ State/com.appname.savedState
# Logs
rm -rf ~/Library/Logs/AppName# Preferences (plist files)
rm -f ~/Library/Preferences/com.appname.plist
rm -f ~/Library/Preferences/com.appname.*.plist
# Preference caches (macOS aggressively caches these)
defaults delete com.appname 2>/dev/null
# Containers (sandboxed apps)
rm -rf ~/Library/Containers/com.appname
rm -rf ~/Library/Group\ Containers/*appname*Note:
defaults deleteclears the in-memory cache of the preference domain. Without this, macOS may still see stale preferences from thecfprefsdcache even after the plist file is deleted.
Some apps create config in the home directory or /etc:
# Common patterns
rm -rf ~/.appname
rm -rf ~/.config/appname
rm -f ~/.appnamerc
# System-wide config
sudo rm -rf /etc/appname
sudo rm -rf /usr/local/etc/appnamesudo /usr/libexec/ApplicationFirewall/socketfilterfw --listapps | grep -i appname
sudo /usr/libexec/ApplicationFirewall/socketfilterfw --remove /path/to/binaryosascript -e 'tell application "System Events" to delete login item "AppName"' 2>/dev/nullAfter completing all steps, search for any remaining traces:
# Broad search for leftover files (replace with actual bundle ID / app name)
sudo find / -iname "*appname*" -not -path "*/Trash/*" 2>/dev/null
mdfind "appname" -onlyin ~/Library/ -onlyin /Library/Note:
mdfinduses Spotlight and is much faster thanfindfor scanning indexed locations. Usefindfor paths Spotlight doesn't index (e.g.,/usr/local, dotfiles). Together they give full coverage.
Note: Some apps (especially vendor/enterprise tools like Logitech, Adobe, antivirus software) install a dedicated uninstaller. Check
/Library/Application Support/AppName/or the vendor's website before doing manual removal. The uninstaller may handle system extensions and kernel-level cleanup that's difficult to replicate manually.
Reboot the machine, then verify from another host:
# SSH access
ssh user@hostname.local
# From the server itself (via SSH):
pmset -g # Energy settings survived reboot
osascript -e 'tell application "System Events" to get the name of every login item'
ls ~/Library/LaunchAgents/ # Only expected agents
brew leaves | wc -l # Formula count is correct
sudo /usr/libexec/ApplicationFirewall/socketfilterfw --listapps | head -1
scutil --get HostName && scutil --get ComputerName && scutil --get LocalHostNameTest Screen Sharing connectivity separately via the macOS Screen Sharing app or a VNC client.
| Setting | Command | Verify |
|---|---|---|
| SSH on | sudo systemsetup -setremotelogin on |
sudo systemsetup -getremotelogin |
| Sleep disabled | sudo pmset -a sleep 0 && sudo pmset -a disablesleep 1 |
pmset -g |
| Wake on LAN | sudo pmset -a womp 1 |
pmset -g |
| Firewall + stealth | socketfilterfw --setglobalstate on --setstealthmode on |
socketfilterfw --getglobalstate |
| Hostname | sudo scutil --set HostName <fqdn> |
scutil --get HostName |
| Auto-login | System Settings UI | Reboot test |