Infrastructure: Full API Isolation via Cloudflare Tunnel
Problem with the current setup
All services share a VPS with published Docker ports. Three weaknesses compound each other:
- IP bypass risk — even with CF Proxy (orange cloud), the VPS still listens on public ports. If the real IP leaks (DNS history, old A records, misconfigured service), an attacker can bypass CF WAF, rate limits, and Access policies entirely:
curl --resolve api.verifluence.io:443:<VPS_IP> https://api.verifluence.io/api/... - Admin secret in the bundle —
VITE_ADMIN_SECRETis baked into the admin panel JS at build time. Anyone who inspects the deployed bundle obtains the secret. - No process boundary — admin routes and public API routes share one process and one port. A single exploit in any route potentially exposes all routes.
Proposed architecture
Key property
cloudflared makes outbound connections to Cloudflare's PoPs. The VPS has no inbound ports — there is no socket to connect to, even if the IP is known. CF WAF, rate limits, and Access policies cannot be bypassed.
Traffic flow
| Origin | Route | Auth at edge |
|---|---|---|
app.verifluence.io | CF Pages → CF Tunnel → api:3000 | None (IS-9 session inside API) |
admin.verifluence.io | CF Pages → CF Access → CF Tunnel → admin-api:3002 | Google SSO — @verifluence.io only |
| Kick webhook | kick.verifluence.io → CF Tunnel → kick-server:3000 | Kick HMAC signature in handler |
| Postgres (ops) | SSH tunnel → 127.0.0.1:5432 | SSH key |
What changes
1. docker-compose.yml
All ports: replaced with expose: — nothing published to the host. Postgres bound to 127.0.0.1 for SSH-tunnel maintenance access only.
api:
expose: ["3000"] # was ports: ["3000:3000"]
kick-server:
expose: ["3000"] # was ports: ["3001:3000"]
db:
ports:
- "127.0.0.1:5432:5432" # loopback only — SSH tunnel for maintenance
admin-api: # new service
expose: ["3002"]
cloudflared: # new service
image: cloudflare/cloudflared:latest
restart: unless-stopped
command: tunnel --no-autoupdate run
environment:
TUNNEL_TOKEN: ${CF_TUNNEL_TOKEN}2. Tunnel ingress (CF dashboard or config.yml)
ingress:
- hostname: api.verifluence.io
service: http://api:3000
- hostname: admin-api.verifluence.io
service: http://admin-api:3002
- hostname: kick.verifluence.io
service: http://kick-server:3000
- service: http_status:4043. New admin-server.ts entry point
A second Hono server sharing the same codebase. Moves these routes out of server.ts:
POST /api/auth/impersonate— create one-time impersonation tokenGET /api/streams+GET /api/streams/:id/probes— admin stream inspection- Admin-write paths on
/api/operatorsand/api/invitations
Replaces X-Admin-Secret with CF Access JWT validation:
// CF Access injects this header on every proxied request.
// Verified against the CF Access public key — no shared secret needed.
const jwt = request.headers.get("Cf-Access-Jwt-Assertion");
const { payload } = await verifyCloudflareAccessJwt(jwt, env.CF_ACCESS_TEAM_DOMAIN);
if (!payload?.email?.endsWith("@verifluence.io")) return errorResponse("Forbidden", 403);4. Dockerfile — new admin-api target
FROM base AS admin-api
COPY migrations/ ./migrations/
CMD ["node", "dist/admin-server.js"]5. CF Zero Trust setup (one-time, dashboard)
- Tunnel — create a named tunnel → copy
TUNNEL_TOKEN→ add to VPS.env - DNS — CF dashboard creates CNAME records pointing each hostname at the tunnel automatically
- Access Application on
admin-api.verifluence.io- Policy: Allow — email ends in
@verifluence.io(Google Identity) - CF injects
Cf-Access-Jwt-Assertionon every request that passes the gate
- Policy: Allow — email ends in
- Service token (optional) — for CI/CD calls that can't do browser SSO
6. Admin panel
VITE_API_URL → VITE_ADMIN_API_URL=https://admin-api.verifluence.io
VITE_ADMIN_SECRET → (removed entirely)Reliability: cloudflared is not a SPOF
By default cloudflared opens 4 parallel connections to different CF PoPs. A single connection drop is invisible to clients — CF retries on the remaining connections. The only hard failure is the cloudflared process itself, which Docker restarts instantly (restart: unless-stopped).
For additional resilience, run two cloudflared containers pointing at the same tunnel token:
cloudflared-1:
image: cloudflare/cloudflared:latest
restart: unless-stopped
command: tunnel --no-autoupdate run
environment:
TUNNEL_TOKEN: ${CF_TUNNEL_TOKEN}
cloudflared-2:
image: cloudflare/cloudflared:latest
restart: unless-stopped
command: tunnel --no-autoupdate run
environment:
TUNNEL_TOKEN: ${CF_TUNNEL_TOKEN}Both connect independently to the same tunnel. CF load-balances across all 8 connections (4 per daemon). A full daemon crash causes at most a few seconds of elevated error rate while Docker restarts it.
Security comparison
| Before | After | |
|---|---|---|
| VPS inbound ports | 3000, 3001 public | none |
| IP bypass possible | ✅ yes | no |
| Admin secret exposure | Shared secret in JS bundle | CF Access JWT — no secret |
| Admin auth boundary | X-Admin-Secret header check | CF Zero Trust (Google SSO) |
| Process isolation | Single process for all routes | Separate process for admin |
| DB reachable from internet | ✅ yes (port 5432 published) | no (loopback only) |
| WAF bypass possible | ✅ via direct IP | no |
Environment variables
| Variable | Service | Purpose |
|---|---|---|
CF_TUNNEL_TOKEN | VPS .env | Authenticates the cloudflared daemon to CF |
CF_ACCESS_TEAM_DOMAIN | admin-api container | e.g. verifluence.cloudflareaccess.com — fetches Access public key for JWT verification |
ADMIN_SECRET | (removed) | Replaced by CF Access JWT |
VITE_ADMIN_SECRET | (removed) | Replaced by CF Access JWT |
Effort estimate
| Task | Estimate |
|---|---|
docker-compose.yml — remove ports, add admin-api + cloudflared | 30 min |
| CF Tunnel + DNS + Access policy (dashboard) | 30 min |
admin-server.ts — extract admin routes + CF Access JWT verification | 2 h |
Dockerfile admin-api target | 30 min |
Admin panel — VITE_ADMIN_API_URL, remove secret | 30 min |
CI/CD — add admin-api image build + deploy | 1 h |
| Total | ~5 h |