Code From Anywhere: Remote Development With Your iPhone and Claude Code

Published on January 24, 2026 · Updated January 24, 2026

Last week I found myself at a coffee shop with two hours to kill and only my iPhone. I had a feature I wanted to build. So I pulled out my phone, opened Blink, connected to my Mac at home, and started coding.

Not "reviewing code" or "checking logs" - actual development. Writing code with Claude Code, running my dev server, viewing the app in Safari, iterating. The full feedback loop, from a 6-inch screen.

This post is the complete setup guide. If you've ever wanted to code from literally anywhere - your phone, a tablet, a borrowed laptop - this is how.

What We're Building

The goal is simple: code it, run it, see it - from your phone.

Here's the workflow:

1. iPhone (Blink) → SSH tunnel → Mac → tmux → Claude Code
2. Start any dev server (Python, Node, Rails, etc.) on localhost
3. ngrok exposes it → View on your phone's browser
4. See changes, iterate, repeat

The infrastructure:

  • ngrok tunnels - Expose your Mac's SSH and dev servers to the internet securely
  • Traffic policies - Block bots, scanners, and rate limit requests at the edge
  • LaunchAgents - Auto-start everything on boot
  • Watchdog - Automatically restart if anything goes down
  • tmux session - Persistent Claude Code session you can attach to from anywhere

Once set up, you open Blink, type ssh mac, and you're in. Your Claude Code session is waiting exactly where you left it.

Prerequisites

Before we start:

  • macOS with Homebrew installed
  • ngrok account (free tier works, paid recommended for reserved domains)
  • Blink Shell on iPhone (or any SSH client)
  • Claude Code CLI installed
# Install required tools
brew install ngrok tmux

# Authenticate ngrok (get token from dashboard.ngrok.com)
ngrok authtoken YOUR_AUTH_TOKEN

Step 1: Reserve Your Domains and Addresses

This is where you set up your stable endpoints. Without reserved addresses, ngrok gives you random URLs that change every restart - not great for muscle memory.

