Securing a VPS with Cloudflare Tunnels
/ 7 min read
Table of Contents
Every solution architect should have a personal automation server. Not for client work — that belongs in proper infrastructure — but for your own tools, experiments, and productivity workflows.
The problem is security. Opening ports to the internet, managing SSL certificates, dealing with dynamic IPs, configuring firewalls correctly… it’s a lot of overhead for something that should be simple. It’s also the kind of overhead that tends to result in 3 AM panic when you realise you left SSH open to the world.
Six months ago, I set up a VPS that runs my personal n8n instance, a project dashboard, and various automation tools. It has zero open ports. No SSH exposed, no HTTP/HTTPS ports, nothing. All traffic routes through Cloudflare Tunnels.
Total cost: under €5/month. Setup time: about 30 minutes. Time saved not worrying about security: priceless, as they say.
Here’s exactly how to replicate it.
Architecture Overview
┌─────────────────────────────────────────────────────────────┐│ INTERNET │└─────────────────────────────────────────────────────────────┘ │ ▼┌─────────────────────────────────────────────────────────────┐│ CLOUDFLARE NETWORK ││ • DDoS protection ││ • SSL termination ││ • Access policies (email auth) │└─────────────────────────────────────────────────────────────┘ │ (Encrypted tunnel) │ ▼┌─────────────────────────────────────────────────────────────┐│ HETZNER VPS (CX22 - €3.79/month) ││ ┌────────────────────────────────────────────────────────┐ ││ │ DOCKER │ ││ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ ││ │ │ cloudflared │ │ Portainer │ │ n8n │ │ ││ │ │ (tunnel) │ │ (mgmt UI) │ │ (automation) │ │ ││ │ └──────────────┘ └──────────────┘ └──────────────┘ │ ││ └────────────────────────────────────────────────────────┘ ││ ││ UFW Firewall: DENY ALL (no open ports) │└─────────────────────────────────────────────────────────────┘The key insight: cloudflared creates an outbound connection to Cloudflare. Your server initiates the tunnel, so no inbound ports need to be open. Cloudflare handles SSL, DDoS protection, and access control.
What You’ll Need
- A domain name (managed by Cloudflare DNS, or with nameservers pointed to Cloudflare)
- A Cloudflare account (free tier is fine)
- About €4/month for the VPS
- 30 minutes
Step 1: Provision the VPS
I use Hetzner Cloud. Their CX-series shared vCPU servers offer exceptional value:
| Plan | vCPUs | RAM | Storage | Traffic | Price |
|---|---|---|---|---|---|
| CX22 | 2 | 4 GB | 40 GB NVMe | 20 TB | €3.79/month |
| CX32 | 4 | 8 GB | 80 GB NVMe | 20 TB | €6.80/month |
For personal automation tools, CX22 is more than enough. I’ve run n8n with 50+ workflows, Portainer, and several utility containers without issues.
Create the Server
- Sign up at Hetzner Cloud
- Create a new project
- Add a server:
- Location: Nuremberg or Falkenstein (cheapest)
- Image: Ubuntu 24.04
- Type: CX22 (Shared vCPU, Intel)
- Networking: Public IPv4 + IPv6
- SSH Key: Add your public key
- Name: Something memorable
The server provisions in under 30 seconds.
Initial Server Setup
SSH in and run the basics:
# Update systemapt update && apt upgrade -y
# Install Dockercurl -fsSL https://get.docker.com | sh
# Add your user to docker group (if not using root)usermod -aG docker $USER
# Install Docker Compose pluginapt install docker-compose-plugin -y
# Verify installationdocker --versiondocker compose versionStep 2: Configure the Firewall
This is where the magic happens. We’re going to deny all inbound traffic.
# Install UFW if not presentapt install ufw -y
# Default policiesufw default deny incomingufw default allow outgoing
# Enable the firewallufw enable
# Verify - should show no allowed incoming portsufw status verboseOutput should look like:
Status: activeLogging: on (low)Default: deny (incoming), allow (outgoing), disabled (routed)No SSH? No HTTP? Correct. We’ll access everything through the tunnel.
Important: Before enabling UFW, make sure you have console access through Hetzner’s web interface in case you lock yourself out. You can always access the server via their VNC console. Ask me how I know this is important.
Step 3: Set Up Cloudflare Tunnel
Create the Tunnel
- Log into the Cloudflare Zero Trust Dashboard
- Navigate to Networks → Tunnels
- Click Create a tunnel
- Select Cloudflared as the connector
- Name your tunnel (e.g., “personal-vps”)
- On the connector setup page, select Docker
- Copy the token from the Docker command (the long
eyJ...string)
Don’t close this page yet — you’ll need to configure the public hostname after the container is running.
Create the Docker Compose Stack
On your server, create a directory for your stack:
mkdir -p /opt/stacks/infrastructurecd /opt/stacks/infrastructureCreate docker-compose.yml:
version: "3.8"
services: cloudflared: image: cloudflare/cloudflared:latest container_name: cloudflared restart: unless-stopped command: tunnel run environment: - TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN} networks: - tunnel-network
portainer: image: portainer/portainer-ce:latest container_name: portainer restart: unless-stopped volumes: - /var/run/docker.sock:/var/run/docker.sock - portainer_data:/data networks: - tunnel-network # No ports exposed - accessed via tunnel
n8n: image: n8nio/n8n:latest container_name: n8n restart: unless-stopped environment: - N8N_HOST=${N8N_HOST} - N8N_PORT=5678 - N8N_PROTOCOL=https - NODE_ENV=production - WEBHOOK_URL=https://${N8N_WEBHOOK_HOST}/ - GENERIC_TIMEZONE=Europe/London volumes: - n8n_data:/home/node/.n8n networks: - tunnel-network # No ports exposed - accessed via tunnel
networks: tunnel-network: driver: bridge
volumes: portainer_data: n8n_data:Create .env:
CLOUDFLARE_TUNNEL_TOKEN=eyJhIjoiYWJjZGVmLi4uIiwidCI6...your-token-hereN8N_HOST=n8n.yourdomain.comN8N_WEBHOOK_HOST=webhook.yourdomain.comStart the stack:
docker compose up -dVerify all containers are running:
docker compose psStep 4: Configure Public Hostnames
Back in the Cloudflare Zero Trust dashboard, configure the public hostnames for your tunnel:
Portainer (Admin Interface)
| Setting | Value |
|---|---|
| Public hostname | portainer.yourdomain.com |
| Service type | HTTP |
| Service URL | portainer:9000 |
n8n (Main Interface)
| Setting | Value |
|---|---|
| Public hostname | n8n.yourdomain.com |
| Service type | HTTP |
| Service URL | n8n:5678 |
n8n Webhooks (Separate Subdomain)
| Setting | Value |
|---|---|
| Public hostname | webhook.yourdomain.com |
| Service type | HTTP |
| Service URL | n8n:5678 |
Click Save tunnel after adding all hostnames.
Within a minute, your services should be accessible at their public URLs — with automatic SSL certificates, no port forwarding, and no exposed server ports.
Step 5: Add Access Policies (Zero Trust)
Your services are now accessible, but they’re public. Let’s add authentication.
Create an Access Application
- In Zero Trust dashboard, go to Access → Applications
- Click Add an application
- Select Self-hosted
Configure for Portainer:
| Setting | Value |
|---|---|
| Application name | Portainer |
| Application domain | portainer.yourdomain.com |
| Session duration | 24 hours |
Add a Policy
| Setting | Value |
|---|---|
| Policy name | Email Authentication |
| Action | Allow |
| Include rule | Emails ending in @yourdomain.com |
Or for personal use:
| Setting | Value |
|---|---|
| Include rule | Emails: [email protected] |
Now when anyone visits portainer.yourdomain.com, they’ll be prompted to authenticate via email (one-time PIN) before seeing the application.
Repeat for n8n — but you might want to leave webhook.yourdomain.com unprotected so external services can trigger your webhooks. You can add IP allowlists or webhook signatures for security instead.
Step 6: Add More Services
The beauty of this setup is how easy it is to add services. Here’s the pattern:
1. Add to Docker Compose
your-service: image: whatever/service:latest container_name: your-service restart: unless-stopped volumes: - your_service_data:/data networks: - tunnel-network # Never expose ports directly2. Add Public Hostname in Cloudflare
| Setting | Value |
|---|---|
| Public hostname | service.yourdomain.com |
| Service type | HTTP |
| Service URL | your-service:PORT |
3. Add Access Policy (if needed)
Create an application and policy for authentication.
That’s it. No port mapping, no SSL certificates, no nginx configuration. It’s almost suspiciously easy.
Example: Adding a Static Site
Want to host a static site? Add Caddy to serve files:
website: image: caddy:alpine container_name: website restart: unless-stopped volumes: - ./website:/usr/share/caddy:ro networks: - tunnel-networkAdd the hostname in Cloudflare pointing to website:80. Done.
Operational Tips
Monitoring
Portainer gives you container logs, resource usage, and management UI. For more comprehensive monitoring, add:
uptime-kuma: image: louislam/uptime-kuma:latest container_name: uptime-kuma restart: unless-stopped volumes: - uptime_kuma_data:/app/data networks: - tunnel-networkBackups
Hetzner offers snapshots for €0.011/GB. Schedule weekly snapshots for disaster recovery:
# Using Hetzner CLIhcloud server create-snapshot your-server-name --description "Weekly backup"Or automate with a cron job and the Hetzner API.
Cost Control
Stop the VPS when you’re not using it:
# Stop (no charge while stopped, except storage)hcloud server poweroff your-server-name
# Starthcloud server poweron your-server-nameAt €0.006/hour, running 24/7 costs ~€4.30/month. Running only during work hours (12h/day, weekdays) would cost ~€1.50/month.
SSH Access (When Needed)
If you need SSH for debugging, you can add a temporary tunnel:
In Cloudflare, add a new public hostname:
- Hostname:
ssh.yourdomain.com - Service: SSH
- URL:
localhost:22
Add an access policy requiring authentication.
Then use Cloudflare’s cloudflared access command:
# On your local machinecloudflared access ssh --hostname ssh.yourdomain.comThis creates an authenticated SSH connection through the tunnel — still no open ports on the server.
Security Checklist
✅ No open inbound ports — UFW denies all incoming
✅ All traffic through Cloudflare — DDoS protection included
✅ Automatic SSL — Cloudflare handles certificates
✅ Zero Trust access — Email authentication for admin interfaces
✅ Encrypted tunnel — All traffic between server and Cloudflare is encrypted
✅ No exposed Docker ports — Services communicate via internal network
✅ Regular updates — apt upgrade and docker compose pull
What I Run on Mine
My personal VPS currently runs:
| Service | Purpose |
|---|---|
| n8n | Workflow automation (Slack notifications, data processing, scheduling) |
| Portainer | Container management |
| Uptime Kuma | Monitoring for client sites |
| Astro site | Personal blog/portfolio |
| Webhook receivers | Various integrations |
Total resource usage: ~1.2 GB RAM, minimal CPU. The CX22 has plenty of headroom.
Costs Breakdown
| Item | Monthly Cost |
|---|---|
| Hetzner CX22 | €3.79 |
| Cloudflare Tunnel | Free |
| Cloudflare Access (50 users) | Free |
| Domain (annual, amortised) | ~€1 |
| Total | ~€5/month |
For comparison, a basic Heroku dyno is $7/month with far fewer resources. DigitalOcean’s smallest droplet is $6/month without the security benefits.
Key Takeaways
- Zero open ports is achievable — Cloudflare Tunnels handle all ingress
- Setup is fast — 30 minutes from zero to running services
- Cost is minimal — Under €5/month for a capable setup
- Security is built-in — DDoS protection, SSL, and access policies included
- Scaling is easy — Add services with Docker Compose + Cloudflare hostname
If you’re a solution architect who doesn’t have a personal automation server, you’re missing out. This setup has saved me countless hours with scheduled data processing, alert monitoring, and workflow automation. Plus there’s something deeply satisfying about telling people your server has zero open ports.
Running a similar setup? I’d be interested to hear what services you’re self-hosting — find me on LinkedIn.