Files
portainer-core/docs/mesh-access-strategy.md
T

16 KiB

Mesh Network Access Strategy (Option B)

Hybrid approach: Public access for media/files, VPN-only for admin tools All VPN access uses Headscale mesh IPs (10.99.0.x) Created: 2025-11-11

Core Principles

RULE: All external/public access MUST route through NPM proxy

Why this rule is mandatory:

  • Let's Encrypt SSL: Automatic certificate management in one place
  • Unified logging: All external access logged in NPM
  • Security headers: Consistent security policy (HSTS, CSP, etc.)
  • Access control: Single point to manage public access
  • DDoS protection: Can add Cloudflare/rate limiting at proxy level
  • No port sprawl: Only ports 80/443 exposed externally

Access Patterns:

  • Internal/VPN access: Direct mesh IPs → http://10.99.0.1:8096
  • External/Public access: Through NPM → https://media.schweitz.net → NPM forwards to mesh IP
  • NEVER: Direct port forwarding to services (except NPM and Headscale)

Architecture Overview

┌─────────────────────────────────────────────────────────┐
│                    Internet Users                        │
└──────────────────┬──────────────────┬───────────────────┘
                   │                  │
        ┌──────────▼────────┐  ┌─────▼──────────────────┐
        │   Public Access   │  │   Headscale VPN        │
        │   (Port 443)      │  │   (Port 8085)          │
        └──────────┬────────┘  └─────┬──────────────────┘
                   │                  │
                   │           ┌──────▼──────────────────┐
                   │           │  VPN Mesh Network       │
                   │           │  10.99.0.0/16           │
                   │           │                         │
                   │           │  tower-of-joy: 10.99.0.1│
                   │           │  laptop: 10.99.0.2      │
                   │           │  phone: 10.99.0.3       │
                   │           └──────┬──────────────────┘
                   │                  │
         ┌─────────▼──────────────────▼─────────────────┐
         │        tower-of-joy Services                  │
         │  ┌────────────────────────────────────────┐  │
         │  │ Public Services (via NPM)              │  │
         │  │ - Jellyfin (media)                     │  │
         │  │ - Nextcloud (files)                    │  │
         │  │ - Organizr (optional)                  │  │
         │  └────────────────────────────────────────┘  │
         │  ┌────────────────────────────────────────┐  │
         │  │ VPN-Only Services (mesh IPs)           │  │
         │  │ - Portainer: 10.99.0.1:8001            │  │
         │  │ - Netdata: 10.99.0.1:19999             │  │
         │  │ - Uptime Kuma: 10.99.0.1:3001          │  │
         │  │ - NPM Admin: 10.99.0.1:81              │  │
         │  │ - Heimdall: 10.99.0.1:8888             │  │
         │  └────────────────────────────────────────┘  │
         └───────────────────────────────────────────────┘

Service Access Matrix

Service Mesh IP Access Public Access Use Case
Organizr http://10.99.0.1:9999 https://home.schweitz.net Unified dashboard
Portainer http://10.99.0.1:8001 VPN ONLY Container management
Netdata http://10.99.0.1:19999 VPN ONLY System metrics
Uptime Kuma http://10.99.0.1:3001 VPN ONLY Service monitoring
Heimdall http://10.99.0.1:8888 VPN ONLY Alternative dashboard
NPM Admin http://10.99.0.1:81 NEVER Proxy config
Headscale http://10.99.0.1:8085 Public :8085 VPN control plane
Jellyfin http://10.99.0.1:8096 https://media.schweitz.net Media streaming
Nextcloud http://10.99.0.1:8082 https://cloud.schweitz.net File storage
Ollama http://10.99.0.1:11434 VPN ONLY ML API

Note: Mesh IP 10.99.0.1 is assumed for tower-of-joy. Actual IP will be assigned by Headscale.

Implementation Steps

Phase 1: Connect tower-of-joy to Headscale

First, get the server onto its own VPN mesh:

# Install Tailscale client on tower-of-joy
curl -fsSL https://tailscale.com/install.sh | sh

# Connect to your Headscale server
sudo tailscale up --login-server=http://192.168.86.149:8085 \
  --authkey=<your-preauth-key> \
  --hostname=tower-of-joy

# Verify connection
tailscale status
# Should show: tower-of-joy with mesh IP (e.g., 10.99.0.1)

# Get the mesh IP assigned to tower-of-joy
tailscale ip -4
# Note this IP - you'll use it in Organizr configuration

Verify from Headscale:

# List all nodes in mesh
docker exec headscale headscale nodes list

# Should show:
# ID | Name           | IP         | Last Seen
# 1  | tower-of-joy   | 10.99.0.1  | now

Phase 2: Deploy Organizr

# Create config directory
mkdir -p ~/docker-data/organizr

