← All Skills

Mac Mini Server

v1.0.2 Infrastructure
3 files, 31.8 KB ~2,514 words · 11 min read Updated 2026-03-26

Turn a Mac Mini into a headless dev server. Tailscale VPN, launchd services, Docker, SSH multiplexing, browser automation.

$ npx snappy-skills install mac-mini-server
zip ↓
Documents
SKILL.md
16.8 KB

Mac Mini as a Headless Dev Server#

Why a Mac Mini#

A Mac Mini sitting on your desk or in a closet gives you:

  • A 24/7 server for crons, automations, and background jobs
  • Native macOS for browser automation (no Linux font rendering issues)
  • Docker Desktop for containerized services
  • Tailscale for secure remote access from anywhere
  • SSH for command-line access from any machine on your network

Cost: ~$600 one-time. No monthly hosting fees. Runs silent, draws 10W idle.

The Architecture#

Your Laptop (anywhere)
    │
    ├── SSH (LAN or Tailscale) ──→ Mac Mini
    │                                ├── Cron jobs (scheduled automation)
    │                                ├── Docker Desktop
    │                                │   └── Your containers
    │                                ├── Background services (APIs, workers)
    │                                ├── Chrome (headless CDP)
    │                                │   └── Browser automation
    │                                └── Node.js / Python scripts
    │
    └── Tailscale (VPN mesh) ──→ Mac Mini (from anywhere)
        └── HTTPS reverse proxy via Tailscale Serve

Setup Order#

Follow this order. Each step depends on the previous.

  1. Fresh Mac Install — Xcode, Homebrew, Node.js, prevent sleep
  2. SSH Access — Keys, multiplexing, file transfer patterns
  3. Tailscale — VPN mesh, Serve mode for HTTPS
  4. Docker — Desktop install, remote access patterns
  5. Background Services & Crons — launchd, pm2, scheduled automation
  6. Browser Automation — Headless Chrome via CDP

Step 1: Fresh Mac Mini Install#

Prerequisites#

  • Mac Mini with macOS (Apple Silicon or Intel)
  • Monitor + keyboard for initial setup (can remove after)
  • Ethernet connection (more reliable than WiFi for a server)

Install Sequence#

bash# 1. Xcode Command Line Tools (required for everything)
xcode-select --install

# 2. Homebrew
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

# Add to PATH (Apple Silicon)
echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> ~/.zprofile
eval "$(/opt/homebrew/bin/brew shellenv)"

# 3. Node.js via fnm (Fast Node Manager)
brew install fnm
echo 'eval "$(fnm env --use-on-cd)"' >> ~/.zshrc
source ~/.zshrc
fnm install --lts
fnm default lts-latest

# 4. Essential tools
brew install git gh tailscale

# 5. Docker Desktop
brew install --cask docker
# Open Docker Desktop once to complete setup, then it auto-starts on boot

Prevent Sleep (Critical for a Server)#

A sleeping Mac Mini drops SSH connections and stops crons.

bash# Disable sleep entirely
sudo pmset -a sleep 0
sudo pmset -a disablesleep 1
sudo pmset -a displaysleep 0

# Verify
pmset -g | grep sleep
# Should show: sleep 0, disablesleep 1

# Enable auto-restart after power failure
sudo pmset -a autorestart 1

Enable Remote Login#

System Preferences → General → Sharing → Remote Login → ON

Or via terminal:

bashsudo systemsetup -setremotelogin on

Verify#

bashnode --version    # v20+ or v22+
npm --version     # 10+
git --version     # 2.39+
docker --version  # 24+

Step 2: SSH Access#

Find Your Mac Mini on the Network#

bash# From your laptop, on the same network:
dns-sd -B _ssh._tcp .
# Look for your Mac Mini's hostname

# Or use its .local address:
ssh youruser@Your-Mac-mini.local

SSH Key Setup (passwordless login)#

bash# On your laptop — generate key if you don't have one
ssh-keygen -t ed25519 -C "your-email@example.com"

# Copy to Mac Mini
ssh-copy-id youruser@Your-Mac-mini.local

# Test — should not ask for password
ssh youruser@Your-Mac-mini.local

SSH Config (Connection Multiplexing)#

Add to ~/.ssh/config on your laptop:

