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
Internet → Cloudflare Edge → Tunnel (cybertask-production) → LXC #100 → Docker Containers
├─ Frontend (port 3000)
└─ Backend (port 3001)
- Host:
192.168.1.123
- User:
root@pam
- LXC Container:
#100
- Tunnel ID:
0a4c6c56-dda6-4dd4-bfd4-44655bf71673
- Tunnel Name:
cybertask-production
- Connections: 4 active connections to Cloudflare edge
- Service Status: Running via systemd
- 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 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.
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"]
}
Attempts to add this configuration failed due to:
- Volume mount permission issues: Vite needs write access to create
.timestamp-*.mjs
files - Image rebuild timeouts:
chown -R
on entire/app
directory took too long - Network timeouts: Pulling nginx images failed
- Module resolution issues: Config files in
/tmp
couldn't resolvevite
package
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
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);
});
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"]
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 |
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
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'
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'
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'
- Go to https://one.dash.cloudflare.com/
- Navigate to Networks → Tunnels
- Click on tunnel:
cybertask-production
- Go to Public Hostname tab
- Add both routes as shown in the table above
curl -I https://viable-system.com
Expected Output:
HTTP/2 200
content-type: text/html
access-control-allow-origin: *
server: cloudflare
curl https://api.viable-system.com/health
Expected Output:
{
"status": "OK",
"timestamp": "2025-10-10T16:06:45.786Z",
"environment": "production",
"version": "1.0.0"
}
ssh [email protected] "pct exec 100 -- systemctl status cloudflared-tunnel"
Expected Output:
● cloudflared-tunnel.service - Cloudflare Tunnel
Loaded: loaded
Active: active (running)
ssh [email protected] "pct exec 100 -- journalctl -u cloudflared-tunnel -n 50"
/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
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
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"
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"
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.
The docker-compose.override.yml
file is automatically merged with docker-compose.yml
. To stop using it, you must delete or rename it.
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
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 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
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.
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.
Attempted:
vite preview --host 0.0.0.0 --allowed-hosts viable-system.com
Failed because: Vite doesn't have an --allowed-hosts
CLI option.
Attempted: Pulling nginx:alpine
image to create a reverse proxy.
Failed because: Network timeout when pulling the nginx image from Docker Hub.
Simple Node.js HTTP server with no dependencies or configuration required.
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.
All traffic passes through Cloudflare's edge network, providing:
- DDoS mitigation
- WAF (Web Application Firewall)
- Bot protection
- Rate limiting
The Proxmox server has no open ports - all traffic flows through the encrypted Cloudflare Tunnel.
- Build new frontend image
- Push to
jmanhype/cybertask-frontend:latest
- 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'"
ssh [email protected] "pct exec 100 -- bash -c 'cd /opt/cybertask && \
docker compose pull backend && \
docker compose up -d backend'"
# Check tunnel connections
ssh [email protected] "pct exec 100 -- journalctl -u cloudflared-tunnel -n 20"
# Should show: "Registered tunnel connection"
✅ 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