A practical, reproducible Zero‑Trust pattern for self-hosted Cloud behind Cloudflare. Traffic is forced through proxied DNS, mTLS at the edge, Cloudflare Access (OTP/session), and an egress‑only Cloudflare Tunnel to an origin where Nginx front-ends Cloud in Docker with explicit trust anchors and micro‑segmented backends.
- Architecture Overview
- Core Benefits (OSI × Defense-in-Depth)
- Environment & Assumptions
- Clean Runbook (Step-by-Step)
- Verification & Tests
- Security Notes & Best Practices
- Appendix: Example Cloudflare Rules
Clients → Cloudflare Edge → Cloudflare Tunnel → Nginx → Cloud → (Redis, DB, etc)
- Browser flow:
cloud.example.com→ Edge mTLS → Access (OTP) → Tunnel → Nginx → usr MFA → Cloud. - Sync apps:
sync.example.com→ Edge mTLS → bypass Access (policy-controlled) → Tunnel → Nginx → usr MFA → Cloud. - Public shares:
share.example.com→ Edge bypass/mild policy → Tunnel → Nginx → usr MFA → Cloud. - LAN maintenance:
https://192.168.178.1:1011→ Client root CA → Nginx → usr MFA → Cloud (allowlisted viaDOCKER-USER).
Origin exposure is eliminated: no inbound ports on the host, deny-by-default at every hop.
- Edge-only perimeter (L7) — origin IP hidden; proxied DNS enforces single choke point.
- Device identity first (L5/L6 TLS with mTLS) — proof-of-possession before app contact; optional serial allowlist.
- User identity & context (L7 Access) — OTP/MFA, IdP groups, posture/WARP; no session ⇒ no tunnel.
- Egress-only path (L3/L4) — Cloudflare Tunnel to
https://127.0.0.1:1011; fail-closed if tunnel drops. - Reverse-proxy boundary (L7) — Nginx isolates the app, sets
X-Forwarded-*, HSTS, limits/timeouts. - Explicit app trust (L7) — Cloud
trusted_proxies,trusted_domains,overwriteprotocol=https. - Micro-segmented backends (L4–L7) — Redis/DB/... on private Docker network; least-privilege communication only.
- DNS & Proxy: Managed in Cloudflare; records proxied (orange cloud).
- Hostnames:
cloud.example.com(browser),sync.example.com(apps),share.example.com(public links). - Origin: Dockerized Cloud behind Nginx bound to
127.0.0.1:1011and (optionally)192.168.178.1:1011for LAN. - Tunnel: Cloudflare Tunnel with ingress →
https://127.0.0.1:1011(internal only,noTLSVerify: true).
Replace
example.comwith your domain (e.g.,sine-math.com).
- Create/Import Zone in Cloudflare; verify NS setup at registrar.
- Add A/AAAA/CNAME for
cloud,sync,share→ proxied (orange cloud). - (Optional) Keep
@/wwwDNS-only if you host a separate landing page.
- Install cloudflared (package or Docker) on the origin host.
- Authenticate:
cloudflared tunnel login→ create tunnel (e.g.,cloud). - Config
/etc/cloudflared/config.yml(or mounted file) with ingress:tunnel: cloud credentials-file: /etc/cloudflared/<TUNNEL_ID>.json ingress: - hostname: cloud.example.com service: https://127.0.0.1:1011 originRequest: { noTLSVerify: true } - hostname: sync.example.com service: https://127.0.0.1:1011 originRequest: { noTLSVerify: true } - hostname: share.example.com service: https://127.0.0.1:1011 originRequest: { noTLSVerify: true } - service: http_status:404
- Route DNS to tunnel (Cloudflare dashboard or
cloudflared tunnel route dns …). - Run as service (
cloudflared service installor Dockerrestart: unless-stopped).
- Bind ports to loopback and LAN only:
127.0.0.1:1011,192.168.178.1:1011. - TLS at Nginx for local hop; set headers & security defaults:
server { listen 1011 ssl; http2 on; server_name _; ssl_certificate /etc/nginx/certs/cloud.crt; ssl_certificate_key /etc/nginx/certs/cloud.key; add_header Strict-Transport-Security "max-age=15768000; includeSubDomains; preload" always; location / { proxy_pass http://cloud-app:80; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Port $server_port; proxy_redirect off; client_max_body_size 0; } }
- Firewall (DOCKER-USER) — allow loopback and LAN, drop others:
sudo iptables -I DOCKER-USER -i lo -p tcp --dport 1011 -j ACCEPT sudo iptables -I DOCKER-USER -s 192.168.178.0/24 -p tcp --dport 1011 -j ACCEPT sudo iptables -A DOCKER-USER -p tcp --dport 1011 -j DROP
- Trusted domains (inside the container, as
www-data):php occ config:system:set trusted_domains 0 --value=cloud.example.com php occ config:system:set trusted_domains 1 --value=sync.example.com php occ config:system:set trusted_domains 2 --value=192.168.178.1 php occ config:system:set trusted_domains 3 --value=192.168.178.1:1011
- Trusted proxy (Nginx container IP & loopback) + headers:
php occ config:system:set trusted_proxies 0 --value=<nginx_container_ip> php occ config:system:set trusted_proxies 1 --value=127.0.0.1 php occ config:system:set forwarded_for_headers 0 --value=HTTP_CF_CONNECTING_IP php occ config:system:set forwarded_for_headers 1 --value=HTTP_X_FORWARDED_FOR php occ config:system:set overwriteprotocol --value=https
- mTLS — Enable Client Certificates Required for
cloud.example.com. - Client CA — Use Cloudflare’s client CA or upload your own.
- Serial allowlist via WAF rule (see Appendix).
- Access Application (Self-hosted) for
cloud.example.com:- Login methods: OTP (or your IdP).
- Session duration: per policy.
- Cookies: HTTPOnly; SameSite=Lax/Strict; optionally binding cookie.
- Bypass policy for
sync.example.com(apps), gated by device/WARP/mTLS as needed. - Mild policy for
share.example.com(public links).
cloudflaredstatus/logs on the host; Zero Trust → Logs for Access/mTLS decisions.- Nginx
nginx -tand logs; Cloud logs for trusted domain/proxy issues.
-
mTLS block (no client cert):
curl -Ik https://cloud.example.com # Expect: 403 at edge -
mTLS pass + Access redirect (with P12):
curl -Ik --cert client.p12 --cert-type P12 --pass "<p12_password>" https://cloud.example.com # Expect: 302 → /cdn-cgi/access/login/...
-
LAN path (no Cloudflare):
curl -Ik https://192.168.178.1:1011/ # Expect: 302 → /login -
Sync path (bypass Access): App connects to
https://sync.example.comunder your policy (mTLS/device posture), then Tunnel → Nginx → Cloud.
- Deny-by-default everywhere; each hop verifies explicitly.
- No inbound origin ports; egress-only Tunnel fail-closed.
- One device, one cert; consider non-exportable keys (TPM/Keychain).
- Whitelist Issuer + Serial if you use serial filters; rotate on renewal.
- Keep Cloud trust anchors tight; watch audit logs and headers.
- Backups/restore paths separated from runtime network.
Block unless mTLS is verified & serial is allowlisted (optional serial clause):
(http.host eq "cloud.example.com"
and not (
cf.tls_client_auth.cert_verified
and lower(cf.tls_client_auth.cert_serial) in {"123456789123456789123456789"}
)
)Variant with Issuer check (safer):
(http.host eq "cloud.example.com"
and not (
cf.tls_client_auth.cert_verified
and lower(cf.tls_client_auth.cert_issuer_dn) contains "your client ca name"
and lower(cf.tls_client_auth.cert_serial) in {"123456789123456789123456789"}
)
)Replace
example.comaccordingly; adjust the allowlist to your actual serial(s) and issuer.