Skip to content
← Back to blog

Self-Hosting Without Opening Ports: Cloudflare Tunnel + Traefik

How I serve this portfolio and other services from a Lenovo mini PC in my living room — no open ports, no static IP, with automatic HTTPS and Cloudflare's DDoS protection.

cloudflaretraefikdockerself-hostednetworking

This site is served from a Lenovo M910q mini PC sitting in my living room. No cloud VPS, no open firewall ports, no static IP. Just a Cloudflare Tunnel, Traefik as a reverse proxy, and a handful of Docker containers.

Here’s how it works and why I built it this way.

The Problem with Traditional Self-Hosting

The classic approach to self-hosting is to open ports 80 and 443 on your router and point them at your server. It works, but it comes with real downsides:

  • Your home IP is publicly exposed
  • You need a static IP or a dynamic DNS service
  • Every service you add needs its own port forwarding rule
  • You’re responsible for absorbing any traffic spikes or attacks

Cloudflare Tunnel solves all of this.

How Cloudflare Tunnel Works

Instead of your server accepting inbound connections, cloudflared (running as a Docker container) opens an outbound connection to Cloudflare’s edge. All traffic flows through that tunnel — your server never needs to accept a connection from the outside world.

Internet → Cloudflare Edge → cloudflared tunnel → Traefik → containers

No open ports. No exposed IP. Cloudflare sits in front of everything and handles TLS termination, DDoS protection, and caching.

The Stack

Everything runs on a dedicated Linux VM on Proxmox (more on the Proxmox setup in another post):

  • Traefik — reverse proxy, routes traffic to containers by hostname
  • cloudflared — Cloudflare Tunnel client, connects the VM to Cloudflare’s edge
  • Docker — all services run as containers on a shared network

Setting Up the Tunnel

First, create a tunnel in the Cloudflare dashboard under Zero Trust → Networks → Tunnels. You’ll get a tunnel token.

Then run cloudflared as a Docker container:

services:
  cloudflared:
    image: cloudflare/cloudflared:latest
    command: tunnel --no-autoupdate run --token ${CLOUDFLARE_TUNNEL_TOKEN}
    restart: unless-stopped
    networks:
      - traefik-net

In the Cloudflare dashboard, configure the tunnel’s public hostname to point to your Traefik instance:

  • Public hostname: andreirepo.com
  • Service: http://traefik:80

Cloudflare handles HTTPS externally. Traffic arrives at Traefik over plain HTTP inside the tunnel — which is fine since it never leaves the private network.

Traefik Configuration

Traefik discovers services automatically via Docker labels. Each container declares its own routing rules:

services:
  portfolio:
    image: ghcr.io/andreirepo/andrei-portfolio:main
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.portfolio.rule=Host(`andreirepo.com`)"
      - "traefik.http.routers.portfolio.entrypoints=web"
    networks:
      - traefik-net

networks:
  traefik-net:
    external: true

Adding a new service is just a matter of adding labels — no nginx config files, no manual routing rules.

Umami Analytics

Because everything is on the same Docker network, adding Umami for self-hosted analytics was straightforward. Umami runs as two containers (app + Postgres), both on traefik-net, with a Traefik label pointing analytics.andreirepo.com at the Umami container.

The portfolio site sends analytics events to analytics.andreirepo.com — which goes through Cloudflare Tunnel just like the main site. No third-party analytics, no data leaving my infrastructure, and the script is served from my own domain so it’s less likely to be blocked by ad blockers.

Benefits in Practice

No open ports — my router has zero port forwarding rules. The only outbound connection is the tunnel.

No static IP needed — the tunnel reconnects automatically if my IP changes. I’ve had zero downtime from IP changes in months of running this.

Free DDoS protection — Cloudflare absorbs traffic spikes before they reach my home network.

Easy service expansion — adding a new subdomain is two steps: add Docker labels to the container, add a public hostname in the Cloudflare tunnel config. Done.

Cost — Cloudflare Tunnel is free on the Zero Trust free tier (up to 50 users). The only cost is the domain itself.

The Trade-offs

It’s not perfect. Cloudflare sits between your users and your server, which means:

  • Cloudflare can see your traffic (though it’s encrypted end-to-end for sensitive services)
  • You’re dependent on Cloudflare’s availability
  • Latency is slightly higher than a direct connection

For a personal portfolio and home lab, these are acceptable trade-offs. For anything handling sensitive user data, you’d want to think more carefully.

What’s Next

In the next post I’ll cover the CI/CD side — how I use a self-hosted GitHub Actions runner and SSH over Cloudflare Tunnel to deploy directly to this machine from a GitHub push, without ever exposing an SSH port to the internet.