Skip to main content
  1. Wiki/
  2. Security/

Architecture: Vaultwarden Traffic Flow & IP Header Strategy

Author
Mario
Security engineer by day, homelab tinkerer by night. Building self-hosted infrastructure and documenting the journey.

Overview
#

When running a self-hosted password manager like Vaultwarden, accurate client IP logging is critical for security alerts. The “New Device Login” email should show the actual IP address of whoever just accessed your vault—not your reverse proxy’s internal IP.

This becomes tricky when you have multiple traffic paths: external users coming through Cloudflare Tunnel, and internal users coming through your local reverse proxy. Each path uses different mechanisms to communicate the real client IP.

This post documents the architecture I use to solve this: unified IP headers across both traffic paths.

The Problem
#

After migrating Vaultwarden’s external access from Cloudflare Proxy (orange cloud DNS) to Cloudflare Tunnel with direct backend routing, my “New Device Login” alerts started showing the tunnel container’s IP instead of the real client IP.

Before (broken):

1
2
3
New device logged in:
  IP: <TUNNEL_IP>  ← Wrong! This is my tunnel container
  Device: Chrome on Windows

After (fixed):

1
2
3
New device logged in:
  IP: 203.0.113.50  ← Correct! Real public IP
  Device: Chrome on Windows

Architecture
#

I use a dual-path architecture where external and internal traffic take completely different routes:

Dual-Path Traffic Flow

Why Direct Tunnel Routing?
#

The key design decision: CF Tunnel routes directly to Vaultwarden, bypassing Caddy.

Why?

  1. Resilience: Password manager is critical infrastructure. If Caddy has issues (NFS mount failure, configuration drift, etc.), Vaultwarden stays accessible externally.

  2. Fewer dependencies: Each additional component in the chain is a potential failure point.

  3. Performance: One less network hop for external requests.

The trade-off is complexity in IP header handling, which this architecture solves.

The IP Header Strategy
#

The Challenge
#

Traffic PathHeader SourceOriginal Header
External (CF Tunnel)Cloudflare EdgeCF-Connecting-IP
Internal (Caddy)CaddyX-Real-IP

Vaultwarden only reads one header name via its IP_HEADER environment variable. Two different headers = inconsistent IP logging.

The Solution
#

Standardize on CF-Connecting-IP for both paths.

External traffic already has this header set by Cloudflare. For internal traffic, configure Caddy to set the same header:

1
2
3
4
5
vault.loc.<YOUR_DOMAIN> {
    reverse_proxy http://<VAULTWARDEN_IP>:80 {
        header_up CF-Connecting-IP {remote_host}
    }
}

Then configure Vaultwarden to read it:

1
IP_HEADER=CF-Connecting-IP

Now both paths use identical header logic.

Security Considerations
#

Trust Boundaries
#

Trust boundaries showing CF-Connecting-IP header flow

Only trusted components should set the CF-Connecting-IP header:

  • Cloudflare Edge: Overwrites any incoming CF-Connecting-IP header (prevents spoofing)
  • Your reverse proxy: Only receives traffic from trusted sources

External requests cannot spoof this header because:

  1. Cloudflare overwrites it at the edge
  2. Direct access to Vaultwarden is blocked at the firewall
  3. The tunnel only accepts authenticated Cloudflare traffic

Why This Matters for Password Managers
#

Accurate IP logging enables:

  • Login alerts: Know where access attempts originate
  • Audit trails: Security investigations need real IPs
  • Rate limiting: Per-client-IP rate limits actually work
  • Future geo-blocking: Block entire regions if needed

Failover Behavior
#

The architecture provides independent failover for each path:

FailureExternal ImpactInternal Impact
CF Tunnel downFails over to replica (<10s)None
Caddy downNoneFails over via VRRP (~2s)
Both Caddy nodes downNoneInternal access down

This is the key benefit: critical external access doesn’t depend on your reverse proxy.

Rollback Plan
#

If issues arise, the previous configuration is documented inline:

1
2
3
4
5
6
7
# Vaultwarden .env
# Previous: IP_HEADER=X-Real-IP
IP_HEADER=CF-Connecting-IP

# Caddy config
# Previous: header_up X-Real-IP {remote_host}
header_up CF-Connecting-IP {remote_host}

Uncomment the previous lines and restart services to revert.

Key Takeaways
#

  1. Direct routing for critical services: Don’t chain your password manager through unnecessary proxies
  2. Unified headers: Pick one header name and configure all traffic paths to use it
  3. Trust boundaries matter: Only let trusted components set client IP headers
  4. Document rollback: Keep previous config commented for emergency reversion
  5. Test from external: Always verify IP logging works from outside your network

What I’d Do Differently
#

Nothing major, but I’d:

  • Add this header unification to my standard service onboarding checklist
  • Consider documenting the CF Tunnel routing decisions earlier (I discovered the direct routing was in place only when debugging)