Skip to content

Instantly share code, notes, and snippets.

@jmanhype
Created October 10, 2025 16:15
Show Gist options
  • Save jmanhype/8fadb5df9854329da7165ccdfde6963d to your computer and use it in GitHub Desktop.
Save jmanhype/8fadb5df9854329da7165ccdfde6963d to your computer and use it in GitHub Desktop.
Cloudflare Tunnel Setup - Complete Guide for Docker + Proxmox with Vite Host Checking Solution

Cloudflare Tunnel Setup - Complete Guide

Overview

This document details the complete setup of Cloudflare Tunnel for exposing a Dockerized React + Express application running in Proxmox LXC #100.

Public URLs:

  • Frontend: https://viable-system.com
  • Backend API: https://api.viable-system.com

Architecture

Internet → Cloudflare Edge → Tunnel (cybertask-production) → LXC #100 → Docker Containers
                                                                         ├─ Frontend (port 3000)
                                                                         └─ Backend (port 3001)

Infrastructure Details

Proxmox Host

  • Host: 192.168.1.123
  • User: root@pam
  • LXC Container: #100

Cloudflare Tunnel

  • Tunnel ID: 0a4c6c56-dda6-4dd4-bfd4-44655bf71673
  • Tunnel Name: cybertask-production
  • Connections: 4 active connections to Cloudflare edge
  • Service Status: Running via systemd

Docker Containers (in LXC #100)

  • Location: /opt/cybertask/
  • Frontend: jmanhype/cybertask-frontend:latest (React app)
  • Backend: jmanhype/cybertask-backend:latest (Express API)
  • Database: postgres:15-alpine
  • Cache: redis:7-alpine

The Problem: Vite Host Checking

Initial Issue

The original frontend container used vite preview which has strict host checking. When accessed via viable-system.com, it returned:

HTTP/2 403 Forbidden
Blocked request. This host ("viable-system.com") is not allowed.

Why It Happened

Vite's preview server blocks requests from domains it doesn't recognize as a security measure. The standard solution would be to configure vite.config.js with:

preview: {
  host: true,
  allowedHosts: ["viable-system.com"]
}

The Challenge

Attempts to add this configuration failed due to:

  1. Volume mount permission issues: Vite needs write access to create .timestamp-*.mjs files
  2. Image rebuild timeouts: chown -R on entire /app directory took too long
  3. Network timeouts: Pulling nginx images failed
  4. Module resolution issues: Config files in /tmp couldn't resolve vite package

The Solution: Custom Node.js Static Server

Instead of fighting with Vite's configuration, I created a simple Node.js HTTP server that:

  • ✅ Serves static files from /app/dist
  • ✅ Accepts requests from any domain (no host checking)
  • ✅ Provides proper MIME types
  • ✅ Implements SPA routing (all routes → index.html)
  • ✅ Enables CORS

Implementation

1. Static Server Code

Created /opt/cybertask/static-server.js:

const http = require("http");
const fs = require("fs");
const path = require("path");

const PORT = 3000;
const ROOT = "/app/dist";

const MIME_TYPES = {
  ".html": "text/html",
  ".js": "application/javascript",
  ".css": "text/css",
  ".json": "application/json",
  ".png": "image/png",
  ".jpg": "image/jpeg",
  ".gif": "image/gif",
  ".svg": "image/svg+xml",
  ".ico": "image/x-icon"
};

const server = http.createServer((req, res) => {
  let filePath = path.join(ROOT, req.url === "/" ? "index.html" : req.url);

  // SPA fallback - if file doesn't exist, serve index.html
  if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) {
    filePath = path.join(ROOT, "index.html");
  }

  const ext = path.extname(filePath);
  const contentType = MIME_TYPES[ext] || "application/octet-stream";

  fs.readFile(filePath, (err, data) => {
    if (err) {
      res.writeHead(404);
      res.end("Not Found");
    } else {
      res.writeHead(200, {
        "Content-Type": contentType,
        "Access-Control-Allow-Origin": "*"
      });
      res.end(data);
    }
  });
});

