Exposing a Home Lab to the Internet with a $2/month VPS

How to publish self-hosted services running on a home server to the public internet using a cheap VPS and a reverse SSH tunnel - no port forwarding, no static IP, no Cloudflare in the middle. Covers the systemd/autossh setup, the double-hop Caddy config, buying a domain, why a static IP from your ISP usually isn't worth it, and using the same VPS as a whole-home VPN.

July 19, 2026 home-lab

The Problem

A home lab is great until you want to reach it from outside your own Wi-Fi - checking Immich photos on the road, sharing a Jellyfin library with family, or just hitting your Home Assistant dashboard from your phone’s mobile data.

The home server sits behind a router with no public IP (or a CGNAT one your ISP won’t let you forward ports on anyway), so there’s nothing to point a domain at directly.

There are basically four ways to solve this:

  1. Port forwarding + dynamic DNS - your router doesn’t know where to send inbound packets unless you tell it, so you forward ports 80/443 to the home server and use a dynamic DNS service to track your changing public IP. Simplest option, but it means your home router is directly reachable from the entire internet and your real home IP is public.
  2. A static public IP from your ISP - works, and removes the dynamic DNS problem, but it’s usually not worth the price - a VPS is often cheaper (more on that below).
  3. Cloudflare Tunnel (or similar) - free, zero-config, but every byte of your traffic is decrypted and re-encrypted by Cloudflare in the middle.
  4. A cheap VPS + reverse SSH tunnel - a few dollars a month, no inbound ports on the home router at all, and no third party sees your plaintext traffic.

This post covers option 4 end-to-end: the actual setup running on my own home lab, plus why I ruled out the other three.

Architecture: visitor connects to the VPS over HTTPS, the VPS forwards it down a reverse SSH tunnel to the home server, which routes it to the right container

The home server never accepts inbound connections from the internet - it dials out to the VPS and keeps that connection open. The VPS only forwards traffic back down the same tunnel it already trusts. Nobody outside can reach the home network directly: no port forwarding on the router, no CGNAT/dynamic-IP problems, and the real home IP is never revealed to a visitor or to any third-party proxy service. Compare to Cloudflare Tunnel: same trick, but the middlebox also terminates TLS and sees plaintext traffic.

Why not a static IP from the ISP

Some ISPs sell a static public IPv4 address as an add-on. It removes the CGNAT/dynamic-DNS problem directly, so it’s tempting.

I don’t recommend it, for two reasons:

  • Price. Depending on the ISP, a static IP add-on runs anywhere from a few dollars a month to significantly more than an entire VPS costs - for strictly less functionality than the VPS gives you.
  • Safety. With a static ISP IP, your home router is the thing directly facing the internet. Every port you open is a port an attacker can probe against your actual home network. With the VPS approach, the VPS is the thing facing the internet - it’s disposable, isolated, and if it gets compromised, the attacker is still on the other side of an SSH tunnel from your home LAN, not on your LAN itself.

The only case where a static ISP IP makes sense is if you specifically need a stable IP for something that can’t tolerate a middlebox at all (e.g. certain VoIP/SIP setups). For serving web apps, the VPS wins on both price and blast radius.

Why not Cloudflare Tunnel

Cloudflare Tunnel (cloudflared) is genuinely easy: install an agent on the home server, it dials out to Cloudflare, Cloudflare gives you free TLS and a free subdomain, done. No VPS needed at all.

I decided against it on privacy grounds. With a tunnel, Cloudflare’s edge terminates TLS - meaning all your traffic is decrypted on Cloudflare’s servers before being re-encrypted and forwarded to your tunnel. That’s a textbook man-in-the-middle position, just one you’ve opted into. For a public blog it’s a non-issue. For photos, home automation, and personal cloud storage, I’d rather not hand a third party the plaintext of everything, however much I trust their reputation.

The VPS approach has exactly one party terminating TLS: a server I rent, configure, and fully control.

Free domains (e.g. a duckdns.org or nip.io subdomain) pair naturally with Cloudflare Tunnel, since you don’t need to point DNS at an IP you own. If you go the VPS route instead, you still need a real domain you control - see below.

The setup

1. Get a VPS

Any cheap VPS with a public IPv4 works. 1 vCPU / 512MB-1GB RAM is plenty - this whole setup is just a TLS-terminating reverse proxy and an SSH daemon, it barely uses any resources. Shop around; small European providers and the usual budget VPS players are all a few dollars a month or less.

2. SSH key, no password

On the home server, generate a key and copy it to the VPS:

Terminal window
ssh-keygen -t ed25519 -f ~/.ssh/vps_tunnel
ssh-copy-id -i ~/.ssh/vps_tunnel.pub root@YOUR_VPS_IP

Add a host alias in ~/.ssh/config on the home server so the tunnel command stays simple:

Host vps
Port 22
User root
HostName YOUR_VPS_IP
IdentityFile ~/.ssh/vps_tunnel

3. The reverse tunnel itself

This is the actual trick, and it’s just one line of autossh: the home server opens an outbound SSH connection to the VPS and asks the VPS to forward one of its own ports back down that same connection.

autossh -M 0 -N -o ExitOnForwardFailure=yes \
-R 127.0.0.1:8080:127.0.0.1:80 \
vps

-R 127.0.0.1:8080:127.0.0.1:80 - anything hitting 127.0.0.1:8080 on the VPS gets forwarded through the tunnel to 127.0.0.1:80 on the home server (the local Caddy instance).

