Self-hosting services on a custom domain securely

September 23, 2025

self-hosting networking

My Setup

  • Proxmox: Running on a bare metal server.
  • Basic Linux VM: Running services I would like to access securely.
  • Custom domain: I have the domain alexboden.ca with DNS records managed by Cloudflare.

The Goal

  • Private by default: Services should only be reachable from my devices.
  • Single URL per service: e.g. git.alexboden.ca, jellyfin.alexboden.ca, proxmox.alexboden.ca.
  • Clean HTTPS: No browser warnings, even on phones.
  • No public exposure: No NAT forwards, no dangling ports.

The Solution

I combined Tailscale Serve with Caddy running inside a VM as a single TLS termination point:

  • Tailscale Serve: Accepts incoming HTTPS traffic on port 443, does raw TCP passthrough.
  • Caddy in a VM: Terminates TLS using a private CA, then reverse-proxies each service.
  • Proxmox host: Accessed over its own Tailscale IP.

The Setup

Tailscale:

You can download tailscale here.

sudo tailscale serve reset
sudo tailscale serve --bg --tcp=443 tcp://localhost:443

This allows incoming traffic on port 443, the standard port for HTTPS.

Caddy + Cloudflare

You can download Caddy here. Since I manage my DNS with Cloudflare, I also installed the Cloudflare DNS module.

I installed Caddy as a systemd service on my self hosted VM. I added the following to my Caddyfile (/etc/caddy/Caddyfile):

(cloudflare) {
    tls {
        dns cloudflare CLOUDFLARE_API_TOKEN
    }
}

# Example of a reverse proxy for a service running on the same VM as Caddy
git.alexboden.ca {
    reverse_proxy http://localhost:3000
    import cloudflare
}

# Example of a reverse proxy for a service in the same tailnet (with a different )
proxmox.alexboden.ca {
    reverse_proxy https://100.109.111.101:8006 {
        transport http {
            tls_insecure_skip_verify
        }
        header_up Host {host}
        header_up X-Forwarded-Proto https
        header_up X-Forwarded-For {remote}
    }
    import cloudflare
}

Then reload Caddy:

systemctl restart caddy

Add the following DNS records to Cloudflare:

Type Name Content Proxy Status TTL
CNAME git.alexboden.ca vm-name.tailnet-name.ts.net DNS only Auto
CNAME proxmox.alexboden.ca vm-name.tailnet-name.ts.net DNS only Auto

You can find the VM name and tailnet name in the Tailscale dashboard.

Diagram of the setup

flowchart TD subgraph Cloudflare[Cloudflare DNS] DNS1["git.alexboden.ca"] DNS2["proxmox.alexboden.ca"] end subgraph Tailnet[Tailscale Network] U["User Devices\n(Laptop, Phone)"] TS["Tailscale Serve (TCP:443 passthrough)"] VM["Caddy (TLS Termination + Reverse Proxy)"] PVE["Proxmox Host (Bare Metal, Tailscale IP)"] Gitea["Gitea Service localhost:3000"] end %% Flows U -->|HTTPS Request git.alexboden.ca| DNS1 U -->|HTTPS Request proxmox.alexboden.ca| DNS2 DNS1 --> TS DNS2 --> TS TS --> VM VM -->|Local Reverse Proxy| Gitea VM -->|Reverse Proxy via Tailscale IP| PVE