
FRP Guide: Expose Home Services Without Port Forwarding
Learn to expose home services using FRP, Docker, and Caddy. No port forwarding needed, perfect for CGNAT environments with multiple services.

TLDRQuick Summary
- •FRP (Fast Reverse Proxy) lets you expose home services without port forwarding
- •Works perfectly with CGNAT environments where traditional port forwarding is impossible
- •One VPS can handle multiple services from multiple local machines
- •Caddy provides automatic HTTPS with Let's Encrypt certificates
- •Adding new services requires minimal configuration changes
You've got a home server running Immich, n8n, or some other self-hosted app, and you want clean domains like `images.yourdomain.com` or `automation.yourdomain.com` instead of ugly ports and IPs. Your ISP is behind CGNAT, port forwarding is unreliable or impossible, and paying for a static IP feels like a tax on self-hosting. The better option is to tunnel everything through a VPS using FRP (Fast Reverse Proxy) and Docker.
Step 1: Prepare Your VPS as the Central Entry Point
You need a VPS with:
- A public static IPv4 (for example,
150.265.32.125) - Docker and Docker Compose installed
- Ports 80, 443, and a FRP control port (like 7000) open in the provider's firewall and on the OS firewall (ufw/firewalld)
SSH into the VPS and create a directory for the FRP server and Caddy:
mkdir -p ~/frp-server
cd ~/frp-server
Create the FRP server config frps.toml:
cat > frps.toml << EOF
bindPort = 7000
vhostHTTPPort = 8080
vhostHTTPSPort = 8443
log.to = "console"
EOF
bindPortis where frpc clients connect.vhostHTTPPortis where FRP listens for HTTP virtual-host traffic (per-domain routing).vhostHTTPSPortis reserved if you later do HTTPS-level handling through FRP itself.
Now create a docker-compose.yml to run frps and Caddy on the VPS:
cat > docker-compose.yml << EOF
services:
frps:
image: snowdreamtech/frps:latest
restart: unless-stopped
network_mode: host
volumes:
- ./frps.toml:/etc/frp/frps.toml
caddy:
image: caddy:alpine
restart: unless-stopped
network_mode: host
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:
EOF
Create an initial Caddyfile for one domain (more will be added later):
cat > Caddyfile << EOF
yourdomain.com {
reverse_proxy localhost:8080
}
EOF
Start the stack:
docker compose up -d
docker logs frps
docker logs caddy
At this point, the VPS is ready to accept FRP client connections on port 7000 and HTTP traffic on port 8080, with Caddy handling HTTPS on 443 and proxying requests down to FRP.
Step 2: Expose Immich from Machine 1 via FRP
On Machine 1, Immich is running at http://192.168.1.14:2283. You will run frpc on this same machine to forward yourdomain.com to that internal address.
Create a client directory:
mkdir -p ~/frp-client
cd ~/frp-client
Create frpc.toml:
cat > frpc.toml << EOF
serverAddr = "150.265.32.125"
serverPort = 7000
[[proxies]]
name = "immich"
type = "http"
localIP = "192.168.1.14"
localPort = 2283
customDomains = ["yourdomain.com"]
EOF
Key points from FRP's HTTP vhost docs:
type = "http"is what allows FRP to route based onHostheader.customDomainsmust match the domain you will point at the VPS.
Create a docker-compose.yml for frpc:
cat > docker-compose.yml << EOF
services:
frpc:
image: snowdreamtech/frpc:latest
container_name: frpc
restart: unless-stopped
network_mode: host
volumes:
- ./frpc.toml:/etc/frp/frpc.toml
EOF
Start the client:
docker compose up -d
docker logs frpc
You should see a log like:
[immich] start proxy success
Step 3: Wire DNS and HTTPS for Immich
In your DNS provider (for example, Cloudflare), create an A record:
Type: A
Name: images
IPv4: 150.265.32.125
Proxy: DNS only (no CDN proxy while debugging)
Caddy is already configured for yourdomain.com and will automatically request a Let's Encrypt certificate and terminate HTTPS.
Test in your browser:
https://yourdomain.com
If Immich is reachable locally and frpc/frps are connected, you should see Immich loaded over HTTPS. If not:
- Check
docker logs frpcon Machine 1. - Check
docker logs frpsanddocker logs caddyon the VPS for TLS or proxy errors.
Step 4: Add a Second Local Service (n8n) on the Same Machine
On the same Machine 1, n8n is available at http://192.168.1.14:5678 and should be exposed as automation.yourdomain.com. FRP supports multiple [[proxies]] entries in one frpc.toml, all mapped to different domains.
Edit ~/frp-client/frpc.toml on Machine 1:
cd ~/frp-client
cat > frpc.toml << EOF
serverAddr = "150.265.32.125"
serverPort = 7000
[[proxies]]
name = "immich"
type = "http"
localIP = "192.168.1.14"
localPort = 2283
customDomains = ["yourdomain.com"]
[[proxies]]
name = "n8n"
type = "http"
localIP = "192.168.1.14"
localPort = 5678
customDomains = ["automation.yourdomain.com"]
hostHeaderRewrite = "automation.yourdomain.com"
EOF
The hostHeaderRewrite directive is taken directly from FRP's header modification docs and ensures that the Host header reaching n8n is exactly what n8n expects.
Restart frpc:
docker compose down
docker compose up -d
docker logs frpc
On the VPS, extend your Caddyfile:
cd ~/frp-server
cat > Caddyfile << EOF
yourdomain.com {
reverse_proxy localhost:8080
}
automation.yourdomain.com {
reverse_proxy localhost:8080 {
header_up Host automation.yourdomain.com
}
}
EOF
docker compose restart caddy
Add DNS for the new subdomain:
Type: A
Name: automation
IPv4: 150.265.32.125
Proxy: DNS only
Then test:
https://automation.yourdomain.com
If you encounter redirect loops with n8n, they almost always come from incorrect Host/Proto headers or misaligned n8n base URL configuration. Using hostHeaderRewrite on frpc and header_up Host in Caddy removes that class of problem.
Step 5: Add Services from a Second Machine on the LAN
Now you want to expose a service running on Machine 2, for example http://192.168.1.63:11000, and map it to cloud.yourdomain.com. You do not reuse the frpc on Machine 1 for this; you run a separate frpc on Machine 2, pointing to the same frps.
On Machine 2:
mkdir -p ~/frp-client
cd ~/frp-client
Create frpc.toml:
cat > frpc.toml << EOF
serverAddr = "150.265.32.125"
serverPort = 7000
[[proxies]]
name = "cloud-dublin"
type = "http"
localIP = "192.168.1.63"
localPort = 11000
customDomains = ["cloud.yourdomain.com"]
EOF
Create docker-compose.yml:
cat > docker-compose.yml << EOF
services:
frpc:
image: snowdreamtech/frpc:latest
container_name: frpc
restart: unless-stopped
network_mode: host
volumes:
- ./frpc.toml:/etc/frp/frpc.toml
EOF
Start frpc:
docker compose up -d
docker logs frpc
On the VPS, expand Caddyfile again:
cd ~/frp-server
cat > Caddyfile << EOF
yourdomain.com {
reverse_proxy localhost:8080
}
automation.yourdomain.com {
reverse_proxy localhost:8080 {
header_up Host automation.yourdomain.com
}
}
cloud.yourdomain.com {
reverse_proxy localhost:8080
}
EOF
docker compose restart caddy
Create DNS for cloud.yourdomain.com pointing to the VPS IP. Now:
- Immich →
yourdomain.com→ VPS → frps → Machine 1 - n8n →
automation.yourdomain.com→ VPS → frps → Machine 1 - Second web app →
cloud.yourdomain.com→ VPS → frps → Machine 2
Quick Checklist Before You Blame FRP
Only one list, but a useful one:
- FRP server (
frps) running and listening on 7000/8080, no port conflicts. - FRP clients (
frpc) running on each machine, logs showstart proxy success. - Each service reachable locally on its
localIP:localPort. - DNS A records for each subdomain point to the VPS public IP.
- Caddy has matching blocks for each domain and is restarted after edits.
- For "picky" apps like n8n,
hostHeaderRewriteand proper Host headers are set.
Conclusion
Once this pipeline is in place, adding a new service is just: Add a `[[proxies]]` block in `frpc.toml` on the relevant machine, add a domain block in `Caddyfile` on the VPS, add an A record in DNS, and restart frpc and Caddy. No ISP calls, no port forwarding, no CGNAT pain—just clean domains for all your self-hosted tools.

