skip to content
san.is
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:

PlanvCPUsRAMStorageTrafficPrice
CX2224 GB40 GB NVMe20 TB€3.79/month
CX3248 GB80 GB NVMe20 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

  1. Sign up at Hetzner Cloud
  2. Create a new project
  3. 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:

Terminal window
# Update system
apt update && apt upgrade -y
# Install Docker
curl -fsSL https://get.docker.com | sh
# Add your user to docker group (if not using root)
usermod -aG docker $USER
# Install Docker Compose plugin
apt install docker-compose-plugin -y
# Verify installation
docker --version
docker compose version

Step 2: Configure the Firewall

This is where the magic happens. We’re going to deny all inbound traffic.

Terminal window
# Install UFW if not present
apt install ufw -y
# Default policies
ufw default deny incoming
ufw default allow outgoing
# Enable the firewall
ufw enable
# Verify - should show no allowed incoming ports
ufw status verbose

Output should look like:

Status: active
Logging: 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

  1. Log into the Cloudflare Zero Trust Dashboard
  2. Navigate to Networks → Tunnels
  3. Click Create a tunnel
  4. Select Cloudflared as the connector
  5. Name your tunnel (e.g., “personal-vps”)
  6. On the connector setup page, select Docker
  7. 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:

Terminal window
mkdir -p /opt/stacks/infrastructure
cd /opt/stacks/infrastructure

Create 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:

Terminal window
CLOUDFLARE_TUNNEL_TOKEN=eyJhIjoiYWJjZGVmLi4uIiwidCI6...your-token-here
N8N_HOST=n8n.yourdomain.com
N8N_WEBHOOK_HOST=webhook.yourdomain.com

Start the stack:

Terminal window
docker compose up -d

Verify all containers are running:

Terminal window
docker compose ps

Step 4: Configure Public Hostnames

Back in the Cloudflare Zero Trust dashboard, configure the public hostnames for your tunnel:

Portainer (Admin Interface)

SettingValue
Public hostnameportainer.yourdomain.com
Service typeHTTP
Service URLportainer:9000

n8n (Main Interface)

SettingValue
Public hostnamen8n.yourdomain.com
Service typeHTTP
Service URLn8n:5678

n8n Webhooks (Separate Subdomain)

SettingValue
Public hostnamewebhook.yourdomain.com
Service typeHTTP
Service URLn8n: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

  1. In Zero Trust dashboard, go to Access → Applications
  2. Click Add an application
  3. Select Self-hosted

Configure for Portainer:

SettingValue
Application namePortainer
Application domainportainer.yourdomain.com
Session duration24 hours

Add a Policy

SettingValue
Policy nameEmail Authentication
ActionAllow
Include ruleEmails ending in @yourdomain.com

Or for personal use:

SettingValue
Include ruleEmails: [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 directly

2. Add Public Hostname in Cloudflare

SettingValue
Public hostnameservice.yourdomain.com
Service typeHTTP
Service URLyour-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-network

Add 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-network

Backups

Hetzner offers snapshots for €0.011/GB. Schedule weekly snapshots for disaster recovery:

Terminal window
# Using Hetzner CLI
hcloud 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:

Terminal window
# Stop (no charge while stopped, except storage)
hcloud server poweroff your-server-name
# Start
hcloud server poweron your-server-name

At €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:

Terminal window
# On your local machine
cloudflared access ssh --hostname ssh.yourdomain.com

This 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 updatesapt upgrade and docker compose pull

What I Run on Mine

My personal VPS currently runs:

ServicePurpose
n8nWorkflow automation (Slack notifications, data processing, scheduling)
PortainerContainer management
Uptime KumaMonitoring for client sites
Astro sitePersonal blog/portfolio
Webhook receiversVarious integrations

Total resource usage: ~1.2 GB RAM, minimal CPU. The CX22 has plenty of headroom.

Costs Breakdown

ItemMonthly Cost
Hetzner CX22€3.79
Cloudflare TunnelFree
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

  1. Zero open ports is achievable — Cloudflare Tunnels handle all ingress
  2. Setup is fast — 30 minutes from zero to running services
  3. Cost is minimal — Under €5/month for a capable setup
  4. Security is built-in — DDoS protection, SSL, and access policies included
  5. 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.