server.listen(PORT, "0.0.0.0", () => {
  console.log("Static server running on port", PORT);
});

2. Docker Compose Override

Created /opt/cybertask/docker-compose.override.yml:

version: "3.8"

services:
  frontend:
    volumes:
      - /opt/cybertask/static-server.js:/static-server.js:ro
    command: ["node", "/static-server.js"]

3. Cloudflare Tunnel Routes

Configured in Cloudflare Dashboard → Networks → Tunnels → cybertask-production:

Public Hostname Type Service
viable-system.com HTTP localhost:3000
api.viable-system.com HTTP localhost:3001

4. DNS Configuration

Both domains have CNAME records pointing to the tunnel:

viable-system.com          CNAME  0a4c6c56-dda6-4dd4-bfd4-44655bf71673.cfargotunnel.com
api.viable-system.com      CNAME  0a4c6c56-dda6-4dd4-bfd4-44655bf71673.cfargotunnel.com

Deployment Steps

1. Create Static Server

ssh [email protected]

# Create the static server file
pct exec 100 -- bash -c 'cat > /opt/cybertask/static-server.js << "EOF"
[paste the static server code from above]
EOF'

2. Create Docker Compose Override

pct exec 100 -- bash -c 'cat > /opt/cybertask/docker-compose.override.yml << "EOF"
version: "3.8"

services:
  frontend:
    volumes:
      - /opt/cybertask/static-server.js:/static-server.js:ro
    command: ["node", "/static-server.js"]
EOF'

3. Restart Frontend Container

pct exec 100 -- bash -c 'cd /opt/cybertask && \
  docker compose -f docker-compose.yml -f docker-compose.override.yml stop frontend && \
  docker compose -f docker-compose.yml -f docker-compose.override.yml rm -f frontend && \
  docker compose -f docker-compose.yml -f docker-compose.override.yml up -d frontend'

4. Configure Cloudflare Tunnel Routes

  1. Go to https://one.dash.cloudflare.com/
  2. Navigate to NetworksTunnels
  3. Click on tunnel: cybertask-production
  4. Go to Public Hostname tab
  5. Add both routes as shown in the table above

Verification

Test Frontend

curl -I https://viable-system.com

Expected Output:

HTTP/2 200
content-type: text/html
access-control-allow-origin: *
server: cloudflare

Test Backend API

curl https://api.viable-system.com/health

Expected Output:

{
  "status": "OK",
  "timestamp": "2025-10-10T16:06:45.786Z",
  "environment": "production",
  "version": "1.0.0"
}

Check Tunnel Status

ssh [email protected] "pct exec 100 -- systemctl status cloudflared-tunnel"

Expected Output:

● cloudflared-tunnel.service - Cloudflare Tunnel
     Loaded: loaded
     Active: active (running)

View Tunnel Logs

ssh [email protected] "pct exec 100 -- journalctl -u cloudflared-tunnel -n 50"

File Structure

/opt/cybertask/
├── docker-compose.yml              # Main docker-compose configuration
├── docker-compose.override.yml     # Override for custom static server
├── static-server.js                # Custom Node.js HTTP server
├── .env                            # Environment variables
└── frontend-entrypoint.sh          # (Not used in final solution)

/root/.cloudflared/
├── config.yml                      # Tunnel configuration
└── credentials.json                # Tunnel credentials

Troubleshooting

Frontend Returns 502

Check if container is running:

ssh [email protected] "pct exec 100 -- docker ps | grep cybertask-frontend"

Check container logs:

ssh [email protected] "pct exec 100 -- docker logs cybertask-frontend"

Expected log output:

Static server running on port 3000

Backend Returns 502

Check backend container:

ssh [email protected] "pct exec 100 -- docker ps | grep cybertask-backend"

Test backend locally:

ssh [email protected] "pct exec 100 -- curl http://localhost:3001/health"

Tunnel Not Connecting

Check tunnel service:

ssh [email protected] "pct exec 100 -- systemctl status cloudflared-tunnel"

Restart tunnel service:

ssh [email protected] "pct exec 100 -- systemctl restart cloudflared-tunnel"