# Deploy Organizr
docker compose -f stacks/organizr.yml up -d

# Verify running
docker ps | grep organizr

Phase 3: Configure Organizr with Mesh IPs

Access Organizr setup:

Complete setup wizard:

  1. Choose installation type: "Personal"
  2. Create admin user
  3. Set timezone: Europe/Amsterdam
  4. Complete setup

Add tabs using mesh IPs:

Navigate to: Settings → Tab Editor

Tab: Portainer

Tab Name: Portainer
Tab URL: http://10.99.0.1:8001
Tab Type: iframe
Category: Admin
Icon: docker
Enabled: Yes
Active: Yes

Tab: Netdata

Tab Name: Netdata
Tab URL: http://10.99.0.1:19999
Tab Type: iframe
Category: Monitoring
Icon: line-chart
Enabled: Yes

Tab: Uptime Kuma

Tab Name: Uptime
Tab URL: http://10.99.0.1:3001
Tab Type: iframe
Category: Monitoring
Icon: heartbeat
Enabled: Yes

Tab: Heimdall

Tab Name: Dashboard
Tab URL: http://10.99.0.1:8888
Tab Type: iframe
Category: Home
Icon: th
Enabled: Yes

Tab: Jellyfin (when deployed)

Tab Name: Media
Tab URL: http://10.99.0.1:8096
Tab Type: iframe
Category: Apps
Icon: film
Enabled: Yes

Tab: Nextcloud (when deployed)

Tab Name: Cloud
Tab URL: http://10.99.0.1:8082
Tab Type: iframe
Category: Apps
Icon: cloud
Enabled: Yes

Phase 4: Configure NPM for Public Access

Only expose these services publicly:

Access NPM admin: http://10.99.0.1:81 (via VPN)

1. Organizr (Public Dashboard)

Proxy Host Configuration:
Domain Names: home.schweitz.net
Scheme: http
Forward Hostname/IP: 10.99.0.1
Forward Port: 9999
✓ Block Common Exploits
✓ Websockets Support