Binding to 127.0.0.1 on the VPS side means the forwarded port is not reachable directly from the internet - only from something else running locally on the VPS (Caddy, in the next step).

Wrapped in a systemd unit so it survives reboots and reconnects on failure:

/etc/systemd/system/ssh-vps-forward.service
[Unit]
Description=Persistent SSH Connection to VPS
After=network-online.target
[Service]
User=youruser
# wait for the local web server to actually be up
# before dialing out, otherwise autossh forwards to nothing
ExecStartPre=/usr/bin/bash -c 'until ss -ltn sport = :80; do sleep 2; done'
ExecStart=/usr/bin/autossh -M 0 -N -o ExitOnForwardFailure=yes \
-R 127.0.0.1:8080:127.0.0.1:80 \
vps
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
Terminal window
sudo systemctl daemon-reload
sudo systemctl enable --now ssh-vps-forward.service

The ExecStartPre check matters more than it looks. If Caddy restarts (updates, reboot, crash) after autossh has already connected, the tunnel keeps running but forwards into a dead port until autossh itself is restarted. Building in an explicit dependency, or at minimum ordering the tunnel’s After= on the container, saves a confusing “site down but tunnel looks fine” debugging session.

4. Caddy on the VPS

The VPS needs something listening on 80/443 to grab a real TLS certificate and forward decrypted traffic into the tunnel. A stock Caddy install does the whole job - no custom build needed. The Caddyfile on the VPS:

yourdomain.com, *.yourdomain.com {
reverse_proxy 127.0.0.1:8080
}

That’s it on the VPS side: automatic HTTPS certs for the domain and every subdomain, all forwarded down the tunnel to port 8080, which autossh is already piping to the home server’s port 80.

Notice reverse_proxy 127.0.0.1:8080 is plain HTTP, not HTTPS. The visitor-to-VPS leg is HTTPS, but the VPS-to-home leg doesn’t need its own certificate: it never touches the public internet. It’s a loopback connection on the VPS (127.0.0.1) that autossh happens to be piping through an already-encrypted SSH tunnel to the home server. TLS terminates once, at the VPS - encrypting it again for that hop would be redundant.

5. Caddy on the home server

The VPS doesn’t know or care what’s running at home - it just blindly forwards to port 80. The actual routing by subdomain happens on the home server’s own Caddy instance, which was probably already running there for local .home.arpa domains.

So the full chain for one request looks like: Caddy on the VPS (public, terminates TLS) → SSH tunnel → Caddy on the home server (private, per-subdomain routing)the actual service (Immich, Jellyfin, Home Assistant, …). The VPS only ever sees “forward everything to port 8080”; the home Caddy is the one deciding which container each subdomain actually goes to:

http://*.yourdomain.com, http://yourdomain.com {
@photos host photos.yourdomain.com
handle @photos {
reverse_proxy immich:2283
}
@movies host movies.yourdomain.com
handle @movies {
reverse_proxy jellyfin:8096
}
@assistant host assistant.yourdomain.com
handle @assistant {
reverse_proxy host.docker.internal:8123
}
}

6. The domain

You need a real domain you own - buy one from any registrar (Namecheap, Cloudflare Registrar, OVH, etc., a few dollars to ~$15/year depending on the TLD). Then just point DNS at the VPS:

A yourdomain.com -> VPS_PUBLIC_IP
A *.yourdomain.com -> VPS_PUBLIC_IP

A wildcard A record means every subdomain (photos., movies., assistant., …) resolves to the same VPS IP, and Caddy on the VPS handles routing per-hostname from there. No DNS changes needed when adding a new service - just add a new handle block on the home Caddy config.

Using the same VPS as a VPN

The same VPS can also double as an exit node for your whole home network’s outbound traffic, not just for exposing services inbound. Put WireGuard on it and connect your router (or individual devices) as clients - every packet leaving your home network then appears to come from the VPS’s public IP and location instead.

A home network in Poland connects over WireGuard to a VPS in the US; from any website's point of view, the traffic originates in the US

Pick the VPS region to match what you need: a US region gets you a US exit IP for the whole home network, a cheap local region keeps latency low for the reverse tunnel. Nothing stops running both roles - reverse tunnel for inbound services, WireGuard for outbound browsing - on the very same VPS.

I’ll cover the actual WireGuard setup in a separate post.

Wrapping up

The net result: services running on hardware sitting under a desk, reachable at a real domain with a real TLS certificate, with the home router never opening a single inbound port. The VPS is the only thing directly exposed to internet scanners, and it only ever forwards to 127.0.0.1 sockets that are themselves fed by an outbound-only SSH connection.

Cost: a domain (~$10-15/year) plus the cheapest VPS a provider offers (often $2-5/month). Considerably cheaper than most ISPs’ static-IP add-on, and with no third party sitting on your plaintext traffic in between.

Do you like the content?

Your support helps me continue my work. Please consider making a donation.

Donations are accepted through PayPal or Stripe. You do not need a account to donate. All major credit cards are accepted.

Portrait of the author

About me

A JavaScript specialist with many years of industry experience whose heart beats for Angular. A follower of the "Keep it simple, stupid" principle and a fan of clean code and good architecture. Fearless in the face of the toughest challenges, I always look for simple and effective solutions. As a pragmatist and an enthusiast of new technologies, I passionately follow the trends in the JavaScript world. After hours — a sailor who loves discovering new places and spending time on trips. My favorite motto? "Talk is cheap. Show me the code."

Leave a comment