Host mini
  HostName Your-Mac-mini.local    # or Tailscale IP
  User youruser
  ControlMaster auto              # reuse connections
  ControlPersist 600              # keep socket 10 minutes
  ServerAliveInterval 60          # prevent drops
  ForwardAgent yes                # forward SSH keys

Now ssh mini connects instantly. Subsequent SSH/SCP commands in the same 10-minute window reuse the socket — no new handshake.

File Transfer Patterns#

bash# Laptop → Mac Mini
scp ./file.txt mini:/path/to/destination/

# Mac Mini → Laptop
scp mini:/path/to/file.txt ./local/

# Sync a directory
rsync -avz --progress ./project/ mini:~/projects/project/

# Quick function (add to your .zshrc)
tomin() { scp "$1" mini:~/"${2:-.}"; }
frommin() { scp mini:~/"$1" "${2:-.}"; }

Critical SSH Gotcha: Non-Interactive Shells#

When you SSH to run a command (not an interactive session), ~/.zshrc is NOT sourced. This means node, npm, brew — anything installed via Homebrew — won't be on PATH.

bash# This FAILS — node not found
ssh mini 'node --version'

# This WORKS — source zshrc first
ssh mini 'source ~/.zshrc && node --version'

# For scripts, always start with:
#!/bin/zsh
source ~/.zshrc
# ... rest of script

This is the #1 gotcha with Mac Mini automation. Every cron, every remote command must source the shell config.


Step 3: Tailscale (Remote Access from Anywhere)#

Tailscale creates a VPN mesh between your devices. Your Mac Mini gets a stable IP accessible from anywhere — coffee shop, client site, phone.

Install#

bashbrew install tailscale

# Start and authenticate
sudo tailscale up
# Opens browser for auth — sign in with your Tailscale account

Find Your Tailscale IP#

bashtailscale ip -4
# Example: 100.x.y.z

Update your SSH config to use this IP for remote access:

Host mini
  HostName 100.x.y.z    # Tailscale IP — works from anywhere
  User youruser
  ControlMaster auto
  ControlPersist 600
  ServerAliveInterval 60

Tailscale Serve (HTTPS Reverse Proxy)#

Expose local services over HTTPS with a valid cert — no Nginx, no Let's Encrypt, no port forwarding.

bash# Expose a local service (e.g., port 3000) over HTTPS
sudo tailscale serve https / http://127.0.0.1:3000

# Your service is now at:
# https://your-mac-mini.tailnet-name.ts.net/

# Expose with a path prefix
sudo tailscale serve https /api http://127.0.0.1:8080

# Check what's being served
tailscale serve status

Serve vs Funnel:

  • Serve — accessible only to devices on your Tailscale network (private)
  • Funnel — accessible from the public internet (use sparingly)
bash# Public access (careful!)
sudo tailscale funnel https / http://127.0.0.1:3000

# Private only (recommended)
sudo tailscale serve https / http://127.0.0.1:3000

Tailscale Gotchas#

  • Tailscale must be running on BOTH devices (laptop + Mac Mini)
  • If the Mac Mini sleeps, Tailscale disconnects (see Step 1: Prevent Sleep)
  • First connection after wake takes 2-3 seconds to re-establish
  • tailscale status shows all connected devices

Step 4: Docker on Mac Mini#

Setup#

Docker Desktop should already be installed from Step 1. Two gotchas for headless use:

Gotcha 1: Docker must be running. Docker Desktop needs to be open (or set to start on login). Without the GUI session, the Docker daemon doesn't start.

Docker Desktop → Settings → General → Start Docker Desktop when you log in → ON

Gotcha 2: Keychain access over SSH. Docker Desktop stores credentials in the macOS keychain, which may not be accessible over SSH.

bash# If docker commands fail with keychain errors over SSH:
# Option 1: Use docker cp + docker restart instead of docker compose up --build
docker cp ./file.js container-name:/app/file.js
docker restart container-name

# Option 2: Pre-build on the Mac Mini with a screen session
ssh mini
screen -S docker
docker compose up --build
# Ctrl-A, D to detach

Common Docker Patterns for Mac Mini#

bash# Run a service in the background
docker compose up -d

# Check running containers
docker ps

# View logs
docker logs container-name -f --tail 50

# Deploy a file change without full rebuild
docker cp ./updated-file.js my-container:/app/updated-file.js
docker restart my-container

Step 5: Background Services & Crons#

A headless Mac Mini shines as a host for background services, scheduled jobs, and long-running processes.

Option A: launchd (macOS Native)#

launchd is the macOS init system. It starts services on boot and restarts them if they crash.

bash# Create a plist for your service
cat > ~/Library/LaunchAgents/com.myapp.api.plist << 'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.myapp.api</string>
    <key>ProgramArguments</key>
    <array>
        <string>/opt/homebrew/bin/node</string>
        <string>/Users/youruser/projects/myapp/server.js</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
    <key>StandardOutPath</key>
    <string>/Users/youruser/logs/myapp.log</string>
    <key>StandardErrorPath</key>
    <string>/Users/youruser/logs/myapp-error.log</string>
    <key>EnvironmentVariables</key>
    <dict>
        <key>PATH</key>
        <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
        <key>PORT</key>
        <string>8080</string>
    </dict>
</dict>
</plist>
EOF

# Load and start the service
launchctl load ~/Library/LaunchAgents/com.myapp.api.plist

# Check status
launchctl list | grep myapp

# Stop and unload
launchctl unload ~/Library/LaunchAgents/com.myapp.api.plist

Key launchd gotcha: Use absolute paths for everything — the binary, the script, log files. launchd does not load your shell profile.

Option B: pm2 (Node.js Process Manager)#

Simpler than launchd for Node.js services:

bashnpm install -g pm2

# Start a service
pm2 start server.js --name api

# Start a Python script
pm2 start script.py --name worker --interpreter python3

# Auto-start on boot
pm2 startup    # generates a launchd config
pm2 save       # saves current process list

# Management
pm2 list              # see all processes
pm2 logs api          # tail logs
pm2 restart api       # restart a process
pm2 monit             # real-time dashboard

Cron Jobs (Scheduled Automation)#

Use crontab for scheduled tasks:

bash# Edit crontab
crontab -e

# Example: run a script every day at 9am
0 9 * * * /bin/zsh -c 'source ~/.zshrc && node ~/scripts/daily-report.js >> ~/logs/daily-report.log 2>&1'

# Example: health check every 5 minutes
*/5 * * * * /bin/zsh -c 'source ~/.zshrc && curl -sf http://localhost:8080/health || pm2 restart api' >> ~/logs/healthcheck.log 2>&1

Cron Script Pattern#

Every cron script should follow this pattern:

bash#!/bin/zsh
source ~/.zshrc    # CRITICAL — makes node/npm/docker available

# Your automation logic here
node /path/to/script.js

# Or run a Python script
python3 /path/to/automation.py

# Or interact with Docker
docker exec my-container /app/run-task.sh

Background Service Gotchas#

  • Cron scripts MUST source ~/.zshrc (see SSH gotcha in Step 2)
  • launchd plists must use absolute paths — no ~, no $HOME
  • Log everything — background jobs fail silently without logs
  • Set generous timeouts for long-running tasks (API calls, AI processing)
  • Use pm2 startup to survive reboots — it generates a launchd plist under the hood
  • If Mac Mini sleeps, all crons and services stop (see Step 1: Prevent Sleep)

Step 6: Browser Automation (Headless Chrome)#

Run browser automation on the Mac Mini for web scraping, testing, or scheduled interactions with web apps.

Setup Chrome CDP#

bash# Install Chrome if not present
brew install --cask google-chrome

# Launch Chrome with CDP (Chrome DevTools Protocol) enabled
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
  --remote-debugging-port=9222 \
  --no-first-run \
  --disable-background-timer-throttling \
  --disable-backgrounding-occluded-windows &

# Verify CDP is accessible
curl -s http://localhost:9222/json/version | python3 -m json.tool

Using Playwright or Puppeteer#

Connect your automation framework to the running Chrome instance:

javascript// Playwright — connect to existing Chrome
const { chromium } = require('playwright');
const browser = await chromium.connectOverCDP('http://localhost:9222');
const page = browser.contexts()[0].pages()[0];
await page.goto('https://example.com');
await page.screenshot({ path: 'screenshot.png' });

// Puppeteer — connect to existing Chrome
const puppeteer = require('puppeteer');
const browser = await puppeteer.connect({
  browserURL: 'http://localhost:9222'
});
const page = (await browser.pages())[0];
await page.goto('https://example.com');

Session Persistence#

Save login cookies so cron jobs don't need to re-authenticate:

javascript// Save cookies after manual login
const cookies = await page.context().cookies();
fs.writeFileSync('cookies.json', JSON.stringify(cookies));

// Restore cookies in a cron script
const cookies = JSON.parse(fs.readFileSync('cookies.json'));
await page.context().addCookies(cookies);
await page.goto('https://app.example.com/dashboard');

Auto-Start Chrome on Boot#

Create a launchd plist so Chrome with CDP is always available:

bashcat > ~/Library/LaunchAgents/com.chrome.cdp.plist << 'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.chrome.cdp</string>
    <key>ProgramArguments</key>
    <array>
        <string>/Applications/Google Chrome.app/Contents/MacOS/Google Chrome</string>
        <string>--remote-debugging-port=9222</string>
        <string>--no-first-run</string>
        <string>--disable-background-timer-throttling</string>
        <string>--disable-backgrounding-occluded-windows</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
</dict>
</plist>
EOF

launchctl load ~/Library/LaunchAgents/com.chrome.cdp.plist

Headless Gotchas#

  • Chrome must be running BEFORE your automation script tries to connect
  • CDP port 9222 is only accessible locally (not exposed via Tailscale unless you set up a serve)
  • Login sessions expire -- build re-login flows into your automation scripts
  • Some sites detect headless browsers -- use the full Chrome binary, not Puppeteer's bundled Chromium
  • On a headless Mac Mini (no display), Chrome still runs fine -- macOS has a virtual framebuffer

Useful Shell Functions#

Add these to the Mac Mini's ~/.zshrc or your laptop's ~/.zshrc:

bash# SSH to Mac Mini and attach tmux session
miniwork() {
  local session="${1:-main}"
  ssh mini -t "tmux attach-session -t $session 2>/dev/null || tmux new-session -s $session"
}

# Quick Mac Mini status check
ministatus() {
  ssh mini 'source ~/.zshrc && echo "=== Uptime ===" && uptime && echo "\n=== Disk ===" && df -h / && echo "\n=== Memory ===" && vm_stat | head -5 && echo "\n=== Docker ===" && docker ps --format "table {{.Names}}\t{{.Status}}" 2>/dev/null && echo "\n=== Ports ===" && lsof -i -P -n 2>/dev/null | grep LISTEN | awk "{print \$1, \$9}" | sort -u'
}

# Transfer file to Mac Mini
tomin() { scp "$1" mini:~/"${2:-.}"; }

# Get file from Mac Mini
frommin() { scp mini:~/"$1" "${2:-.}"; }

# Tail pm2 logs
minilogs() {
  ssh mini 'source ~/.zshrc && pm2 logs --lines 50'
}

Verification Checklist#

After setup, verify everything works:

bash# From your laptop:
ssh mini 'source ~/.zshrc && node --version'       # Node.js
ssh mini 'docker ps'                                 # Docker
ssh mini 'tailscale status'                          # Tailscale
ssh mini 'source ~/.zshrc && pm2 list'               # Background services
ssh mini 'curl -s http://localhost:9222/json/version' # Chrome CDP

# From outside your network (via Tailscale):
ssh youruser@100.x.y.z 'echo "Tailscale works"'    # Remote SSH
curl https://your-mac-mini.tailnet.ts.net/           # Tailscale Serve

Anti-Patterns#

Wrong Right
Let Mac Mini sleep sudo pmset -a sleep 0 -a disablesleep 1
Run commands via SSH without sourcing zshrc Always source ~/.zshrc first
Use docker compose up --build over SSH Use docker cp + docker restart
Expose ports directly to the internet Use Tailscale Serve for HTTPS
Store credentials in scripts Use environment variables or secret managers
Assume WiFi is reliable for a server Use Ethernet
Skip the ControlMaster SSH config Multiplexing makes everything instant
Run crons as root Run as your user account
---
name: mac-mini-server
category: Infrastructure
description: >
  Turn a Mac Mini into a headless dev server with Tailscale VPN, Docker, SSH multiplexing, cron automation, and browser automation. Covers fresh install through production workloads. Field-tested patterns from running real automation 24/7.
---

# Mac Mini as a Headless Dev Server

## Why a Mac Mini

A Mac Mini sitting on your desk or in a closet gives you:
- A 24/7 server for crons, automations, and background jobs
- Native macOS for browser automation (no Linux font rendering issues)
- Docker Desktop for containerized services
- Tailscale for secure remote access from anywhere
- SSH for command-line access from any machine on your network

Cost: ~$600 one-time. No monthly hosting fees. Runs silent, draws 10W idle.

## The Architecture

```
Your Laptop (anywhere)
    │
    ├── SSH (LAN or Tailscale) ──→ Mac Mini
    │                                ├── Cron jobs (scheduled automation)
    │                                ├── Docker Desktop
    │                                │   └── Your containers
    │                                ├── Background services (APIs, workers)
    │                                ├── Chrome (headless CDP)
    │                                │   └── Browser automation
    │                                └── Node.js / Python scripts
    │
    └── Tailscale (VPN mesh) ──→ Mac Mini (from anywhere)
        └── HTTPS reverse proxy via Tailscale Serve
```

## Setup Order

Follow this order. Each step depends on the previous.

1. **Fresh Mac Install** — Xcode, Homebrew, Node.js, prevent sleep
2. **SSH Access** — Keys, multiplexing, file transfer patterns
3. **Tailscale** — VPN mesh, Serve mode for HTTPS
4. **Docker** — Desktop install, remote access patterns
5. **Background Services & Crons** — launchd, pm2, scheduled automation
6. **Browser Automation** — Headless Chrome via CDP

---

## Step 1: Fresh Mac Mini Install

### Prerequisites

- Mac Mini with macOS (Apple Silicon or Intel)
- Monitor + keyboard for initial setup (can remove after)
- Ethernet connection (more reliable than WiFi for a server)

### Install Sequence

```bash
# 1. Xcode Command Line Tools (required for everything)
xcode-select --install

# 2. Homebrew
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

# Add to PATH (Apple Silicon)
echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> ~/.zprofile
eval "$(/opt/homebrew/bin/brew shellenv)"

# 3. Node.js via fnm (Fast Node Manager)
brew install fnm
echo 'eval "$(fnm env --use-on-cd)"' >> ~/.zshrc
source ~/.zshrc
fnm install --lts
fnm default lts-latest

# 4. Essential tools
brew install git gh tailscale

# 5. Docker Desktop
brew install --cask docker
# Open Docker Desktop once to complete setup, then it auto-starts on boot
```

### Prevent Sleep (Critical for a Server)

A sleeping Mac Mini drops SSH connections and stops crons.

```bash
# Disable sleep entirely
sudo pmset -a sleep 0
sudo pmset -a disablesleep 1
sudo pmset -a displaysleep 0

# Verify
pmset -g | grep sleep
# Should show: sleep 0, disablesleep 1

# Enable auto-restart after power failure
sudo pmset -a autorestart 1
```

### Enable Remote Login

```
System Preferences → General → Sharing → Remote Login → ON
```

Or via terminal:
```bash
sudo systemsetup -setremotelogin on
```

### Verify

```bash
node --version    # v20+ or v22+
npm --version     # 10+
git --version     # 2.39+
docker --version  # 24+
```

---

## Step 2: SSH Access

### Find Your Mac Mini on the Network

```bash
# From your laptop, on the same network:
dns-sd -B _ssh._tcp .
# Look for your Mac Mini's hostname

# Or use its .local address:
ssh youruser@Your-Mac-mini.local
```

### SSH Key Setup (passwordless login)

```bash
# On your laptop — generate key if you don't have one
ssh-keygen -t ed25519 -C "your-email@example.com"

# Copy to Mac Mini
ssh-copy-id youruser@Your-Mac-mini.local

# Test — should not ask for password
ssh youruser@Your-Mac-mini.local
```

### SSH Config (Connection Multiplexing)

Add to `~/.ssh/config` on your laptop:

```
Host mini
  HostName Your-Mac-mini.local    # or Tailscale IP
  User youruser
  ControlMaster auto              # reuse connections
  ControlPersist 600              # keep socket 10 minutes
  ServerAliveInterval 60          # prevent drops
  ForwardAgent yes                # forward SSH keys
```

Now `ssh mini` connects instantly. Subsequent SSH/SCP commands in the same 10-minute window reuse the socket — no new handshake.

### File Transfer Patterns

```bash
# Laptop → Mac Mini
scp ./file.txt mini:/path/to/destination/

# Mac Mini → Laptop
scp mini:/path/to/file.txt ./local/

# Sync a directory
rsync -avz --progress ./project/ mini:~/projects/project/

# Quick function (add to your .zshrc)
tomin() { scp "$1" mini:~/"${2:-.}"; }
frommin() { scp mini:~/"$1" "${2:-.}"; }
```

### Critical SSH Gotcha: Non-Interactive Shells

When you SSH to run a command (not an interactive session), `~/.zshrc` is NOT sourced. This means `node`, `npm`, `brew` — anything installed via Homebrew — won't be on PATH.

```bash
# This FAILS — node not found
ssh mini 'node --version'

# This WORKS — source zshrc first
ssh mini 'source ~/.zshrc && node --version'

# For scripts, always start with:
#!/bin/zsh
source ~/.zshrc
# ... rest of script
```

This is the #1 gotcha with Mac Mini automation. Every cron, every remote command must source the shell config.

---

## Step 3: Tailscale (Remote Access from Anywhere)

Tailscale creates a VPN mesh between your devices. Your Mac Mini gets a stable IP accessible from anywhere — coffee shop, client site, phone.

### Install

```bash
brew install tailscale

# Start and authenticate
sudo tailscale up
# Opens browser for auth — sign in with your Tailscale account
```

### Find Your Tailscale IP

```bash
tailscale ip -4
# Example: 100.x.y.z
```

Update your SSH config to use this IP for remote access:

```
Host mini
  HostName 100.x.y.z    # Tailscale IP — works from anywhere
  User youruser
  ControlMaster auto
  ControlPersist 600
  ServerAliveInterval 60
```

### Tailscale Serve (HTTPS Reverse Proxy)

Expose local services over HTTPS with a valid cert — no Nginx, no Let's Encrypt, no port forwarding.

```bash
# Expose a local service (e.g., port 3000) over HTTPS
sudo tailscale serve https / http://127.0.0.1:3000

# Your service is now at:
# https://your-mac-mini.tailnet-name.ts.net/

# Expose with a path prefix
sudo tailscale serve https /api http://127.0.0.1:8080

# Check what's being served
tailscale serve status
```

**Serve vs Funnel:**
- **Serve** — accessible only to devices on your Tailscale network (private)
- **Funnel** — accessible from the public internet (use sparingly)

```bash
# Public access (careful!)
sudo tailscale funnel https / http://127.0.0.1:3000

# Private only (recommended)
sudo tailscale serve https / http://127.0.0.1:3000
```

### Tailscale Gotchas

- Tailscale must be running on BOTH devices (laptop + Mac Mini)
- If the Mac Mini sleeps, Tailscale disconnects (see Step 1: Prevent Sleep)
- First connection after wake takes 2-3 seconds to re-establish
- `tailscale status` shows all connected devices

---

## Step 4: Docker on Mac Mini

### Setup

Docker Desktop should already be installed from Step 1. Two gotchas for headless use:

**Gotcha 1: Docker must be running.** Docker Desktop needs to be open (or set to start on login). Without the GUI session, the Docker daemon doesn't start.

```
Docker Desktop → Settings → General → Start Docker Desktop when you log in → ON
```

**Gotcha 2: Keychain access over SSH.** Docker Desktop stores credentials in the macOS keychain, which may not be accessible over SSH.

```bash
# If docker commands fail with keychain errors over SSH:
# Option 1: Use docker cp + docker restart instead of docker compose up --build
docker cp ./file.js container-name:/app/file.js
docker restart container-name

# Option 2: Pre-build on the Mac Mini with a screen session
ssh mini
screen -S docker
docker compose up --build
# Ctrl-A, D to detach
```

### Common Docker Patterns for Mac Mini

```bash
# Run a service in the background
docker compose up -d

# Check running containers
docker ps

# View logs
docker logs container-name -f --tail 50

# Deploy a file change without full rebuild
docker cp ./updated-file.js my-container:/app/updated-file.js
docker restart my-container
```

---

## Step 5: Background Services & Crons

A headless Mac Mini shines as a host for background services, scheduled jobs, and long-running processes.

### Option A: launchd (macOS Native)

launchd is the macOS init system. It starts services on boot and restarts them if they crash.

```bash
# Create a plist for your service
cat > ~/Library/LaunchAgents/com.myapp.api.plist << 'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.myapp.api</string>
    <key>ProgramArguments</key>
    <array>
        <string>/opt/homebrew/bin/node</string>
        <string>/Users/youruser/projects/myapp/server.js</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
    <key>StandardOutPath</key>
    <string>/Users/youruser/logs/myapp.log</string>
    <key>StandardErrorPath</key>
    <string>/Users/youruser/logs/myapp-error.log</string>
    <key>EnvironmentVariables</key>
    <dict>
        <key>PATH</key>
        <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
        <key>PORT</key>
        <string>8080</string>
    </dict>
</dict>
</plist>
EOF

# Load and start the service
launchctl load ~/Library/LaunchAgents/com.myapp.api.plist

# Check status
launchctl list | grep myapp

# Stop and unload
launchctl unload ~/Library/LaunchAgents/com.myapp.api.plist
```

**Key launchd gotcha:** Use absolute paths for everything — the binary, the script, log files. launchd does not load your shell profile.

### Option B: pm2 (Node.js Process Manager)

Simpler than launchd for Node.js services:

```bash
npm install -g pm2

# Start a service
pm2 start server.js --name api

# Start a Python script
pm2 start script.py --name worker --interpreter python3

# Auto-start on boot
pm2 startup    # generates a launchd config
pm2 save       # saves current process list

# Management
pm2 list              # see all processes
pm2 logs api          # tail logs
pm2 restart api       # restart a process
pm2 monit             # real-time dashboard
```

### Cron Jobs (Scheduled Automation)

Use crontab for scheduled tasks:

```bash
# Edit crontab
crontab -e

# Example: run a script every day at 9am
0 9 * * * /bin/zsh -c 'source ~/.zshrc && node ~/scripts/daily-report.js >> ~/logs/daily-report.log 2>&1'

# Example: health check every 5 minutes
*/5 * * * * /bin/zsh -c 'source ~/.zshrc && curl -sf http://localhost:8080/health || pm2 restart api' >> ~/logs/healthcheck.log 2>&1
```

### Cron Script Pattern

Every cron script should follow this pattern:

```bash
#!/bin/zsh
source ~/.zshrc    # CRITICAL — makes node/npm/docker available

# Your automation logic here
node /path/to/script.js

# Or run a Python script
python3 /path/to/automation.py

# Or interact with Docker
docker exec my-container /app/run-task.sh
```

### Background Service Gotchas

- Cron scripts MUST source `~/.zshrc` (see SSH gotcha in Step 2)
- launchd plists must use absolute paths — no `~`, no `$HOME`
- Log everything — background jobs fail silently without logs
- Set generous timeouts for long-running tasks (API calls, AI processing)
- Use `pm2 startup` to survive reboots — it generates a launchd plist under the hood
- If Mac Mini sleeps, all crons and services stop (see Step 1: Prevent Sleep)

---

## Step 6: Browser Automation (Headless Chrome)

Run browser automation on the Mac Mini for web scraping, testing, or scheduled interactions with web apps.

### Setup Chrome CDP

```bash
# Install Chrome if not present
brew install --cask google-chrome

# Launch Chrome with CDP (Chrome DevTools Protocol) enabled
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
  --remote-debugging-port=9222 \
  --no-first-run \
  --disable-background-timer-throttling \
  --disable-backgrounding-occluded-windows &

# Verify CDP is accessible
curl -s http://localhost:9222/json/version | python3 -m json.tool
```

### Using Playwright or Puppeteer

Connect your automation framework to the running Chrome instance:

```javascript
// Playwright — connect to existing Chrome
const { chromium } = require('playwright');
const browser = await chromium.connectOverCDP('http://localhost:9222');
const page = browser.contexts()[0].pages()[0];
await page.goto('https://example.com');
await page.screenshot({ path: 'screenshot.png' });

// Puppeteer — connect to existing Chrome
const puppeteer = require('puppeteer');
const browser = await puppeteer.connect({
  browserURL: 'http://localhost:9222'
});
const page = (await browser.pages())[0];
await page.goto('https://example.com');
```

### Session Persistence

Save login cookies so cron jobs don't need to re-authenticate:

```javascript
// Save cookies after manual login
const cookies = await page.context().cookies();
fs.writeFileSync('cookies.json', JSON.stringify(cookies));

// Restore cookies in a cron script
const cookies = JSON.parse(fs.readFileSync('cookies.json'));
await page.context().addCookies(cookies);
await page.goto('https://app.example.com/dashboard');
```

### Auto-Start Chrome on Boot

Create a launchd plist so Chrome with CDP is always available:

```bash
cat > ~/Library/LaunchAgents/com.chrome.cdp.plist << 'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.chrome.cdp</string>
    <key>ProgramArguments</key>
    <array>
        <string>/Applications/Google Chrome.app/Contents/MacOS/Google Chrome</string>
        <string>--remote-debugging-port=9222</string>
        <string>--no-first-run</string>
        <string>--disable-background-timer-throttling</string>
        <string>--disable-backgrounding-occluded-windows</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
</dict>
</plist>
EOF

launchctl load ~/Library/LaunchAgents/com.chrome.cdp.plist
```

### Headless Gotchas

- Chrome must be running BEFORE your automation script tries to connect
- CDP port 9222 is only accessible locally (not exposed via Tailscale unless you set up a serve)
- Login sessions expire -- build re-login flows into your automation scripts
- Some sites detect headless browsers -- use the full Chrome binary, not Puppeteer's bundled Chromium
- On a headless Mac Mini (no display), Chrome still runs fine -- macOS has a virtual framebuffer

---

## Useful Shell Functions

Add these to the Mac Mini's `~/.zshrc` or your laptop's `~/.zshrc`:

```bash
# SSH to Mac Mini and attach tmux session
miniwork() {
  local session="${1:-main}"
  ssh mini -t "tmux attach-session -t $session 2>/dev/null || tmux new-session -s $session"
}

# Quick Mac Mini status check
ministatus() {
  ssh mini 'source ~/.zshrc && echo "=== Uptime ===" && uptime && echo "\n=== Disk ===" && df -h / && echo "\n=== Memory ===" && vm_stat | head -5 && echo "\n=== Docker ===" && docker ps --format "table {{.Names}}\t{{.Status}}" 2>/dev/null && echo "\n=== Ports ===" && lsof -i -P -n 2>/dev/null | grep LISTEN | awk "{print \$1, \$9}" | sort -u'
}

# Transfer file to Mac Mini
tomin() { scp "$1" mini:~/"${2:-.}"; }

# Get file from Mac Mini
frommin() { scp mini:~/"$1" "${2:-.}"; }

# Tail pm2 logs
minilogs() {
  ssh mini 'source ~/.zshrc && pm2 logs --lines 50'
}
```

---

## Verification Checklist

After setup, verify everything works:

```bash
# From your laptop:
ssh mini 'source ~/.zshrc && node --version'       # Node.js
ssh mini 'docker ps'                                 # Docker
ssh mini 'tailscale status'                          # Tailscale
ssh mini 'source ~/.zshrc && pm2 list'               # Background services
ssh mini 'curl -s http://localhost:9222/json/version' # Chrome CDP

# From outside your network (via Tailscale):
ssh youruser@100.x.y.z 'echo "Tailscale works"'    # Remote SSH
curl https://your-mac-mini.tailnet.ts.net/           # Tailscale Serve
```

---

## Anti-Patterns

| Wrong | Right |
|-------|-------|
| Let Mac Mini sleep | `sudo pmset -a sleep 0 -a disablesleep 1` |
| Run commands via SSH without sourcing zshrc | Always `source ~/.zshrc` first |
| Use `docker compose up --build` over SSH | Use `docker cp` + `docker restart` |
| Expose ports directly to the internet | Use Tailscale Serve for HTTPS |
| Store credentials in scripts | Use environment variables or secret managers |
| Assume WiFi is reliable for a server | Use Ethernet |
| Skip the ControlMaster SSH config | Multiplexing makes everything instant |
| Run crons as root | Run as your user account |

Keyboard Shortcuts

Search in document⌘K
Focus search/
Previous file tab
Next file tab
Close overlayEsc
Show shortcuts?