I was at university trying to connect to my VPN and... nothing. Couldn't connect. Then I remembered: most university networks block outbound UDP on non-standard ports. My VPN runs on UDP on a custom high port, so the university firewall just silently drops everything.

This was actually the use case I had in mind way back in Chapter 5 when I chose OpenVPN over WireGuard despite WireGuard being faster and simpler. The plan was always to eventually run OpenVPN over TCP 443 (the same port HTTPS uses) so it looks like normal web traffic to any firewall. Today, that day arrived.

"The best VPN is the one that works from the place you're actually at."

The Plan

The goal: run OpenVPN on TCP port 443, the same port already used by my website. To any firewall, all traffic to port 443 looks like HTTPS, completely indistinguishable from someone visiting a website. Even the most restrictive networks allow port 443 outbound, otherwise nothing on the internet would work.

The problem: I already have Nginx using port 443 for the website. Two services can't bind the same port.

The solution: sslh. It's a protocol multiplexer that listens on port 443, sniffs the first packet of each incoming connection, and routes it based on the protocol signature:

  • HTTPS handshake → forward to Nginx
  • OpenVPN handshake → forward to OpenVPN
  • Anything else → drop the connection

Setting It Up

Step 1: Second OpenVPN Instance (TCP)

OpenVPN can only listen on one port/protocol per instance, so I needed a second instance for TCP. Copied the existing UDP config and modified it:

sudo cp /etc/openvpn/server/server.conf /etc/openvpn/server/server-tcp.conf

Key changes in server-tcp.conf:

  • port 1194 - internal port, sslh will forward to it
  • proto tcp - TCP instead of UDP
  • local 127.0.0.1 - only listen on localhost (not exposed directly to internet)
  • server 10.8.1.0 255.255.255.0 - different subnet from UDP instance
  • dev tun1 - different virtual interface
  • ifconfig-pool-persist ipp-tcp.txt - separate IP pool file
  • status /var/log/openvpn/status-tcp.log - separate status log
  • management /var/run/openvpn-server/server-tcp.sock unix - separate management socket

Without these separate files, the two OpenVPN instances would fight over the same resources and both would crash.

Step 2: Move Nginx Off Port 443

Changed Nginx to listen on port 8443 instead, only on localhost:

listen 127.0.0.1:8443 ssl;
listen [::1]:8443 ssl;

Now Nginx is invisible from the outside, only reachable through sslh.

Step 3: Install sslh

sudo apt install sslh

Configured it to listen on 443 and route traffic:

DAEMON_OPTS="--user sslh --listen 0.0.0.0:443 --tls 127.0.0.1:8443 --openvpn 127.0.0.1:1194 --pidfile /var/run/sslh/sslh.pid"

Hit a permission issue with the PID file directory:

sslh[102375]: write_pid_file: /var/run/sslh/sslh.pid: Permission denied

Fixed with:

sudo mkdir -p /var/run/sslh
sudo chown sslh:sslh /var/run/sslh
sudo systemctl restart sslh

Step 4: TCP Client Config

Generated a new client through the install script, then manually edited the .ovpn file:

proto tcp
remote [VPN_SUBDOMAIN].mosearc.eu 443

Used the VPN subdomain (DNS-only, not proxied) because if I used the main domain, Cloudflare's proxy would intercept the TCP 443 connection and try to terminate TLS, which would completely break OpenVPN's handshake.

The Result

Tested it on university Wi-Fi. Connection established in 3 seconds. To the university firewall, it looks like I'm just browsing some random HTTPS website. To me, I'm tunneled all the way back to my home server. Beautiful.

Now I have two VPN options:

  • UDP on custom port: faster, lower latency. Use this when the network allows it (home, mobile data, friendly Wi-Fi).
  • TCP on 443: slower but works literally anywhere with internet. Use this on restrictive networks.

The Security Question

Adding sslh means port 443 now serves two services instead of one. Did this make me more vulnerable?

Slightly, but not by much. Here's the analysis:

  • OpenVPN over TCP still requires tls-crypt-v2. Without the key, the server silently drops packets. Same protection as the UDP instance.
  • sslh itself gets security updates via unattended-upgrades.
  • TCP attacks use more server resources than UDP (handshake overhead), so flooding TCP 443 could affect performance.

