The goal
Host a public website on infrastructure we control, without opening any inbound port on the firewall and without exposing a server address that can be scanned or attacked directly.
We use the same pattern for internal applications that need limited outside access, so it’s worth documenting properly.
Environment
- Hypervisor: Proxmox VE
- Guest: Debian 12, unprivileged LXC container
- Resources: 1 vCPU, 512 MB memory, 8 GB disk
- Web server: Nginx serving a static build directory
- Edge: Cloudflare DNS, WAF, and Cloudflare Tunnel
- Firewall: no inbound WAN ports and no port forwarding rules
Why an unprivileged container
An unprivileged container maps its root user to an ordinary user on the host, so if someone breaks into the container, they don’t get host-level access. This workload only reads files from disk and answers HTTP requests. A full virtual machine would add overhead without adding meaningful isolation for this threat model. The small footprint also means the host can run many of these without much capacity planning.
Why a tunnel
The usual approach is a port forward to the web server plus a certificate on the server itself. That has two problems. The server’s address becomes public and can be scanned forever, and the firewall now has an inbound rule that has to be maintained and justified.
A tunnel flips the connection around. A connector inside the container opens an outbound connection to Cloudflare’s network, and public traffic comes back down that connection. The result:
- No inbound firewall rule and no port forward
- No public server address to find
- HTTPS handled at the edge, so there are no certificates to renew inside the container
- The container can be moved, rebuilt, or readdressed without touching DNS
How it’s deployed
The site is built to a static output folder and copied into the container. Nginx serves it with compression on and a few security headers set at the server level:
server {
listen 80;
root /var/www/site;
index index.html;
location / {
try_files $uri $uri/ =404;
}
gzip on;
gzip_vary on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
}
The connector runs as a system service pointed at the local Nginx listener. Public hostnames are mapped at the edge instead of in a local config file, so routing changes never touch the container.
Security considerations
- Nginx listens only on the container’s address and can’t be reached directly from the internet
- The connector holds an outbound-only credential scoped to this one tunnel
- Edge firewall rules and rate limiting apply before traffic reaches the container
- Publishing new content means syncing files and fixing ownership. The web server config doesn’t change during routine updates.
- The container can be rebuilt from documentation, so recovery means a fresh build instead of restoring months of manual changes
Result
A public site running on private infrastructure, with nothing exposed at the firewall, no server certificate to watch, and a documented rebuild path. The same approach works for internal tools, dashboards, and business apps that need controlled outside access without a VPN.
It’s what we recommend whenever a client asks how to publish an internal app without opening the firewall. If that’s a question in your environment, let’s talk.