Run a WireGuard VPN server on macOS
One command to set up a full WireGuard server on any Mac with auto-start, NAT, QR codes, and live monitoring.
Running a WireGuard server on macOS is full of platform-specific traps. Existing scripts and guides fail because they don't handle all of them together.
git clone https://github.com/hjunhuh/wireguard-macos.git
cd wireguard-macos
# Install server (do NOT use sudo — it will ask when needed)
./install.shThe installer will prompt for:
| Prompt | Example | Default |
|---|---|---|
| Endpoint | 203.0.113.5:51820 |
(required) |
| Server VPN IP | 10.0.10.1 |
10.0.10.1 |
| DNS server | 1.1.1.1 |
1.1.1.1 |
| WAN interface | en0 |
en0 |
- macOS 13 (Ventura) or later
- Homebrew installed
- A router with UDP port forwarding capability
- A static public IP or DDNS hostname
./client.sh iphone
./client.sh macbookA QR code is printed to the terminal. Scan it with the WireGuard app on iOS/Android, or import the generated .conf file on desktop clients.
./client.sh --remove iphoneRemoves the client from the running WireGuard service. Run without a client name to see existing clients.
Important: Always use the Homebrew bash path. Running
sudo wg-quickdirectly uses macOS system bash 3.2, which will fail.
# Start
sudo /opt/homebrew/bin/bash /opt/homebrew/bin/wg-quick up /opt/homebrew/etc/wireguard/wg0.conf
# Stop
sudo /opt/homebrew/bin/bash /opt/homebrew/bin/wg-quick down /opt/homebrew/etc/wireguard/wg0.confThe server starts automatically on boot via launchd, so manual start is only needed the first time.
sudo ./status.sh============================================================
WireGuard Server Status
============================================================
Status: RUNNING (interface: utun10)
interface: utun10
public key: ...
private key: (hidden)
listening port: 51820
peer: ...
preshared key: (hidden)
allowed ips: 10.0.10.2/32
IP forwarding: ENABLED
NAT rule: ACTIVE (nat on en0 inet from 10.0.10.0/24 to any -> (en0))
Registered clients:
- iphone (10.0.10.2)
- macbook (10.0.10.3)
============================================================
sudo ./monitor.shA live dashboard that refreshes every 2 seconds:
============================================================
WireGuard Monitor [2026-03-03 14:32:05]
============================================================
Interface: utun10 Port: 51820
Public Key: aBcDeFgHiJkLmNoPqRsT...
Uptime: 3d 14h 22m Peers: 2/3 online
------------------------------------------------------------
CLIENT STATUS ENDPOINT LAST HANDSHAKE
------------------------------------------------------------
iphone ONLINE 203.0.113.50:4921 12s ago
RX: 145.2 MB (52.3 KB/s) TX: 1.2 GB (128.7 KB/s)
macbook ONLINE 198.51.100.8:51820 45s ago
RX: 2.3 GB (1.2 MB/s) TX: 523.4 MB (256.0 KB/s)
ipad OFFLINE -- 3h 12m ago
RX: 89.1 MB (0 B/s) TX: 12.3 MB (0 B/s)
------------------------------------------------------------
TOTALS RX: 2.5 GB (1.3 MB/s) TX: 1.7 GB (384.7 KB/s)
PEERS 3 registered, 2 online, 1 offline
============================================================
Refresh: 2s | Ctrl+C to exit
============================================================
sudo ./remove.shStops WireGuard, removes the launchd service, disables IP forwarding, and optionally deletes all keys and configuration files.
After installation:
/opt/homebrew/etc/wireguard/
├── wg0.conf # Server config (includes peers)
├── wg0.conf.def # Backup of server config (no peers)
├── postup.sh # NAT enable script (pfctl anchor)
├── postdown.sh # NAT disable + anchor flush
├── wg-quick-sudo.sh # Homebrew bash wrapper
├── server_public.key
├── server_private.key
├── endpoint.var
├── dns.var
├── vpn_subnet.var
├── wan_interface_name.var
├── last_used_ip.var
└── clients/
├── iphone/
│ ├── iphone.conf # Client config (share this)
│ ├── privatekey
│ ├── publickey
│ ├── presharedkey
│ └── ip
└── macbook/
└── ...
/Library/LaunchDaemons/
└── com.wireguard.wg0.plist # Auto-start on boot
| Feature | wireguard-macos | wg-easy | PiVPN | Manual setup |
|---|---|---|---|---|
| macOS native | Yes | No (Docker) | No (Linux) | Partial |
| Apple Silicon | Yes | N/A | N/A | Manual |
| One-command install | Yes | Yes | Yes | No |
| Auto-start on boot | Yes | Docker restart | systemd | Manual |
| NAT survives OS updates | Yes | N/A | N/A | No |
| QR codes for mobile | Yes | Yes | Yes | Manual |
| Post-quantum preshared keys | Yes | No | Optional | Manual |
| Live monitoring dashboard | Yes | Web UI | No | No |
| No Docker required | Yes | No | Yes | Yes |
Instead of editing /etc/pf.conf (which gets overwritten on macOS updates), this project uses pfctl anchors:
- PostUp adds NAT + pass rules to the
com.apple/wireguardanchor and saves the pf token - PostDown flushes the anchor rules, releases the pf token, and removes the subnet route
This approach was proposed by lifepillar as an improvement to the original Barrowclift guide.
A launchd plist is registered at /Library/LaunchDaemons/com.wireguard.wg0.plist with RunAtLoad: true. It calls wg-quick up via Homebrew bash with the correct PATH environment variable set, avoiding the bash 3 and path issues entirely.
After installation, configure port forwarding on your router:
| Field | Value |
|---|---|
| External port | The port from your endpoint (default: 51820) |
| Internal IP | Your Mac's LAN IP |
| Internal port | Same as external |
| Protocol | UDP |
"bash 3 detected" or wg-quick fails silently
macOS ships with bash 3.2. Always invoke wg-quick via Homebrew bash:
sudo /opt/homebrew/bin/bash /opt/homebrew/bin/wg-quick up /opt/homebrew/etc/wireguard/wg0.confVPN connects but no internet
# Check IP forwarding (should be 1)
sysctl net.inet.ip.forwarding
# Check NAT rule exists
sudo pfctl -a com.apple/wireguard -snIf the NAT rule is missing, restart WireGuard (down then up).
Handshake never completes
- Verify UDP port forwarding is configured on your router
- Confirm the endpoint public IP is correct (
curl ipinfo.io/ip) - Ensure server and client public keys are correctly cross-referenced
- Check firewall logs:
cat /tmp/wireguard-wg0.err
status.sh shows "STOPPED" but wg-quick says "already exists"
macOS maps WireGuard to utun interfaces (utun10, not wg0). status.sh uses sudo wg show (without an interface name) to correctly detect running interfaces.
Homebrew refuses to run ("Running Homebrew as root")
Do not use sudo ./install.sh. Run ./install.sh directly — the script calls sudo internally only where needed.
Contributions are welcome! Please see CONTRIBUTING.md for guidelines.
MIT — Hyeong Jun Huh