A production-ready OCI image for Proxmox LXC that bundles Traefik (with Cloudflare DNS challenge), Cloudflared (Argo Tunnels), and Favonia/Cloudflare DDNS into a single Alpine-based container acting as your central reverse proxy.
- Features
- Architecture Overview
- Prerequisites
- Quick Start
- Building the Image
- Importing into Proxmox
- Configuration
- Adding Applications
- Networking Guide
- Security Checklist
- Troubleshooting
- License
- Alpine Linux (latest) - Minimal footprint (~50MB base)
- Traefik v3 - Modern reverse proxy with automatic HTTPS via Let's Encrypt
- Cloudflare DNS Challenge - Wildcard certificates without exposing port 80
- Cloudflared Tunnel - Zero-trust access without opening firewall ports
- Favonia DDNS - Automatic DNS record updates for dynamic IPs
- Supervisor - Process management for all services
- OCI Compliant - Works with Docker, Podman, Buildah, and Proxmox
βββββββββββββββββββββββββββββββββββββββββββ
β Proxmox LXC "dns" β
β β
Internet βββ Cloudflare ββββββββββββΊβ βββββββββββββββ ββββββββββββββββββββ β
(DNS/CDN) β β Cloudflared β β Traefik β β
β β Tunnel ββββ (Port 443) β β
β βββββββββββββββ ββββββββββ¬ββββββββββ β
β β β
β βββββββββββββββ β β
β β Favonia β β β
β β DDNS Script β βΌ β
β βββββββββββββββ Internal Apps β
β (VLAN/Bridge) β
βββββββββββββββββββββββββββββββββββββββββββ
-
Domain registered with Cloudflare (free tier works)
-
API Token with these permissions:
Zone:Zone:Read- List zonesZone:DNS:Edit- Manage DNS records (for DDNS + ACME)
β οΈ Security: Create a scoped token, NOT a Global API Key! -
Cloudflare Tunnel (optional but recommended):
- Install
cloudflaredlocally - Run
cloudflared tunnel login - Run
cloudflared tunnel create dns-tunnel - Save the credentials JSON file
- Install
- Proxmox VE 9.x
- Storage with "Container Template" content type enabled
- Network bridges configured (e.g.,
vmbr0for WAN,vmbr1for internal VLAN)
# 1. Clone this repository
git clone https://github.com/harshsinghmp/dns-reverse-proxy.git
cd dns-reverse-proxy
# 2. Copy and edit environment file
cp .env.example .env
nano .env # Fill in your Cloudflare credentials
# 3. Build the OCI image
docker build -t dns-reverse-proxy:latest .
# 4. Export for Proxmox
docker save dns-reverse-proxy:latest | gzip > dns-reverse-proxy.tar.gz
# 5. Upload to Proxmox and import (see detailed steps below)# Build the image
docker build -t dns-reverse-proxy:latest .
# Optional: Test locally before exporting
docker run -it --rm \
-p 80:80 -p 443:443 \
-v $(pwd)/config:/etc/traefik \
-v $(pwd)/data:/data \
--env-file .env \
dns-reverse-proxy:latest
# Export as OCI archive for Proxmox
docker save dns-reverse-proxy:latest -o dns-reverse-proxy.tar
gzip dns-reverse-proxy.tar# Build with Podman
podman build -t dns-reverse-proxy:latest .
# Export as OCI archive
podman save dns-reverse-proxy:latest -o dns-reverse-proxy.tar
gzip dns-reverse-proxy.tar# Build with Buildah
buildah bud -t dns-reverse-proxy:latest .
# Export as OCI archive
buildah push dns-reverse-proxy:latest oci-archive:dns-reverse-proxy.tar
gzip dns-reverse-proxy.tarThe included GitHub Actions workflow builds the image automatically:
- Fork this repository
- Add secrets in Settings > Secrets and variables > Actions:
GITHUB_TOKEN(auto-provided)
- Push to
mainbranch or create a release tag - Download artifact from the Actions tab
# The workflow will:
# - Build the OCI image on Ubuntu runner
# - Export as .tar.gz artifact
# - Upload to GitHub Container Registry (optional)To download the built image:
# From GitHub Container Registry
docker pull ghcr.io/harshsinghmp/dns-reverse-proxy:latest
docker save ghcr.io/harshsinghmp/dns-reverse-proxy:latest -o dns-reverse-proxy.tar
gzip dns-reverse-proxy.tar# Option A: SCP from local machine
scp dns-reverse-proxy.tar.gz root@proxmox:/var/lib/vz/template/cache/
# Option B: Download directly on Proxmox
ssh root@proxmox
cd /var/lib/vz/template/cache/
wget https://github.com/harshsinghmp/dns-reverse-proxy/releases/download/v1.0.0/dns-reverse-proxy.tar.gzProxmox doesn't natively import Docker/OCI images. Use this method:
# SSH into Proxmox host
ssh root@proxmox
# Create a temporary directory
mkdir -p /tmp/oci-import && cd /tmp/oci-import
# Extract the OCI image
tar -xzf /var/lib/vz/template/cache/dns-reverse-proxy.tar.gz
# Install skopeo if not present (for conversion)
apt update && apt install -y skopeo
# Convert Docker archive to rootfs
# Method: Extract layers and create LXC template
mkdir rootfs
for layer in $(cat manifest.json | jq -r '.[0].Layers[]'); do
tar -xf "$layer" -C rootfs
done
# Create the LXC template
cd rootfs
tar -czf /var/lib/vz/template/cache/dns-reverse-proxy-lxc.tar.gz .
# Cleanup
rm -rf /tmp/oci-import# Via Proxmox CLI
pct create 100 /var/lib/vz/template/cache/dns-reverse-proxy-lxc.tar.gz \
--hostname dns \
--memory 512 \
--cores 2 \
--net0 name=eth0,bridge=vmbr0,ip=dhcp \
--net1 name=eth1,bridge=vmbr1,ip=10.10.10.2/24 \
--rootfs local-lvm:8 \
--ostype alpine \
--unprivileged 1 \
--features nesting=1 \
--mp0 /mnt/data/containers/reverse-proxy,mp=/data \
--start 0
# Or via Proxmox Web UI:
# 1. Datacenter > Storage > local > CT Templates
# 2. Upload the .tar.gz file
# 3. Create CT with template selectionIf OCI import is problematic, use the official Alpine template:
# Download Alpine template
pveam update
pveam download local alpine-3.19-default_20231211_amd64.tar.xz
# Create LXC
pct create 100 local:vztmpl/alpine-3.19-default_20231211_amd64.tar.xz \
--hostname dns \
--memory 512 \
--cores 2 \
--net0 name=eth0,bridge=vmbr0,ip=dhcp \
--net1 name=eth1,bridge=vmbr1,ip=10.10.10.2/24 \
--rootfs local-lvm:8 \
--unprivileged 1 \
--features nesting=1 \
--mp0 /mnt/data/containers/reverse-proxy,mp=/data \
--start 1
# Enter container and run setup
pct enter 100
# Inside container: run the setup script
wget -O- https://raw.githubusercontent.com/harshsinghmp/dns-reverse-proxy/main/scripts/setup-alpine.sh | shCreate this structure on your Proxmox host at /mnt/data/containers/reverse-proxy:
/mnt/data/containers/reverse-proxy/
βββ traefik/
β βββ traefik.yml # Static configuration
β βββ dynamic.yml # Dynamic routes (your apps!)
β βββ acme.json # Let's Encrypt certificates (auto-created)
βββ cloudflared/
β βββ config.yml # Tunnel configuration
β βββ credentials.json # Tunnel credentials (from cloudflared tunnel create)
βββ ddns/
β βββ ddns.env # DDNS-specific environment variables
βββ logs/
βββ traefik.log
βββ cloudflared.log
# On Proxmox host
mkdir -p /mnt/data/containers/reverse-proxy/{traefik,cloudflared,ddns,logs}
chmod 600 /mnt/data/containers/reverse-proxy/traefik/acme.json 2>/dev/null || true
touch /mnt/data/containers/reverse-proxy/traefik/acme.json
chmod 600 /mnt/data/containers/reverse-proxy/traefik/acme.jsonCopy .env.example to the persistent storage and edit:
cp .env.example /mnt/data/containers/reverse-proxy/.env
nano /mnt/data/containers/reverse-proxy/.envRequired variables:
# Cloudflare API Token (scoped, not Global Key!)
CF_API_TOKEN=your_cloudflare_api_token_here
# Your domain
DOMAIN=example.com
# Email for Let's Encrypt
ACME_EMAIL=admin@example.com
# Cloudflare Zone ID (find in domain overview)
CF_ZONE_ID=your_zone_id_here
# Tunnel name (must match what you created)
TUNNEL_NAME=dns-tunnel
# Subdomains for DDNS updates (comma-separated)
DDNS_DOMAINS=home.example.com,vpn.example.com
# Traefik dashboard (set to false in production)
TRAEFIK_DASHBOARD=true
TRAEFIK_DASHBOARD_USER=admin
# Generate with: htpasswd -nb admin yourpassword
TRAEFIK_DASHBOARD_PASSWORD=$apr1$xxxxx-
Generate tunnel on your local machine:
# Install cloudflared # macOS: brew install cloudflared # Linux: See https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/ # Login to Cloudflare cloudflared tunnel login # Create the tunnel cloudflared tunnel create dns-tunnel # This creates ~/.cloudflared/<TUNNEL_ID>.json
-
Copy credentials to Proxmox:
scp ~/.cloudflared/*.json root@proxmox:/mnt/data/containers/reverse-proxy/cloudflared/credentials.json
-
Configure DNS routing in Cloudflare Dashboard:
- Go to Zero Trust > Access > Tunnels
- Select your tunnel > Configure
- Add public hostname:
*.example.comβhttp://localhost:80
Edit /mnt/data/containers/reverse-proxy/traefik/dynamic.yml:
# ============================================================
# DYNAMIC CONFIGURATION - ADD YOUR APPS HERE
# ============================================================
#
# This file is hot-reloaded by Traefik (no restart needed!)
# Add services using internal IPs of your apps
#
# Template for a new service:
#
# routers:
# myapp:
# rule: "Host(`myapp.example.com`)"
# service: myapp
# entryPoints:
# - websecure
# tls:
# certResolver: cloudflare
#
# services:
# myapp:
# loadBalancer:
# servers:
# - url: "http://10.10.10.50:8080"
# ============================================================
http:
routers:
# βββββββββββββββββββββββββββββββββββββββββ
# Example: Jellyfin Media Server
# βββββββββββββββββββββββββββββββββββββββββ
jellyfin:
rule: "Host(`media.example.com`)"
service: jellyfin
entryPoints:
- websecure
tls:
certResolver: cloudflare
middlewares:
- secure-headers
# βββββββββββββββββββββββββββββββββββββββββ
# Example: Gitea (Self-hosted Git)
# βββββββββββββββββββββββββββββββββββββββββ
gitea:
rule: "Host(`git.example.com`)"
service: gitea
entryPoints:
- websecure
tls:
certResolver: cloudflare
middlewares:
- secure-headers
# βββββββββββββββββββββββββββββββββββββββββ
# Example: Home Assistant
# βββββββββββββββββββββββββββββββββββββββββ
homeassistant:
rule: "Host(`home.example.com`)"
service: homeassistant
entryPoints:
- websecure
tls:
certResolver: cloudflare
middlewares:
- secure-headers
# βββββββββββββββββββββββββββββββββββββββββ
# Example: Proxmox Web UI
# βββββββββββββββββββββββββββββββββββββββββ
proxmox:
rule: "Host(`pve.example.com`)"
service: proxmox
entryPoints:
- websecure
tls:
certResolver: cloudflare
middlewares:
- secure-headers
services:
jellyfin:
loadBalancer:
servers:
- url: "http://10.10.10.50:8096"
gitea:
loadBalancer:
servers:
- url: "http://10.10.10.51:3000"
homeassistant:
loadBalancer:
servers:
- url: "http://10.10.10.52:8123"
proxmox:
loadBalancer:
servers:
# Note: Proxmox uses HTTPS internally
- url: "https://10.10.10.1:8006"
serversTransport: insecure-skip-verify
serversTransports:
insecure-skip-verify:
insecureSkipVerify: true
middlewares:
# Security headers for all services
secure-headers:
headers:
browserXssFilter: true
contentTypeNosniff: true
forceSTSHeader: true
stsIncludeSubdomains: true
stsPreload: true
stsSeconds: 31536000
customFrameOptionsValue: "SAMEORIGIN"
customResponseHeaders:
X-Robots-Tag: "noindex,nofollow,nosnippet,noarchive,notranslate,noimageindex"
# Basic auth middleware (for protected services)
auth:
basicAuth:
users:
# Generate: htpasswd -nb admin password
- "admin:$apr1$H6uskkkW$IgXLP6ewTrSuBkTrqE8wj/"
# Rate limiting middleware
rate-limit:
rateLimit:
average: 100
burst: 50If running apps as Docker containers on the same network:
# docker-compose.yml for an app
services:
myapp:
image: myapp:latest
labels:
- "traefik.enable=true"
- "traefik.http.routers.myapp.rule=Host(`myapp.example.com`)"
- "traefik.http.routers.myapp.entrypoints=websecure"
- "traefik.http.routers.myapp.tls.certresolver=cloudflare"
- "traefik.http.services.myapp.loadbalancer.server.port=8080"
networks:
- traefikβββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β PROXMOX HOST β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β vmbr0 (WAN Bridge) βββββββββββββββββββββββββββββββββββββββΊ Internet β
β β β
β βββ LXC 100 "dns" (eth0: DHCP or static public IP) β
β β β
β βββ (Optional) Other public-facing VMs β
β β
β vmbr1 (Internal VLAN / Isolated Bridge) βββββββββββββββββΊ Internal β
β β β
β βββ LXC 100 "dns" (eth1: 10.10.10.2/24) β
β βββ LXC 101 "jellyfin" (eth0: 10.10.10.50/24) β
β βββ LXC 102 "gitea" (eth0: 10.10.10.51/24) β
β βββ VM 103 "docker-host" (eth0: 10.10.10.60/24) β
β βββ Other internal services... β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Edit /etc/network/interfaces on Proxmox host:
# WAN Bridge (connects to your router/ISP)
auto vmbr0
iface vmbr0 inet static
address 192.168.1.10/24
gateway 192.168.1.1
bridge-ports enp0s25
bridge-stp off
bridge-fd 0
# Internal VLAN Bridge (isolated network for apps)
auto vmbr1
iface vmbr1 inet static
address 10.10.10.1/24
bridge-ports none
bridge-stp off
bridge-fd 0
# No gateway - this is isolated!
# Optional: Enable NAT for internal network to access internet
# (for updates, etc. - containers can reach out but not be reached)
post-up iptables -t nat -A POSTROUTING -s '10.10.10.0/24' -o vmbr0 -j MASQUERADE
post-down iptables -t nat -D POSTROUTING -s '10.10.10.0/24' -o vmbr0 -j MASQUERADEThe container has two interfaces:
# /etc/network/interfaces inside the LXC
# WAN interface (for Cloudflared tunnel egress)
auto eth0
iface eth0 inet dhcp
# Or static:
# iface eth0 inet static
# address 192.168.1.100/24
# gateway 192.168.1.1
# Internal interface (to reach your apps)
auto eth1
iface eth1 inet static
address 10.10.10.2/24
# No gateway on this interface!For the "dns" LXC, configure these rules in Proxmox Firewall:
# Allow inbound on WAN (if not using Cloudflared exclusively)
# Note: With Cloudflared tunnel, you can block ALL inbound!
[ACCEPT] IN eth0 -p tcp --dport 80
[ACCEPT] IN eth0 -p tcp --dport 443
# Allow all internal traffic from/to apps
[ACCEPT] IN eth1
[ACCEPT] OUT eth1
# Allow outbound for Cloudflared tunnel (uses QUIC/443)
[ACCEPT] OUT eth0 -p tcp --dport 443
[ACCEPT] OUT eth0 -p udp --dport 443
# Block everything else by default
[DROP] IN eth0With Cloudflared tunnel, you don't need ANY inbound firewall rules:
- Cloudflared connects outbound to Cloudflare's edge
- Traffic flows: Internet β Cloudflare β Tunnel β Traefik β Internal Apps
- No ports exposed on your router or Proxmox
Configure your router:
- No port forwarding needed!
- Block all inbound traffic for maximum security
- Use API Token, NOT Global API Key
- Scope the token to only required permissions:
Zone:Zone:Read(for ACME challenges)Zone:DNS:Edit(for DNS updates)
- Limit to specific zones (your domain only)
- Set token expiration if possible
- Store securely in
.envfile withchmod 600
- Use Let's Encrypt production (not staging)
- Protect acme.json:
chmod 600 acme.json - Enable HSTS in Traefik middlewares
- Force TLS 1.2+ in Traefik configuration
- Enable OCSP stapling (default in Traefik)
- Store credentials.json securely:
chmod 600 - Run as non-root user inside container
- Use Access policies for sensitive apps (Cloudflare Zero Trust)
- Enable tunnel metrics for monitoring
- Run as unprivileged LXC:
--unprivileged 1 - Enable nesting only if needed:
--features nesting=1 - Minimal packages - Alpine base is already slim
- No SSH - Use
pct enterfor console access - Read-only rootfs where possible
- Isolate internal apps on separate VLAN/bridge
- No port forwarding when using Cloudflared
- Firewall default deny on WAN interface
- Internal DNS for service discovery (optional)
- Disable dashboard in production or protect with auth
- Rate limiting middleware enabled
- Security headers middleware on all routes
- Fail2ban integration (optional, advanced)
- Regular updates:
apk upgradeinside container - Monitor logs:
/data/logs/*.log - Backup credentials: Store copies securely offline
- Test recovery: Verify you can rebuild from scratch
# Check Traefik logs
tail -f /data/logs/traefik.log
# Verify Cloudflare token
curl -X GET "https://api.cloudflare.com/client/v4/user/tokens/verify" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json"
# Check acme.json permissions
ls -la /data/traefik/acme.json
# Should be: -rw------- (600)# Check Cloudflared logs
tail -f /data/logs/cloudflared.log
# Test tunnel manually
cloudflared tunnel --config /data/cloudflared/config.yml run
# Verify credentials path
cat /data/cloudflared/config.yml | grep credentials-file
ls -la /data/cloudflared/credentials.json# Check DDNS logs
tail -f /data/logs/ddns.log
# Test API manually
curl -X GET "https://api.cloudflare.com/client/v4/zones/YOUR_ZONE_ID/dns_records" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json"# Check LXC logs on Proxmox host
journalctl -u pve-container@100 -f
# Common issues:
# - Mount point doesn't exist: Create /mnt/data/containers/reverse-proxy
# - Permission issues: Check ownership (should be 100000:100000 for unprivileged)
# - Network bridge missing: Verify vmbr0 and vmbr1 exist# Inside the LXC, test internal connectivity
ping 10.10.10.50 # Internal app IP
curl http://10.10.10.50:8096 # Test service directly
# Check Traefik router
curl -s http://localhost:8080/api/http/routers | jq
# Verify DNS resolution
nslookup myapp.example.comMIT License - Feel free to use, modify, and distribute.
- Traefik - The Cloud Native Edge Router
- Cloudflared - Cloudflare Tunnel client
- Favonia DDNS - Cloudflare DDNS updater
- Alpine Linux team for the minimal base image
Need help? Open an issue on GitHub or check the Traefik documentation.