DEV Community

Animesh Mohanty
Animesh Mohanty

Posted on Originally published at Medium AI-assisted

Turning an Old MacBook into a Home Server

Few months back , I have replaced my old Macbook pro with a tuxedo laptop and created a medium article about it .

Recently I decided to repurpose it into a remote server , so that I could access seamlessly via SSH from my primary linux machine , instead of letting it gather dust.

Here is the step-by-step story of how I configured it, the networking and OS-level hurdles I hit along the way, and how to solve every single one of them.

1. The Networking Decision between Static IPs , WireGuard and Tailscale

My first question was simple: Do I need to buy a static IP address from my ISP to SSH into my MacBook from outside my home network?

Thankfully, the answer is no. While home ISPs assign dynamic public IPs (and frequently lock connections behind CGNAT), you generally have three paths for remote access:

  1. Dynamic DNS (DDNS) + Port Forwarding: Free services like DuckDNS map a domain to your changing IP, but you still have to open port 22 on your home router—exposing your SSH daemon to constant automated brute-force bots.

  2. Raw WireGuard: WireGuard is an extraordinarily fast,open source VPN protocol. However, running raw WireGuard still requires a designated server endpoint, manual UDP port forwarding (51820), and DDNS.

  3. Headscale vs. Tailscale: I briefly looked at Proxmox (which is a bare-metal hypervisor and doesn't solve the networking layer on its own) and Headscale (the open-source, self-hosted control plane for Tailscale). However, Headscale requires an external VPS with a public IP to coordinate handshakes anyway.

The Solution: I opted for Tailscale. It uses WireGuard under the hood as its data plane (meaning traffic is peer-to-peer encrypted), while acting as a zero-config coordination plane that punches through CGNAT and firewalls automatically. Best of all, Tailscale assigns each device a permanent 100.x.y.z IP address tied to its cryptographic node key—meaning my MacBook's virtual IP never changes, no matter where I take my client laptop.

2. Setting Up Remote Login & Hitting the First Roadblocks

With Tailscale installed on both my MacBook and my Linux laptop, I enabled macOS Remote Login via the terminal:

sudo systemsetup -setremotelogin on

Enter fullscreen mode Exit fullscreen mode

Roadblock #1: Pinging the Public IP Fails

Initially, I tried pinging my MacBook’s public IP from my Linux machine to test connectivity and got 100% packet loss.

  • The Cause: Home routers drop incoming WAN ICMP (ping) requests by default, and Tailscale doesn't route over public IPs anyway—it builds an isolated Layer 3 virtual network (utun).

  • The Fix: Always test connectivity against the Tailscale IP (100.x.y.z), not the public IP:

tailscale ping <mac_tailscale_ip>

Enter fullscreen mode Exit fullscreen mode

Roadblock #2: No ED25519 host key is known for <ip> and you have requested strict checking

When I tried to SSH into the Mac's Tailscale IP for the first time, my Linux client immediately rejected the connection.

  • The Cause: My Linux SSH client had StrictHostKeyChecking=yes enforced, refusing to connect to any host whose fingerprint wasn't already recorded in ~/.ssh/known_hosts.

  • The Fix: Override the check to automatically accept new host keys on initial handshake while keeping strict verification for future logins:

ssh -o StrictHostKeyChecking=accept-new <username>@<mac_tailscale_ip>

Enter fullscreen mode Exit fullscreen mode

3. Hardening SSH: Why MAC Filtering Doesn't Work & Enforcing Key-Only Auth

At first, I considered adding MAC address filtering on my firewall for an extra layer of security. However, I quickly realized why that wouldn't work:

  • MAC addresses operate at Layer 2 (Data Link Layer): They do not survive router hops across the internet.

  • Tailscale is a Layer 3 (IP) tunnel: Its virtual utun interface encapsulates raw IP packets without Ethernet frames—meaning MAC addresses don't even exist inside a Tailscale tunnel.

Instead, the gold standard is Passwordless SSH (Ed25519 public-key cryptography) combined with completely disabling password logins.

Roadblock #3: Disabling PasswordAuthentication Isn't Enough on macOS

On standard Linux distros, setting PasswordAuthentication no in sshd_config usually suffices. On macOS, however, OpenSSH falls back to PAM via interactive keyboard prompts (KbdInteractiveAuthentication).

  • The Fix: After copying my Linux public key over (ssh-copy-id), I created a drop-in configuration file at /etc/ssh/sshd_config.d/01-disable-passwords.conf (which survives macOS system updates):
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no

Enter fullscreen mode Exit fullscreen mode

Then, I validated and reloaded the macOS SSH daemon:

sudo sshd -t
sudo launchctl stop com.openssh.sshd
sudo launchctl start com.openssh.sshd

Enter fullscreen mode Exit fullscreen mode

4. Principle of Least Privilege: Isolating a Standard user

Logging in repeatedly using my Mac's primary administrator account felt risky. If an automated script or exposed service were ever compromised, the attacker would have immediate sudo access. I wanted a dedicated, non-admin user (serveruser) exclusively for SSH.

Creating a Standard User via Terminal (dscl)

Rather than clicking through System Settings, you can provision a standard user (Group 20 / staff) directly from the terminal:

sudo dscl . -create /Users/serveruser
sudo dscl . -create /Users/serveruser UserShell /bin/zsh
sudo dscl . -create /Users/serveruser RealName "Server Service Account"
sudo dscl . -create /Users/serveruser UniqueID 502
sudo dscl . -create /Users/serveruser PrimaryGroupID 20
sudo dscl . -create /Users/serveruser NFSHomeDirectory /Users/serveruser
sudo dscl . -passwd /Users/serveruser
sudo createhomedir -c -u serveruser

Enter fullscreen mode Exit fullscreen mode

Roadblock #4: No such key: GroupMembership When Restricting SSH Access

To lock down SSH so only serveruser could log in (and block my admin account), I tried reading the macOS SSH access group:

dscl . -read /Groups/com.apple.access_ssh GroupMembership

Enter fullscreen mode Exit fullscreen mode

Which threw the error: No such key: GroupMembership.

  • The Cause: When Remote Login is enabled globally, macOS doesn't initialize the GroupMembership key until individual users are explicitly added via dseditgroup.

  • The Fix: Using dseditgroup automatically initializes the key and adds serveruser:

sudo dseditgroup -o edit -a serveruser -t user com.apple.access_ssh
sudo dseditgroup -o edit -d <admin_user> -t user com.apple.access_ssh

Enter fullscreen mode Exit fullscreen mode

For defense-in-depth, I also created /etc/ssh/sshd_config.d/02-restrict-users.conf:

AllowUsers serveruser

Enter fullscreen mode Exit fullscreen mode

Granting Scoped sudo Privileges

While serveruser shouldn't have full root privileges, it occasionally needs to reload firewall rules or adjust power settings. By creating /etc/sudoers.d/serveruser via sudo visudo -f /etc/sudoers.d/serveruser, I granted scoped, passwordless execution to specific binary paths only:

serveruser ALL=(root) NOPASSWD: /sbin/pfctl -f /etc/pf.conf, /sbin/pfctl -e, /sbin/pfctl -d
serveruser ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep *

Enter fullscreen mode Exit fullscreen mode

5. Locking Down Ports with the macOS Packet Filter (pf)

The built-in macOS Application Firewall in System Settings only blocks or allows applications globally—it cannot filter by source IP or port. To restrict incoming connections at the network packet level, macOS includes BSD's Packet Filter (pf).

Because my Linux laptop's Tailscale IP (100.x.y.z) never changes, I created a custom anchor at /etc/pf.anchors/custom.rules:

pass quick on lo0 all
pass out all keep state

# Allow SSH (and future custom app ports) strictly from my Linux Tailscale IP
pass in quick proto tcp from <linux_tailscale_ip> to any port 22 keep state

# Block all other incoming SSH attempts
block in quick proto tcp from any to any port 22

Enter fullscreen mode Exit fullscreen mode

After referencing custom.rules inside /etc/pf.conf, I enabled it with sudo pfctl -ef /etc/pf.conf. If I ever spin up a web app on port 8080, Tailscale allows all ports between my devices by default—I simply add port 8080 to my pf anchor (or manage it centrally via Tailscale ACLs).

6. Preventing Sleep Mode from Dropping Connections

Roadblock #5: macOS Drops SSH When the Screen Sleeps or the Lid Closes

Even with everything configured, my SSH session disconnected after a few minutes whenever the Mac went idle or I closed the lid.

  • The Cause: macOS aggressively powers down network interfaces during idle sleep, and closing the lid triggers immediate system suspension unless an external display is attached.

  • The Fix: Use pmset to disable system sleep on AC power while allowing the display backlight to turn off after 5 minutes:

# Never sleep the system when plugged into AC power
sudo pmset -c sleep 0

# Turn off the screen after 5 minutes to save energy
sudo pmset -c displaysleep 5

# Keep network connections alive
sudo pmset -c tcpkeepalive 1
sudo pmset -c womp 1

Enter fullscreen mode Exit fullscreen mode

And if you want to close the lid completely without attaching a dummy HDMI plug, override the lid switch:

sudo pmset -a disablesleep 1

Enter fullscreen mode Exit fullscreen mode

7. The Final Boss: Running Docker Remotely Over SSH

Unlike Linux—where dockerd runs natively as a root systemd service—Docker on macOS runs inside a lightweight Linux VM managed by Docker Desktop, a graphical application. This created two major headaches over SSH.

Roadblock #6: open -a Docker Fails with Domain does not support specified action

When I tried launching Docker Desktop from my remote SSH terminal using open -a Docker, macOS threw a LaunchServices error.

  • The Cause: SSH sessions live in a headless background bootstrap domain, whereas open requires an active graphical WindowServer (Aqua) session.

  • The Fix: Route the launch command into the active GUI user's bootstrap context using their UID (typically 501), or launch the binary directly in unattended mode:

# Find the active GUI user's UID
stat -f "%u" /dev/console

# Execute inside the GUI user's session
sudo launchctl asuser 501 open -a "Docker"

Enter fullscreen mode Exit fullscreen mode

Roadblock #7: Docker Desktop is Running, But docker info Over SSH Says It Isn't

Even after starting Docker Desktop manually on the Mac, running docker info or docker ps over SSH as serveruser returned: Cannot connect to the Docker daemon... Is the docker daemon running?

  • The Cause: Modern Docker Desktop creates its UNIX socket inside the home directory of the GUI user who launched it (/Users/<admin_user>/.docker/run/docker.sock) rather than exclusively using /var/run/docker.sock. When serveruser runs docker info, the CLI looks in /Users/serveruser/.docker/run/docker.sock, finds nothing, and fails.

  • The Fix: Locate the active socket, grant read/write permissions so serveruser can communicate with it, and export DOCKER_HOST in serveruser's ~/.zshrc:

# 1. Grant access to the socket path (run as admin)
sudo chmod 755 /Users/<admin_user>/.docker /Users/<admin_user>/.docker/run
sudo chmod 666 /Users/<admin_user>/.docker/run/docker.sock

# 2. Point serveruser's Docker CLI to the active socket
echo 'export DOCKER_HOST="unix:///Users/<admin_user>/.docker/run/docker.sock"' >> ~/.zshrc
source ~/.zshrc

Enter fullscreen mode Exit fullscreen mode

Conclusion

What started as a simple attempt to SSH into an old MacBook turned into a deep dive into macOS internals—covering launchd bootstrap domains, BSD pf firewalls, dscl directory services, pmset power assertions, and UNIX domain sockets.

Today, my old MacBook sits quietly on a shelf: screen off, unreachable from the public internet, locked down to a single non-admin user via Ed25519 keys, and running Docker containers smoothly whenever I connect over Tailscale from my Linux laptop.

Top comments (0)