Skip to content

Instantly share code, notes, and snippets.

@cgarrovillo
Forked from jrd404/README.md
Last active August 10, 2026 04:54
Show Gist options
  • Select an option

  • Save cgarrovillo/f6f51dbeb5ee661b1149b6932f45e3ff to your computer and use it in GitHub Desktop.

Select an option

Save cgarrovillo/f6f51dbeb5ee661b1149b6932f45e3ff to your computer and use it in GitHub Desktop.
macOS headless server setup guide

macOS Headless Server Setup

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.

Prerequisites

  • Physical or Screen Sharing access to the Mac during setup
  • SSH access from another machine for post-reboot verification
  • Admin account credentials

1. Enable Remote Access

SSH

sudo systemsetup -setremotelogin on

Verify with sudo systemsetup -getremotelogin.

Harden the SSH Daemon

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
EOF

This 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.conf with its own defaults (PAM, SFTP subsystem, LANG env passthrough). Your headless.conf will 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.

Generate SSH Keys

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"

Authorized Keys

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
EOF

Ensure correct permissions. SSH is strict about this:

chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys

Client Config

On 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

Screen Sharing

Enable via System Settings > General > Sharing > Screen Sharing. This lets you manage the GUI remotely after going headless.

Note: Screen Sharing (screensharingd) will appear in pmset -g as a process preventing sleep. This is expected and desirable; it keeps the machine responsive.

2. Set Hostname

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 m4

HostName 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 -setcomputername also works but only sets ComputerName. Use scutil to control all three independently.

3. Energy Settings

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 0 and SleepDisabled 1 are different settings. sleep 0 sets the idle timer to "never" but macOS can still initiate sleep under other conditions. SleepDisabled 1 (set via sudo pmset -a disablesleep 1) is the hard kill switch. Set both.

Note: autopoweroff and autorestart flags are deprecated on Apple Silicon + macOS Tahoe. The pmset commands will accept them without error, but the settings silently don't apply and won't appear in pmset -g output. Don't rely on them.

Disable Sleep-Holding Apps

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.

4. Auto-Login

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.

Keychain Access Over SSH

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
fi

Note: This stores your keychain password in plaintext on disk. The chmod 600 restricts 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.

5. Allow Accessories

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.

6. Clean Up Login Items

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.

7. Homebrew Cleanup

Identify unnecessary leaves

brew leaves                        # All top-level formulae
brew uses --installed <formula>    # Check if anything depends on a formula
brew deps --installed --tree <formula>  # Show dependency tree

Uninstall in bulk, then clean orphans:

brew uninstall formula1 formula2 ...
brew autoremove
brew cleanup --prune=all

Casks

List installed casks with brew list --cask. Remove desktop apps that aren't needed headless.

Note: If both rust (Homebrew formula) and rustup are installed, remove the Homebrew rust. rustup is the proper toolchain manager; having both causes conflicts.

8. Clean Up LaunchAgents and LaunchDaemons

User agents

ls ~/Library/LaunchAgents/

Remove plists for uninstalled services:

launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.example.plist
rm ~/Library/LaunchAgents/com.example.plist

System daemons

ls /Library/LaunchDaemons/

Same pattern with sudo launchctl bootout system/ and sudo rm.

Note: Always bootout before deleting the plist. Deleting the file alone won't stop a running agent, and launchctl will error on next boot trying to find the missing plist.

9. Firewall

Enable and harden

sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setglobalstate on
sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setstealthmode on

Clean stale exceptions

Over time the firewall accumulates exceptions for apps that have been uninstalled. List them:

sudo /usr/libexec/ApplicationFirewall/socketfilterfw --listapps

Remove exceptions for apps whose binaries no longer exist on disk:

sudo /usr/libexec/ApplicationFirewall/socketfilterfw --remove /path/to/binary

Note: socketfilterfw --remove only 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 off then --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.

10. Remove Leftover Application Support

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 vendorname

Note: 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 -f if needed, but expect them to reappear until reboot clears the cached launch state.

11. Full App Uninstall Procedure

Dragging an app to the Trash only removes the .app bundle. Most apps scatter files across the system. This checklist covers a thorough removal.

Step 1: Quit the app and kill its processes

osascript -e 'tell application "AppName" to quit' 2>/dev/null
pkill -f "AppName"
ps aux | grep -i appname   # Confirm nothing remains

Step 2: Remove the .app bundle

sudo rm -rf "/Applications/AppName.app"
# Also check non-standard locations:
ls ~/Applications/
ls /Applications/Utilities/

Step 3: Unload and remove LaunchAgents / LaunchDaemons

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

Note: Always bootout before deleting the plist. If you delete first, the agent/daemon stays running until reboot and launchctl will throw errors on next boot looking for the missing file.

Step 4: Remove system extensions and plugins

# 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.framework are supposed to auto-uninstall when the parent .app is removed. In practice this doesn't always happen. Check with systemextensionsctl list after removing the app. If a stale extension remains, a reboot usually triggers the cleanup.

Step 5: Remove Application Support and caches

# 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

Step 6: Remove preferences and configuration

# 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 delete clears the in-memory cache of the preference domain. Without this, macOS may still see stale preferences from the cfprefsd cache even after the plist file is deleted.

Step 7: Remove dotfiles and CLI config

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

Step 8: Clean firewall exceptions

sudo /usr/libexec/ApplicationFirewall/socketfilterfw --listapps | grep -i appname
sudo /usr/libexec/ApplicationFirewall/socketfilterfw --remove /path/to/binary

Step 9: Remove login items

osascript -e 'tell application "System Events" to delete login item "AppName"' 2>/dev/null

Verification

After 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: mdfind uses Spotlight and is much faster than find for scanning indexed locations. Use find for 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.

12. Post-Setup Verification

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 LocalHostName

Test Screen Sharing connectivity separately via the macOS Screen Sharing app or a VNC client.

Quick Reference

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment