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
A Mac Mini sitting on your desk or in a closet gives you:
Cost: ~$600 one-time. No monthly hosting fees. Runs silent, draws 10W idle.
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
Follow this order. Each step depends on the previous.
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
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
System Preferences → General → Sharing → Remote Login → ON
Or via terminal:
bashsudo systemsetup -setremotelogin on
bashnode --version # v20+ or v22+
npm --version # 10+
git --version # 2.39+
docker --version # 24+
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
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
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.
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:-.}"; }
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.
Tailscale creates a VPN mesh between your devices. Your Mac Mini gets a stable IP accessible from anywhere — coffee shop, client site, phone.
bashbrew install tailscale
# Start and authenticate
sudo tailscale up
# Opens browser for auth — sign in with your Tailscale account
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
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:
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 status shows all connected devicesDocker 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
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
A headless Mac Mini shines as a host for background services, scheduled jobs, and long-running processes.
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.
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
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
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
~/.zshrc (see SSH gotcha in Step 2)~, no $HOMEpm2 startup to survive reboots — it generates a launchd plist under the hoodRun browser automation on the Mac Mini for web scraping, testing, or scheduled interactions with web apps.
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
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');
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');
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
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'
}
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
| 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 |