Check tunnel logs:

ssh [email protected] "pct exec 100 -- journalctl -u cloudflared-tunnel -f"

Important Notes

⚠️ Ingress Rules in config.yml Are Ignored

For dashboard-created tunnels (like ours), the ingress rules in /root/.cloudflared/config.yml are completely ignored. All routing must be configured through the Cloudflare dashboard.

⚠️ Docker Compose Override

The docker-compose.override.yml file is automatically merged with docker-compose.yml. To stop using it, you must delete or rename it.

⚠️ Container Restarts

If you modify the static server or override file, you must recreate the container (not just restart):

cd /opt/cybertask
docker compose -f docker-compose.yml -f docker-compose.override.yml up -d frontend --force-recreate

Performance Considerations

Static Server vs Vite Preview

The custom Node.js server is:

  • Simpler: No configuration, no dependencies
  • More permissive: Accepts all domains
  • Production-ready: Serves pre-built static files
  • ⚠️ Less features: No hot reload, no dev tools (but not needed in production)

Cloudflare Tunnel

Cloudflare Tunnel provides:

  • Zero trust security: No exposed ports on the server
  • DDoS protection: Built into Cloudflare edge
  • SSL/TLS: Automatic HTTPS
  • High availability: 4 redundant connections to edge network

Alternative Solutions Attempted

❌ Method 1: Volume Mount vite.config.js

Attempted:

volumes:
  - /opt/cybertask/vite.config.js:/app/vite.config.js:ro

Failed because: Vite needs write access to create .timestamp-*.mjs files next to the config.

❌ Method 2: Rebuild Image with Config

Attempted:

RUN echo "..." > /app/vite.config.js && \
    chown -R nodejs:nodejs /app

Failed because: The chown -R command on the entire /app directory timed out after 2+ minutes.

❌ Method 3: CLI Flag

Attempted:

vite preview --host 0.0.0.0 --allowed-hosts viable-system.com

Failed because: Vite doesn't have an --allowed-hosts CLI option.

❌ Method 4: Nginx Proxy

Attempted: Pulling nginx:alpine image to create a reverse proxy.

Failed because: Network timeout when pulling the nginx image from Docker Hub.

✅ Method 5: Custom Static Server (Final Solution)

Simple Node.js HTTP server with no dependencies or configuration required.

Security Considerations

CORS Headers

The static server adds Access-Control-Allow-Origin: * header. This is safe for a public frontend serving static files, but should be restricted if serving sensitive data.

Cloudflare Protection

All traffic passes through Cloudflare's edge network, providing:

  • DDoS mitigation
  • WAF (Web Application Firewall)
  • Bot protection
  • Rate limiting

No Direct Server Exposure

The Proxmox server has no open ports - all traffic flows through the encrypted Cloudflare Tunnel.

Maintenance

Updating Frontend Code

  1. Build new frontend image
  2. Push to jmanhype/cybertask-frontend:latest
  3. Pull and restart in LXC:
ssh [email protected] "pct exec 100 -- bash -c 'cd /opt/cybertask && \
  docker compose pull frontend && \
  docker compose -f docker-compose.yml -f docker-compose.override.yml up -d frontend --force-recreate'"

Updating Backend Code

ssh [email protected] "pct exec 100 -- bash -c 'cd /opt/cybertask && \
  docker compose pull backend && \
  docker compose up -d backend'"

Monitoring Tunnel Health

# Check tunnel connections
ssh [email protected] "pct exec 100 -- journalctl -u cloudflared-tunnel -n 20"

# Should show: "Registered tunnel connection"

Summary

Frontend: https://viable-system.com → Custom Node.js static server ✅ Backend: https://api.viable-system.com → Express API ✅ Tunnel: 4 active connections, routing correctly ✅ Security: Zero trust architecture, no exposed ports ✅ Performance: Cloudflare edge caching and acceleration


Documentation Date: October 10, 2025 Tunnel Status: Active and Healthy Next Steps: Monitor logs, set up alerting, consider CDN optimizations

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment