Beat CGNAT with a VPS reverse tunnel
CGNAT leaves you with no public IP. Put frp on a cheap VPS, dial the tunnel out from home, and terminate HTTPS on the public end with a real certificate.
Why port forwarding does nothing behind CGNAT
Behind CGNAT (carrier-grade network address translation) your router's WAN address is shared with other subscribers, so no public IP belongs to you and there is no port to forward. A reverse tunnel fixes that: a cheap VPS holds the public IP, your home box dials out to the VPS, and inbound requests ride back down the connection the home box already opened. You keep the hardware you already own. You rent the one thing your ISP will not sell you, which is a routable address.
Every command below is labelled with the machine it runs on. This needs two machines: the VPS with a public IP, and the home box running the service you want to reach.
How to tell if you are actually behind CGNAT
Open your router's admin page and read the WAN address it reports. Then ask the internet what address it sees.
# on the home box
curl -4 -s https://ifconfig.me; echoIf the two addresses match, you have a public IP and you do not need any of this. Forward the port and stop reading. If the router's WAN address sits inside 100.64.0.0/10, you are behind CGNAT. That block is RFC 6598 shared address space, reserved for exactly this use. Some ISPs put 10.0.0.0/8 on the WAN side instead, which is the same situation wearing a different label.
Check one thing before you rent anything. Many CGNAT ISPs hand out a real IPv6 prefix, and if your home box has a global IPv6 address you can open the firewall on that address and skip the tunnel entirely. It stops working the moment a visitor is on an IPv4-only network, which is why most people end up here anyway.
How a VPS reverse tunnel works, dialled from the inside out
CGNAT and ordinary home routers both block unsolicited inbound connections. Corporate firewalls do too. None of them block outbound connections, because outbound is what every browser and every update client does all day. A NAT device that sees an outbound TCP connection creates a mapping for it and then allows the return traffic on that connection. Nothing on the outside can start a connection to your home box. So the home box starts it, and the tunnel carries traffic back down that same connection in the other direction.
That is the whole mechanism. The home box connects out to the VPS on one port and holds the connection open. The VPS accepts public requests and hands them down that existing connection. Nothing ever tries to reach your home IP address, so nothing has to.
Two consequences follow, and both are useful. Your DNS record points at the VPS, never at your house. And your public address is now the VPS's address, so whatever an observer learns from an IP lookup, they learn about a rented server instead of your home line.
Three ways to build it
ssh -R: already installed on both ends, and right for one service or a temporary demo. It gives you no dashboard and no reconnection logic worth the name.- frp: a small Go server (
frps) and a matching client (frpc). Right for a permanent setup with several services behind one hostname. This is the bulk of the guide. - A mesh VPN: Tailscale, or a WireGuard server you run yourself. Right when you want your own devices to reach each other privately rather than to publish something to the public internet.
Pick the mesh if your goal is private access from devices you control. Tailscale Serve and Funnel covers publishing out of a tailnet, and a self-hosted WireGuard VPN on the same VPS gives you the same shape with no third-party coordination server in the path. Read one of those and skip the rest of this page. Everything below assumes you want a public HTTPS hostname that anyone can load.
The quick version: ssh -R for one service
Say the home box runs an app on 127.0.0.1:3000 and you already have SSH access to the VPS.
# on the home box
ssh -N \
-o ExitOnForwardFailure=yes \
-o ServerAliveInterval=30 \
-o ServerAliveCountMax=3 \
-R 127.0.0.1:8080:127.0.0.1:3000 \
tunnel@vps.example.com-R 127.0.0.1:8080:127.0.0.1:3000 tells the VPS's sshd to listen on its own 127.0.0.1:8080 and send whatever arrives there to 127.0.0.1:3000 on the home box. -N means do not run a shell. The two ServerAlive options make ssh notice a dead link in about ninety seconds instead of hanging on a connection that no longer exists.
Now the part that confuses everybody. That listener is on loopback, so curl http://vps.example.com:8080 from anywhere else fails. sshd ships with GatewayPorts no, which means a remote forward binds to the loopback interface only. Do not fix this by setting GatewayPorts yes. Leave the forward on loopback and put nginx in front of it, the same way the frp setup below does, so the public port is 443 with a certificate and the tunnel port never faces the internet. If you are unsure what is currently listening and on which interface, a short tour of ports and listeners on Linux is worth ten minutes.
If the port is already claimed on the VPS, ssh prints this, and ExitOnForwardFailure=yes makes it give up instead of connecting with no working tunnel:
Warning: remote port forwarding failed for listen port 8080The usual cause is a previous session that died without sshd noticing. Set ClientAliveInterval 30 and ClientAliveCountMax 3 in the VPS's /etc/ssh/sshd_config so dead sessions get reaped and release the port. Wrap the whole command in a systemd unit with Restart=always and a dedicated key, or use autossh. For anything with more than one service, stop here and use frp.
Install frp on the VPS, pinned to a tag
frp ships as a static Go binary and it is not in the Ubuntu or Debian archives, so you download a release and verify it yourself. Pin the version. The configuration format changed at v0.52.0 and option names have moved since, so a stale tutorial will hand you keys your binary does not recognise. This guide uses v0.71.0, published on 14 August 2026.
# on the VPS
FRP_VERSION=0.71.0
ARCH=amd64 # use arm64 if `uname -m` prints aarch64
cd /tmp
curl -fsSLO "https://github.com/fatedier/frp/releases/download/v${FRP_VERSION}/frp_${FRP_VERSION}_linux_${ARCH}.tar.gz"
curl -fsSLO "https://github.com/fatedier/frp/releases/download/v${FRP_VERSION}/frp_sha256_checksums.txt"
sha256sum --check --ignore-missing frp_sha256_checksums.txtsha256sum should print exactly one line:
frp_0.71.0_linux_amd64.tar.gz: OK--ignore-missing is needed because the checksum file covers all eighteen release assets and you downloaded one of them. Without that flag, sha256sum reports the other seventeen as missing and exits non-zero, which reads like a failed verification when nothing is wrong.
# on the VPS
tar xzf "frp_${FRP_VERSION}_linux_${ARCH}.tar.gz"
sudo install -m 755 "frp_${FRP_VERSION}_linux_${ARCH}/frps" /usr/local/bin/frps
sudo useradd --system --no-create-home --shell /usr/sbin/nologin frp
sudo install -d -m 750 -o root -g frp /etc/frp
frps --versionfrps --version prints 0.71.0. Only frps goes on the VPS. frpc goes on the home box. Installing both binaries everywhere is how people end up accidentally running a tunnel server at home.
The VPS config: token, forced TLS, loopback listeners
Generate a token first. It is the only thing standing between your tunnel and anyone who portscans the VPS.
# on the VPS
openssl rand -base64 32Write that value into /etc/frp/frps.toml:
bindAddr = "0.0.0.0"
bindPort = 7000
# Every listener frp creates for a proxy, including the HTTP vhost, stays on loopback.
proxyBindAddr = "127.0.0.1"
vhostHTTPPort = 8080
auth.method = "token"
auth.token = "PASTE_THE_OPENSSL_OUTPUT_HERE"
transport.tls.force = true
webServer.addr = "127.0.0.1"
webServer.port = 7500
webServer.user = "admin"
webServer.password = "PASTE_A_SECOND_SECRET_HERE"
log.level = "info"Four of those lines are doing the security work, so take them one at a time.
auth.token must match auth.token on the client. Without it, frps accepts any client that finds port 7000, and that client can then publish whatever it likes through your VPS and your certificate.
transport.tls.force = true rejects any control connection that is not TLS (transport layer security). Clients have enabled TLS by default since v0.50.0, so this costs you nothing in practice and it closes the case where an old or hand-rolled client connects in the clear without telling you.
proxyBindAddr = "127.0.0.1" is the line most guides leave out, and it is why this setup is safe to leave running. It moves every listener frp opens on behalf of a proxy, both the HTTP vhost and any remotePort a client asks for, onto the loopback interface. The internet cannot reach those listeners at all. The only public door is nginx on 443, which you configure and control.
webServer.addr = "127.0.0.1" keeps the dashboard off the public interface. The dashboard is a complete map of your private services and their traffic, protected by one HTTP basic auth password, so it does not belong on 0.0.0.0.
Set ownership so the token is not world readable, then check the syntax before you start anything:
# on the VPS
sudo chown root:frp /etc/frp/frps.toml
sudo chmod 640 /etc/frp/frps.toml
sudo -u frp frps verify -c /etc/frp/frps.tomlA valid file prints:
frps: the configuration file /etc/frp/frps.toml syntax is okOne format note that will save you an hour. frp picks its parser from the file extension, and it knows .toml, .yaml, .yml and .json. Old .ini files still load through a legacy conversion path, but INI is deprecated and new options are documented for TOML only. If a tutorial shows you a [common] section and server_addr = x.x.x.x, it predates v0.52.0 and its key names will not match the binary you just installed.
Run frps as an unprivileged service
bindPort is 7000 and vhostHTTPPort is 8080. Both are above 1024, so frps never needs root and never needs CAP_NET_BIND_SERVICE. That is the reason not to put the vhost on port 80 and let nginx take that instead.
Write /etc/systemd/system/frps.service:
[Unit]
Description=frp reverse tunnel server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=frp
Group=frp
ExecStart=/usr/local/bin/frps -c /etc/frp/frps.toml
Restart=on-failure
RestartSec=5s
LimitNOFILE=65535
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
[Install]
WantedBy=multi-user.target# on the VPS
sudo systemctl daemon-reload
sudo systemctl enable --now frps
sudo journalctl -u frps -n 20 --no-pagerThe log should show both listeners, and the addresses matter more than the ports:
frps tcp listen on 0.0.0.0:7000
http service listen on 127.0.0.1:8080ProtectSystem=strict makes the whole filesystem read-only for this service. frps tolerates that because its log goes to standard output by default and journald captures it. If you set log.to to a file path, the service fails to write it until you add a matching ReadWritePaths= line, so leave the default alone.
Firewall: open one port, not a range
# on the VPS
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 7000/tcp
sudo ufw enable
sudo ufw status numberedFour rules, and one of them exists only for certificate renewal. 22 is SSH. 80 redirects to 443 and answers the ACME (automatic certificate management environment) challenge. 443 serves every tunnelled app. 7000 is the frp control port, and it is the only port a client ever needs to reach.
Guides that tell you to open a range such as sudo ufw allow 20000:30000/tcp are describing the other design, where each service claims its own public TCP port. You do not need that here, because everything arrives on 443 and frp routes it by hostname. If you later need one genuinely public TCP port, put proxyBindAddr back to 0.0.0.0 and add limits so a client can only claim ports you named:
allowPorts = [
{ start = 20000, end = 20010 }
]
maxPortsPerClient = 5Most providers also run a network firewall in the control panel, separate from ufw on the box. A rule that looks correct in sudo ufw status and still times out is usually blocked there. The ufw rules a VPS actually needs walks through the default-deny setup this section assumes.
Terminate HTTPS on the VPS with a real certificate
Point an A record for home.example.com at the VPS's public IP. Not at your house. Your house has no address to point at, which is the problem you are solving.
# on the VPS
sudo apt update
sudo apt install -y nginx certbot python3-certbot-nginxCreate /etc/nginx/sites-available/home.example.com with a plain port 80 block first, so certbot has a matching server_name to work with:
server {
listen 80;
listen [::]:80;
server_name home.example.com;
location / { return 404; }
}# on the VPS
sudo ln -s /etc/nginx/sites-available/home.example.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
sudo certbot certonly --nginx -d home.example.comnginx -t prints nginx: configuration file /etc/nginx/nginx.conf test is successful when the files parse. Run it before every reload. nginx keeps the old configuration running when a reload fails, so a broken edit looks like an edit that did nothing.
WebSocket upgrades need one map at the http level. Put it in /etc/nginx/conf.d/upgrade.conf:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}Now replace the site file with the real one:
server {
listen 80;
listen [::]:80;
server_name home.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name home.example.com;
ssl_certificate /etc/letsencrypt/live/home.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/home.example.com/privkey.pem;
client_max_body_size 512m;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
}
}proxy_set_header Host $host; is not optional here. frp's HTTP vhost routes by the Host header, matching it against the customDomains list in the client config. Leave that header out and nginx sends Host: 127.0.0.1, frp finds no proxy for that name, and your visitor gets a bare 404 from frp instead of a page from the app. Every line of an nginx reverse proxy block, explained covers what the other headers are doing.
# on the VPS
sudo nginx -t && sudo systemctl reload nginx
sudo certbot renew --dry-runThe dry run proves renewal will work in ninety days, when you will not be watching. It needs port 80 reachable, which is why that ufw rule is there.
The home side: frpc as an unprivileged service
Install frpc on the home box exactly as you installed frps, same version and same checksum step, then create the same frp user and /etc/frp directory. Write /etc/frp/frpc.toml:
serverAddr = "vps.example.com"
serverPort = 7000
auth.method = "token"
auth.token = "PASTE_THE_SAME_TOKEN_HERE"
transport.tls.enable = true
loginFailExit = false
proxies = [
{ name = "home-app", type = "http", localIP = "127.0.0.1", localPort = 3000, customDomains = ["home.example.com"] }
]Key order matters in this file, and not for style reasons. TOML assigns every key after a table header to that table, so a top-level setting such as serverAddr written below a proxy table header quietly becomes a proxy setting that frp ignores. Writing the proxy list as an inline array, the way it appears above, avoids the trap: every top-level key stays unambiguously top level.
type = "http" routes this proxy through the vhost listener instead of claiming its own public TCP port, which is why the firewall stayed at four rules. customDomains must contain the hostname nginx forwards in the Host header, so it is home.example.com and never the VPS's IP address.
loginFailExit = false matters more than it looks. The default is true, which makes frpc exit if its first login attempt fails. On a home box that finishes booting before the ISP link is up, that is a service which is dead until you happen to notice. Set it to false and frpc keeps retrying until the VPS answers.
Write /etc/systemd/system/frpc.service:
[Unit]
Description=frp reverse tunnel client
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=frp
Group=frp
ExecStart=/usr/local/bin/frpc -c /etc/frp/frpc.toml
Restart=always
RestartSec=10s
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
[Install]
WantedBy=multi-user.target# on the home box
sudo chown root:frp /etc/frp/frpc.toml
sudo chmod 640 /etc/frp/frpc.toml
sudo -u frp frpc verify -c /etc/frp/frpc.toml
sudo systemctl daemon-reload
sudo systemctl enable --now frpc
sudo journalctl -u frpc -n 20 --no-pagerA client that connected logs a run id:
login to server success, get run id [3a1f9c2b7d4e5f60]Open https://home.example.com in a browser and you should get the app that lives on 127.0.0.1:3000 at home. Restart=always on the client is deliberate: home connections drop, and the service should come back without you.
Keep the dashboard off the public interface
With webServer.addr = "127.0.0.1" the dashboard answers only on the VPS itself. Reach it from your laptop with a local forward instead of opening a port:
# on your laptop
ssh -N -L 7500:127.0.0.1:7500 you@vps.example.comOpen http://127.0.0.1:7500 and sign in with the webServer.user and webServer.password from frps.toml. The page lists every connected client and the traffic counters for each proxy, which makes it the fastest way to answer the question "is the home box even connected right now". Close the ssh session and the dashboard is unreachable again.
What the tunnel does not do
Read this part twice, because it is where people get hurt. The tunnel makes a private service reachable from the public internet. It does not authenticate the people who reach it. Once https://home.example.com resolves, scanners will find it within days, and they will find it whether or not you told anyone the name. Certificate transparency logs publish every hostname you issue a certificate for, so the name is public the moment certbot succeeds.
Whatever you expose has to carry its own authentication. If the app has a real login with rate limiting, good. If its login is one shared password, or if it has no login at all, put an authenticating proxy in front of it on the VPS. An oauth2-proxy sitting in front of the app is the usual answer, and it slots between nginx and the frp vhost with no change to either end of the tunnel.
The token in frps.toml protects the tunnel, not the apps. It stops a stranger registering their own proxy on your VPS. It does nothing about a request that arrives on 443 for a hostname you published on purpose.
Two habits are worth keeping. Rotate the token by editing both files and restarting both services, because it never expires on its own. And keep frp current: this binary is your public-facing front door, and the v0.71.0 notes list a server panic triggered by a bad value sent from a client, which is the class of bug you want patched rather than reasoned about.
Failure modes, with the strings you will see
The client never connects. journalctl -u frpc repeats connect to server error: followed by a dial timeout. Nothing is reaching port 7000. Check ufw on the VPS, then the provider's network firewall in the control panel, then confirm the name resolves with getent hosts vps.example.com.
The token is wrong. The client says so in as many words:
login to the server failed: token in login doesn't match token from configurationCopy the token again. A trailing newline, or a $ in an unquoted shell string that expanded to nothing, causes almost all of these. That is why the openssl rand -base64 32 output belongs inside quotes in the TOML file.
The tunnel is up but the browser gets a bare 404. frpc logged a successful login and the dashboard lists the proxy, yet the page returns 404 with none of the app's styling. That is frp reporting it has no proxy for this Host header. Test the vhost directly on the VPS, bypassing both nginx and TLS:
# on the VPS
curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: home.example.com' http://127.0.0.1:8080/A 404 from that command means customDomains is wrong. Any other code means the request never got the right Host from nginx.
502 from nginx. nginx is answering and frp is not. sudo ss -lntp | grep 8080 on the VPS should show frps listening on 127.0.0.1:8080. Empty output means frps is stopped, or vhostHTTPPort is unset in frps.toml.
The app thinks every visitor is local. Your app logs 127.0.0.1 for every request. frp sets X-Forwarded-For and nginx appends to it, so the real client address is in that header. Configure the app to trust it. Do not skip this if the app rate limits by IP address, because at the moment every visitor on the internet shares one bucket.
Long requests get cut at 60 seconds. Uploads or streaming responses stop partway through. That is nginx's default proxy_read_timeout, not the tunnel. The block above raises it to 3600s. client_max_body_size is the matching limit for upload size, and its default of 1 MB rejects larger bodies with a 413.
Everything works, then dies after a router reboot. Restart=always in the frpc unit plus loginFailExit = false covers this. Confirm with sudo systemctl is-enabled frpc, which must print enabled.
FAQ
How do I know if I am behind CGNAT?
Compare the WAN address on your router's admin page with what curl -4 -s https://ifconfig.me reports from inside the same network. If they differ and the router's WAN address is inside 100.64.0.0/10, your ISP is running carrier-grade NAT. That range is RFC 6598 shared address space and it exists for this purpose. Some ISPs use 10.0.0.0/8 on the WAN side instead, which means the same thing. If the two addresses match, you have a public IP: forward the port and you are finished.
Do I need a domain name for a reverse tunnel?
For the HTTPS setup described here, yes. A certificate is issued to a hostname, and frp's HTTP vhost routes requests by the Host header, so both ends need a name to agree on. A raw TCP proxy on a numbered port works against the VPS's bare IP with no domain at all, but then you have no certificate and no hostname routing, so one public port serves exactly one service.
Is it safe to run frp on a public VPS?
It is safe when the control port is the only thing exposed and it is authenticated. Set auth.token to a random value on both ends and transport.tls.force = true on the server. Then set proxyBindAddr = "127.0.0.1" so nothing frp opens for a proxy faces the internet, and keep the dashboard on webServer.addr = "127.0.0.1", reached over an SSH local forward. Update the binary when releases land, because it is the process listening on your public address.
Why can nobody reach my ssh -R forwarded port?
sshd ships with GatewayPorts no, so a remote forward binds only to the VPS's loopback interface. curl run on the VPS itself works and curl from anywhere else times out. The correct fix is to leave the forward on loopback and put nginx on 443 in front of it. Setting GatewayPorts yes publishes a raw port with no certificate and no TLS, which is worse than the problem it solves.
Should I use frp or a mesh VPN like Tailscale or WireGuard?
Use a mesh VPN when only your own devices need access, because then nothing is published and there is no public hostname for anyone to scan. Use frp when you need a public HTTPS address that any browser can load, such as a webhook receiver or a page you share with people who will not install a VPN client. The two coexist happily on one VPS, on different ports, doing different jobs.