To mitigate the TCP flood concern, I added rate limiting in UFW's before.rules:

# Rate limit TCP 443 connections
-A ufw-before-input -p tcp --dport 443 -m conntrack --ctstate NEW -m recent --set --name tcp443
-A ufw-before-input -p tcp --dport 443 -m conntrack --ctstate NEW -m recent --update --seconds 10 --hitcount 60 --name tcp443 -j DROP

60 new connections per 10 seconds per IP. HTTPS legitimately makes more connections than VPN (multiple requests, parallel loading), so the limit is higher than the VPN-only port.

Bonus: fail2ban for Nginx

While I was thinking about web security, I realized fail2ban was only protecting SSH. The website was unprotected against bots probing for vulnerabilities: WordPress admin panels, PHP files, .env files, the usual suspects. Even though my site is static HTML and has none of those, bots don't know that. They just keep trying.

Added four nginx jails to fail2ban:

  • nginx-http-auth - failed HTTP authentication attempts
  • nginx-botsearch - known bot user agents
  • nginx-badbots - custom filter matching scrapers/scanners
  • nginx-noscript - bans IPs probing for .php, .asp, wp-admin, .env, etc.

The custom nginx-noscript filter is particularly satisfying. Anyone trying to access /wp-admin or /.env on my static portfolio site gets banned after 3 attempts. Free entertainment via Telegram notifications.

Current Defense Stack

After all the additions in this chapter:

  • UFW firewall (only 80, 443, VPN UDP port)
  • fail2ban on SSH + 4 nginx jails + recidive
  • SSH key-only, no password auth
  • SSH not exposed to internet (no port forwarding)
  • OpenVPN UDP on custom port (fast, for normal networks)
  • OpenVPN TCP on 443 via sslh (works on restrictive networks)
  • tls-crypt-v2 server invisible without key
  • UDP rate limiting on VPN port
  • TCP rate limiting on port 443
  • Cloudflare proxy on website (hides real IP)
  • Nginx rate limiting (10 req/s per IP)
  • Telegram notifications for everything
  • UptimeRobot external monitoring

The Remaining Vulnerability

There's still one weak spot: my VPN subdomain is DNS-only (not proxied), which means anyone who knows the subdomain can see my real home IP. If they discover it, they can bypass Cloudflare entirely and attack my home internet connection directly. A massive DDoS could saturate my upstream bandwidth even if the server itself stays up.

This isn't a realistic threat for a personal portfolio DDoS-ing a random hobbyist's home for fun costs more than it's worth. But it's still the theoretical weak point.

Future Plan: VPS Reverse Proxy

The cleanest long-term solution is to put a VPS in front of everything. Rent a cheap virtual server (~€4/month on Hetzner or OVH), configure it as a reverse proxy, and tunnel traffic from the VPS back to my home server through a private VPN. The setup would look like:

Visitors → mosearc.eu (DNS) → VPS in datacenter → private tunnel → home server

Benefits:

  • No public DNS record ever points to my home IP. completely hidden
  • No port forwarding on my router: even more locked down
  • The VPS is the public face: if it gets attacked, the VPS provider deals with it
  • If the VPS goes down, my home network is untouched: only the website becomes unreachable
  • VPS providers usually have enterprise-grade DDoS protection in their datacenters

Tradeoffs:

  • €4/month subscription cost
  • One more system to maintain
  • The VPS becomes a single point of failure for the website
  • Slight added latency for visitors

I'm not doing this yet, for now the Cloudflare proxy handles 99% of the protection I need, and the VPN subdomain is obscure enough that nobody is going to find it accidentally. But it's on the list for when the project grows beyond a personal portfolio. Or just when I feel like over-engineering things again, which based on the previous 11 chapters of this blog is roughly every two weeks ahahah.


What's Next

  • UPS (still waiting on the crash experiment from last chapter)
  • Containerization with Docker
  • Self-hosted drive with NAS
  • VPS reverse proxy
  • Mail server
  • Local LLM