SSL Tab:
✓ Force SSL
✓ HTTP/2 Support
✓ HSTS Enabled
Request New SSL Certificate (Let's Encrypt)

2. Jellyfin (Public Media)

Proxy Host Configuration:
Domain Names: media.schweitz.net
Scheme: http
Forward Hostname/IP: 10.99.0.1
Forward Port: 8096
✓ Block Common Exploits
✓ Websockets Support

SSL Tab:
✓ Force SSL
✓ HTTP/2 Support
Request New SSL Certificate (Let's Encrypt)

3. Nextcloud (Public Files)

Proxy Host Configuration:
Domain Names: cloud.schweitz.net
Scheme: http
Forward Hostname/IP: 10.99.0.1
Forward Port: 8082
✓ Block Common Exploits
✓ Websockets Support

SSL Tab:
✓ Force SSL
✓ HTTP/2 Support
Request New SSL Certificate (Let's Encrypt)

Custom Nginx Configuration:
client_max_body_size 10G;  # Allow large file uploads
proxy_request_buffering off;

Phase 5: DNS Configuration

Required DNS records:

home.schweitz.net   A  <your-public-ip>
media.schweitz.net  A  <your-public-ip>
cloud.schweitz.net  A  <your-public-ip>

Or use wildcard:

*.schweitz.net      A  <your-public-ip>

Phase 6: Router Port Forwarding

CRITICAL: ONLY these ports exposed to internet:

External Port 443  → 192.168.86.149:443  (NPM HTTPS - ALL public services)
External Port 80   → 192.168.86.149:80   (NPM HTTP redirect to HTTPS)
External Port 8085 → 192.168.86.149:8085 (Headscale VPN control plane)

⚠️ NEVER forward service ports directly!

  • DO NOT forward port 8096 (Jellyfin)
  • DO NOT forward port 8082 (Nextcloud)
  • DO NOT forward port 9999 (Organizr)
  • DO NOT forward ANY service port except NPM and Headscale

Why?

  • All public services MUST go through NPM for SSL and logging
  • Direct port forwards bypass centralized security and logging
  • NPM provides unified Let's Encrypt management
  • NPM logs all external access for audit trails

Access Patterns

Scenario 1: Working from Home (Local Network)

Can access via:

Best practice: Use mesh IPs consistently for uniform experience

Scenario 2: Remote Work (Connected to Headscale VPN)

From laptop/phone on VPN:

# Verify VPN connection
tailscale status

# Access Organizr
http://10.99.0.1:9999

# All tabs work with mesh IPs:
- Portainer: http://10.99.0.1:8001
- Netdata: http://10.99.0.1:19999
- Uptime Kuma: http://10.99.0.1:3001

Accessing public services:

Scenario 3: Sharing with Family/Friends (No VPN)

Public access only:

Cannot access:

  • Admin tools (Portainer, Netdata, NPM) - VPN required
  • They need Headscale VPN for admin access

Security Configuration

Organizr Authentication

Enable auth for public access:

Settings → User Management

  • Create user accounts for family/friends
  • Configure access levels:
    • Admin: Full access to all tabs
    • User: Only media/cloud tabs visible
    • Guest: Read-only access

Restrict admin tabs to admin users only:

  • Tab Editor → each admin tab → "Minimum Authentication" → Admin

NPM Access Lists (Optional)

For extra security on public services:

Access Lists → Create "VPN Only"

Name: Headscale VPN Only
Allow: 10.99.0.0/16
Deny: all

Apply to sensitive proxy hosts if needed.

Service-Level Authentication

Each service maintains its own auth:

  • Portainer: Admin password
  • Jellyfin: User accounts
  • Nextcloud: User accounts
  • Uptime Kuma: Admin password

This is defense in depth:

  1. VPN layer (for admin tools)
  2. Organizr layer (for organizing access)
  3. Service layer (individual logins)

Connecting Other Devices

Laptop/Desktop

# Install Tailscale
curl -fsSL https://tailscale.com/install.sh | sh

# Connect to Headscale
sudo tailscale up --login-server=http://192.168.86.149:8085 \
  --authkey=<your-preauth-key> \
  --hostname=my-laptop

# Verify mesh access
curl http://10.99.0.1:9999
# Should load Organizr

Phone (Android/iOS)

  1. Install Tailscale app from store
  2. In app settings:
  3. Authenticate with pre-auth key
  4. Open browser: http://10.99.0.1:9999

Work Computer (Can't Install Software)

Use public access only:

Cannot access admin tools without VPN

Testing Checklist

Phase 1: Local Access

  • tower-of-joy connected to Headscale
  • Mesh IP assigned (10.99.0.x)
  • Can access services via mesh IP from tower-of-joy itself

Phase 2: VPN Access from Another Device

  • Connect laptop/phone to Headscale
  • Verify mesh connectivity: ping 10.99.0.1
  • Access Organizr: http://10.99.0.1:9999
  • All tabs load correctly with mesh IPs
  • Portainer accessible via mesh
  • Netdata accessible via mesh

Phase 3: Public Access

Phase 4: Security Validation

  • Admin tools (Portainer, Netdata) not accessible from public internet
  • Only exposed ports: 80, 443, 8085
  • Organizr authentication working
  • Service-level authentication working

Advantages of This Architecture

Mesh IP Benefits

Location independent: Same IPs whether at home or remote Secure by default: Admin tools only via VPN Simple routing: No complex proxy rewrites Flexible access: Public and private services coexist Future-proof: Add devices easily, IPs don't change No split-brain: One set of URLs to remember

NPM Proxy Benefits (For Public Access)

Centralized SSL: All Let's Encrypt certs in one place Unified logging: All external access logged in NPM audit log Security headers: Consistent HSTS, CSP, X-Frame-Options Access control: Add rate limiting, IP blocking at proxy level DDoS protection: Can add Cloudflare in front of NPM Port efficiency: Only 2 ports exposed (80, 443)

Compliance & Auditing

Audit trail: NPM logs all external access attempts SSL compliance: Automatic certificate renewal Security posture: Single point to review/harden public access Change management: Proxy config changes tracked in one place

Troubleshooting

Can't connect to mesh IPs

Check:

# Verify Tailscale running
sudo systemctl status tailscaled

# Check mesh status
tailscale status

# Test connectivity
ping 10.99.0.1

Organizr tabs not loading

Issue: Service blocking iframe embedding Solution:

  • Check browser console for errors
  • Some services need X-Frame-Options configured
  • Use "pseudo tab" mode (opens in new tab instead)

Public access not working

Check:

  1. DNS resolves to your public IP: nslookup home.schweitz.net
  2. Router port forwarding configured
  3. NPM proxy host using correct mesh IP (10.99.0.1)
  4. SSL certificate valid

Headscale connection fails

Check:

  • Port 8085 accessible from internet
  • Pre-auth key still valid
  • Headscale service running: docker logs headscale

Next Actions

  1. Connect tower-of-joy to Headscale (get mesh IP)
  2. Deploy Organizr (make deploy-organizr)
  3. Configure Organizr tabs (using mesh IPs)
  4. Configure NPM (public services only)
  5. Test VPN access (from another device)
  6. Test public access (from mobile data)

This gives you the best of both worlds:

  • Secure admin access via VPN + mesh IPs
  • Public access for media/files (family/friends)
  • Single Organizr dashboard for everything
  • No complex proxy rewrites
  • Easy to add new devices