In the ngrok dashboard:

  1. Reserve a TCP address for SSH (e.g., tcp://5.tcp.ngrok.io:12345)
  2. Go to: Cloud Edge → TCP Addresses → Reserve

  3. Reserve HTTP domains for your dev servers (optional but recommended)

  4. Go to: Cloud Edge → Domains
  5. Free tier: yourname-dev.ngrok-free.app
  6. Paid tier: yourapp-dev.ngrok.app or custom domain

Write these down. You'll use them in the config.

Step 2: Create a Traffic Policy

This is the security layer that runs at ngrok's edge - before traffic ever reaches your Mac. It blocks the constant barrage of WordPress scanners, SQL injection attempts, and other garbage that hits any public endpoint.

mkdir -p ~/ngrok-policies
cat > ~/ngrok-policies/default-policy.yml << 'EOF'
# Block bots, scanners, and add security headers
on_http_request:
  # Block WordPress/PHP scanners
  - name: "Block Scanner Traffic"
    expressions:
      - "req.url.path.startsWith('/wp-')"
      - "req.url.path.contains('wp-admin')"
      - "req.url.path.contains('wp-login')"
      - "req.url.path.contains('xmlrpc.php')"
      - "req.url.path.startsWith('/phpmyadmin')"
      - "req.url.path.startsWith('/pma')"
      - "req.url.path.contains('.env')"
      - "req.url.path.contains('.git')"
      - "req.url.path.endsWith('.sql')"
      - "req.url.path.endsWith('.bak')"
    actions:
      - type: "deny"
        config:
          status_code: 404

  # Rate limit API endpoints
  - name: "Rate Limit API"
    expressions:
      - "req.url.path.startsWith('/api/')"
    actions:
      - type: "rate-limit"
        config:
          name: "api_requests"
          algorithm: "sliding_window"
          capacity: 100
          rate: "1m"
          bucket_key:
            - "req.headers['x-forwarded-for']"

  # Block known scanner user-agents
  - name: "Block Scanners"
    expressions:
      - "size(req.headers['user-agent']) > 0 && req.headers['user-agent'][0].contains('sqlmap')"
      - "size(req.headers['user-agent']) > 0 && req.headers['user-agent'][0].contains('nikto')"
      - "size(req.headers['user-agent']) > 0 && req.headers['user-agent'][0].contains('nmap')"
      - "size(req.headers['user-agent']) == 0"
    actions:
      - type: "deny"
        config:
          status_code: 403

on_http_response:
  - name: "Add Security Headers"
    actions:
      - type: "add-headers"
        config:
          headers:
            X-Frame-Options: "DENY"
            X-Content-Type-Options: "nosniff"
            X-XSS-Protection: "1; mode=block"
            Referrer-Policy: "strict-origin-when-cross-origin"
EOF

This policy:
- Returns 404 for WordPress/PHP scanner paths (they'll move on)
- Rate limits API endpoints to 100 requests/minute per IP
- Blocks requests with known scanner user-agents or no user-agent
- Adds security headers to all responses

Step 3: Configure ngrok

Now create the ngrok config file that defines your tunnels:

cat > "$HOME/Library/Application Support/ngrok/ngrok.yml" << 'EOF'
version: "3"

agent:
  authtoken: YOUR_AUTH_TOKEN_HERE

endpoints:
  # SSH access (required for remote development)
  - name: mac-ssh
    url: tcp://5.tcp.ngrok.io:YOUR_RESERVED_PORT
    upstream:
      url: localhost:22

  # Dev server - view your app on your phone
  # Works with any framework: Django, Flask, Node, Rails, etc.
  # Just start your server on port 8000 and it's exposed
  - name: dev
    url: https://YOUR-RESERVED-DOMAIN.ngrok.app
    upstream:
      url: http://localhost:8000
    traffic_policy_file: /Users/YOUR_USERNAME/ngrok-policies/default-policy.yml
EOF

Replace the placeholders:
- YOUR_AUTH_TOKEN_HERE - from ngrok dashboard
- YOUR_RESERVED_PORT - your reserved TCP port
- YOUR-RESERVED-DOMAIN - your reserved domain
- YOUR_USERNAME - your macOS username

Test it:

ngrok config check

If it says "valid", you're good.

Step 4: Auto-Start ngrok on Boot

We want ngrok running as soon as we log in. LaunchAgents handle this:

cat > ~/Library/LaunchAgents/com.user.ngrok-tunnels.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.user.ngrok-tunnels</string>
    <key>ProgramArguments</key>
    <array>
        <string>/opt/homebrew/bin/ngrok</string>
        <string>start</string>
        <string>--all</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
    <key>StandardOutPath</key>
    <string>/Users/YOUR_USERNAME/Library/Logs/ngrok-tunnels.log</string>
    <key>StandardErrorPath</key>
    <string>/Users/YOUR_USERNAME/Library/Logs/ngrok-tunnels.log</string>
    <key>EnvironmentVariables</key>
    <dict>
        <key>PATH</key>
        <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
        <key>HOME</key>
        <string>/Users/YOUR_USERNAME</string>
    </dict>
</dict>
</plist>
EOF

# Replace YOUR_USERNAME with your actual username
sed -i '' "s/YOUR_USERNAME/$USER/g" ~/Library/LaunchAgents/com.user.ngrok-tunnels.plist

# Load it
launchctl load ~/Library/LaunchAgents/com.user.ngrok-tunnels.plist

The KeepAlive key means macOS will restart ngrok if it crashes. Nice.

Step 5: Create the tmux Session Script

tmux is what makes this whole thing work. Your session persists even when you disconnect - you can attach from your phone, do some work, detach, and come back later exactly where you left off.

mkdir -p ~/bin
cat > ~/bin/start-dev-session.sh << 'EOF'
#!/bin/bash
SESSION="dev"
WORKDIR="$HOME/projects"  # Change to your project directory

# Kill existing session if it exists
tmux has-session -t $SESSION 2>/dev/null && tmux kill-session -t $SESSION

# Create new session with Claude Code
tmux new-session -d -s $SESSION -n "claude" -c $WORKDIR
tmux send-keys -t $SESSION:0 "claude" C-m

# Add a terminal window
tmux new-window -t $SESSION:1 -n "terminal" -c $WORKDIR

echo "Session '$SESSION' created. Attach with: tmux attach -t $SESSION"
EOF

chmod +x ~/bin/start-dev-session.sh

This creates a tmux session with:
- Window 0: Claude Code already running
- Window 1: A regular terminal for running servers, git commands, etc.

Step 6: Auto-Start the tmux Session

Same idea as ngrok - we want the dev session ready when we log in:

cat > ~/Library/LaunchAgents/com.user.dev-session.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.user.dev-session</string>
    <key>ProgramArguments</key>
    <array>
        <string>/Users/YOUR_USERNAME/bin/start-dev-session.sh</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>StandardOutPath</key>
    <string>/Users/YOUR_USERNAME/Library/Logs/dev-session.log</string>
    <key>StandardErrorPath</key>
    <string>/Users/YOUR_USERNAME/Library/Logs/dev-session.log</string>
    <key>EnvironmentVariables</key>
    <dict>
        <key>PATH</key>
        <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
        <key>HOME</key>
        <string>/Users/YOUR_USERNAME</string>
    </dict>
</dict>
</plist>
EOF

# Replace YOUR_USERNAME
sed -i '' "s/YOUR_USERNAME/$USER/g" ~/Library/LaunchAgents/com.user.dev-session.plist

# Load it
launchctl load ~/Library/LaunchAgents/com.user.dev-session.plist

Step 7: The Watchdog

Things break. Networks hiccup. ngrok occasionally loses connection. The watchdog checks every 5 minutes and restarts things if needed.

cat > ~/bin/ngrok-watchdog.sh << 'EOF'
#!/bin/bash
# ngrok Tunnel Watchdog
# Checks if tunnels are healthy and restarts if needed
# Runs every 5 minutes via LaunchAgent

LOG_FILE="$HOME/Library/Logs/ngrok-watchdog.log"
LAUNCH_AGENT="$HOME/Library/LaunchAgents/com.user.ngrok-tunnels.plist"
API_TIMEOUT=5

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE"
}

# Trim log file if too large (keep last 1000 lines)
if [ -f "$LOG_FILE" ] && [ $(wc -l < "$LOG_FILE") -gt 2000 ]; then
    tail -1000 "$LOG_FILE" > "$LOG_FILE.tmp" && mv "$LOG_FILE.tmp" "$LOG_FILE"
fi

log "=== Watchdog check ==="

# Check if ngrok API is responding
API_RESPONSE=$(curl -s --max-time $API_TIMEOUT http://localhost:4040/api/tunnels 2>/dev/null)
CURL_EXIT=$?

if [ $CURL_EXIT -ne 0 ] || [ -z "$API_RESPONSE" ]; then
    log "ERROR: ngrok API not responding"
    API_HEALTHY=false
else
    # Check if we have tunnels
    TUNNEL_COUNT=$(echo "$API_RESPONSE" | python3 -c "
import sys, json
try:
    data = json.load(sys.stdin)
    print(len(data.get('tunnels', [])))
except:
    print(0)
" 2>/dev/null)

    if [ "$TUNNEL_COUNT" -gt 0 ]; then
        log "OK: $TUNNEL_COUNT tunnel(s) healthy"
        API_HEALTHY=true
    else
        log "ERROR: No tunnels found"
        API_HEALTHY=false
    fi
fi

# If unhealthy, restart ngrok
if [ "$API_HEALTHY" != "true" ]; then
    log "ACTION: Restarting ngrok tunnels..."

    # Kill existing ngrok processes
    pkill -f "ngrok start" 2>/dev/null
    sleep 2

    # Reload LaunchAgent
    launchctl unload "$LAUNCH_AGENT" 2>/dev/null
    sleep 2
    launchctl load "$LAUNCH_AGENT" 2>/dev/null

    # Wait and verify
    sleep 10
    VERIFY=$(curl -s --max-time $API_TIMEOUT http://localhost:4040/api/tunnels 2>/dev/null)
    if [ -n "$VERIFY" ]; then
        log "SUCCESS: Tunnels restarted"
    else
        log "ERROR: Tunnels still not responding after restart"
    fi
fi
EOF

chmod +x ~/bin/ngrok-watchdog.sh

And the LaunchAgent to run it every 5 minutes:

cat > ~/Library/LaunchAgents/com.user.ngrok-watchdog.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.user.ngrok-watchdog</string>
    <key>ProgramArguments</key>
    <array>
        <string>/Users/YOUR_USERNAME/bin/ngrok-watchdog.sh</string>
    </array>
    <key>StartInterval</key>
    <integer>300</integer>
    <key>RunAtLoad</key>
    <true/>
    <key>StandardOutPath</key>
    <string>/Users/YOUR_USERNAME/Library/Logs/ngrok-watchdog-stdout.log</string>
    <key>StandardErrorPath</key>
    <string>/Users/YOUR_USERNAME/Library/Logs/ngrok-watchdog-stderr.log</string>
</dict>
</plist>
EOF

# Replace YOUR_USERNAME
sed -i '' "s/YOUR_USERNAME/$USER/g" ~/Library/LaunchAgents/com.user.ngrok-watchdog.plist

# Load it
launchctl load ~/Library/LaunchAgents/com.user.ngrok-watchdog.plist

Step 8: Enable SSH on Your Mac

This is the one GUI step:

  1. System Settings → General → Sharing → Remote Login → Enable
  2. Set to allow access for your user

Step 9: Set Up SSH Keys

On your iPhone in Blink, generate an SSH key:

# In Blink terminal
ssh-keygen -t ed25519 -C "iphone-blink"
cat ~/.ssh/id_ed25519.pub

Copy that public key and add it to your Mac:

# On your Mac
echo "ssh-ed25519 AAAA... iphone-blink" >> ~/.ssh/authorized_keys

Keys are more secure than passwords and won't prompt you every time.

Step 10: Configure Blink

In the Blink app on your iPhone:

  1. Hosts → Add New Host
  2. Alias: mac (or whatever you want)
  3. Hostname: 5.tcp.ngrok.io (your ngrok TCP address)
  4. Port: YOUR_RESERVED_PORT
  5. User: your macOS username
  6. Key: select your SSH key

  7. Save and test: ssh mac

Using It

From your iPhone in Blink:

# Connect to your Mac
ssh mac

# Attach to the dev session
tmux attach -t dev

# You're now in Claude Code!

Detach without killing session: Ctrl+B then D

The full loop:

# 1. In Claude Code, build something

# 2. Open a new tmux window (Ctrl+B, c) and start your server:
python manage.py runserver 8000   # Django
npm run dev -- --port 8000        # Node/Vite
rails server -p 8000              # Rails

# 3. Open your phone browser to your ngrok domain
#    https://YOUR-DOMAIN.ngrok.app

# 4. See your app, go back to Claude Code, iterate

This works with any framework. Django, Flask, FastAPI, Node, Rails, Go - if it runs on localhost, ngrok can expose it.

Verify Everything Works

Back on your Mac, run these checks:

# Check ngrok is running
curl -s http://localhost:4040/api/tunnels | jq -r '.tunnels[] | "\(.name): \(.public_url)"'

# Check tmux session exists
tmux list-sessions

# Check LaunchAgents are loaded
launchctl list | grep -E "ngrok|dev-session"

# Check watchdog logs
tail -20 ~/Library/Logs/ngrok-watchdog.log

Troubleshooting

Can't SSH through tunnel:

# Check tunnel is running
curl -s http://localhost:4040/api/tunnels | jq '.tunnels[] | select(.proto=="tcp")'

# Check SSH is enabled
nc -zv localhost 22

tmux session not starting:

# Check logs
tail -50 ~/Library/Logs/dev-session.log

# Manually start
~/bin/start-dev-session.sh

ngrok not starting on boot:

# Check logs
tail -50 ~/Library/Logs/ngrok-tunnels.log

# Reload LaunchAgent
launchctl unload ~/Library/LaunchAgents/com.user.ngrok-tunnels.plist
launchctl load ~/Library/LaunchAgents/com.user.ngrok-tunnels.plist

Security Notes

A few things to keep in mind:

  • SSH uses key-only auth - Password auth works but keys are more secure
  • Traffic policies block scanners - Check the ngrok inspector at http://localhost:4040 to see what's being blocked
  • TCP tunnels can't use policies - The SSH tunnel is open to the internet; secure it with strong keys
  • Reserved addresses are stable - Your SSH endpoint won't change between restarts

Limitations

This setup does not handle:

  • System updates/restarts - You need physical or screen-sharing access to the Mac
  • Power failures - LaunchAgents are user-level; your Mac account must be logged in for them to run. Enable auto-login or use a UPS.

The Result

I've been using this setup for months now. I've written features from airports, debugged production issues from coffee shops, and done code reviews from the couch. The experience is surprisingly good - Blink's keyboard support is excellent, and Claude Code's terminal interface works great on a phone screen.

Is it as fast as sitting at my desk? No. But it's development from anywhere, and that's worth the tradeoff.


Questions about the setup? Found a way to improve it? Reach out - I'd love to hear how others are approaching mobile development.