Introduction
Traditional VPNs grant network-level access: once connected, users can reach any resource on the corporate network. This violates the zero trust principle of least privilege. ZTNA (Zero Trust Network Access) replaces network-level access with application-level, identity-aware tunnels — users authenticate to specific applications, not to the network.
This guide covers two practical ZTNA implementations — Cloudflare Tunnel (managed, SaaS-integrated) and WireGuard + Pomerium (self-hosted) — with configuration examples, Mermaid architecture comparison, policy-as-code templates, and a migration strategy from legacy VPN infrastructure.
Architecture: VPN vs ZTNA
flowchart LR
subgraph TraditionalVPN["Traditional VPN"]
U1[User] -->|Full network access| VPN[VPN Concentrator]
VPN -->|All internal IPs| App1[App 1: CRM]
VPN -->|All internal IPs| App2[App 2: HR System]
VPN -->|All internal IPs| App3[App 3: Database]
U2[Attacker] -->|Exploit VPN access| VPN
end
subgraph ZTNA["Zero Trust (ZTNA)"]
U3[User] -->|Auth via IdP| Proxy[Identity-Aware Proxy]
Proxy -->|Application-specific tunnel| ZApp1[App 1: CRM]
Proxy -.-x|No access| ZApp2[App 2: HR System]
Proxy -.-x|No access| ZApp3[App 3: Database]
U4[Attacker] -->|Unauthenticated| Proxy
Proxy -->|Blocked ✗| U4
end
Key differences:
- VPN: Authenticates to the network. Trusted = can reach anything.
- ZTNA: Authenticates to each application. Trusted = can reach only that application.
ZTNA Implementation 1: Cloudflare Tunnel + Access
Cloudflare Tunnel creates encrypted outbound-only connections from your server to Cloudflare’s edge. No open inbound ports. Cloudflare Access enforces identity-aware authentication before allowing requests through the tunnel.
Install and configure on your origin server:
# Install cloudflared
sudo apt install cloudflared
# Authenticate the tunnel
cloudflared tunnel login
# Create a tunnel
cloudflared tunnel create my-app-tunnel
# Configure tunnel routing
cloudflared tunnel route dns my-app-tunnel app.internal.example.com
Tunnel Configuration File
# ~/.cloudflared/config.yml
tunnel: my-app-tunnel
credentials-file: /home/user/.cloudflared/my-app-tunnel.json
ingress:
# Route /api to internal API service
- hostname: app.internal.example.com
service: http://localhost:3000
path: /api/*
# Route everything else to the web app
- hostname: app.internal.example.com
service: http://localhost:3000
# Block all other traffic
- service: http_status:404
Cloudflare Access Policy (Identity-Aware)
Configured in the Cloudflare dashboard or via API:
# Create a Zero Trust policy that requires:
# 1. Valid email from your domain
# 2. Hardware key (WebAuthn)
# 3. Device posture check (OS updated, disk encrypted)
# Equivalent curl command (via Cloudflare API):
curl -X POST https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/access/policies \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "Internal App Access",
"decision": "allow",
"include": [{"email_domain": {"domain": "example.com"}}],
"require": [
{"auth_method": {"auth_method": "hwk"}},
{"device_posture": {"integration_uid": "$POSTURE_ID"}}
]
}'
Run the Tunnel
# Run as a service
sudo cloudflared service install
# No open inbound ports!
# The server connects outbound to Cloudflare edge (port 7844 UDP).
# Users connect to app.internal.example.com → Cloudflare → tunnel → localhost:3000
ZTNA Implementation 2: WireGuard + Pomerium
Pomerium is an open-source identity-aware proxy that works over any network, including WireGuard tunnels:
# WireGuard config: create a tunnel for the application backend
# /etc/wireguard/app-backend.conf
[Interface]
Address = 10.1.0.1/32
PrivateKey = <server-private-key>
[Peer]
# Pomerium proxy connects here
PublicKey = <pomerium-public-key>
AllowedIPs = 10.1.0.2/32
Pomerium Configuration
# config.yaml — Pomerium identity-aware proxy
authenticate_service_url: https://authenticate.example.com
# Route: only /crm/* requires authentication
policy:
- from: https://crm.example.com
to: http://10.1.0.2:8080
allowed_users:
- [email protected]
- [email protected]
allow_public_unauthenticated_access: false
cors_allow_preflight: true
timeout: 30s
# Additional security: require re-auth every 15 minutes
idle_timeout: 15m
- from: https://wiki.example.com
to: http://10.1.0.2:9090
allowed_domains:
- example.com
allow_public_unauthenticated_access: false
Comparison: Traditional VPN vs ZTNA
| Aspect | Traditional VPN | ZTNA (Cloudflare/Pomerium) |
|---|---|---|
| Access model | Network-level (IP) | Application-level (URL) |
| Authentication | At connection time | Per-request, continuous |
| Attack surface | Public ports open | No inbound ports |
| Latency | Traffic backhauled | Edge-proxied (Cloudflare) |
| Scalability | Appliance capacity bound | Scales with edge network |
| Migration effort | — | Moderate (app per app) |
| Monitoring | VPN appliance logs | Per-request audit logs |
Migration Strategy
- Inventory: List all applications currently accessed via VPN.
- Prioritize: Start with the most critical and frequently accessed apps.
- Tunnel: Deploy Cloudflare Tunnel or Pomerium in front of one app at a time.
- Test: Access the app via ZTNA while keeping the VPN as fallback.
- Cut over: Remove VPN route for the app once ZTNA is validated.
- Repeat: Continue application by application.
# During migration: run VPN and ZTNA in parallel
# Keep VPN for remaining apps, add ZTNA for migrated apps
# Example: route /crm through ZTNA, everything else through VPN
# DNS-based split:
# crm.example.com → Cloudflare Tunnel (ZTNA)
# *.legacy.example.com → VPN DNS resolution
Resources
- Cloudflare Tunnel Documentation — Zero Trust network setup
- Pomerium Documentation — Open-source identity-aware proxy
- WireGuard ZTNA Patterns — Network namespace isolation
- Google BeyondCorp Whitepaper — Original ZTNA architecture design
- NIST SP 800-207 Zero Trust Architecture
Comments