# frps deployment — hosting a public-facing tunnel server behind nginx + Let's Encrypt

Captured from a real session on 2026-07-05. Useful next time anyone asks "deploy frps", "set up frp server", "expose a reverse-tunnel server".

## When to load

- User asks to deploy frps (or any tunnel/reverse-proxy server with a web dashboard) on a host that already has nginx + acme.sh wired up.
- The frps pattern generalizes: anything that exposes both a public TCP port (clients connect) and a localhost web dashboard (admin UI), where the dashboard needs HTTPS but the public port does not.

## What this captures (not a substitute for frp docs)

### Install

1. **Pick the latest release.** `curl -sSL https://api.github.com/repos/fatedier/frp/releases/latest | grep tag_name`. Avoid hand-typing a version — minor releases rename config fields (see pitfalls).
2. **Download, extract, place the binary at `/usr/local/bin/frps`.** Don't install via apt/snap — those packages lag releases.
3. **Config dir at `/etc/frp/`** (chmod 700). Config file at `/etc/frp/frps.toml` (chmod 600).
4. **Credentials in a sibling file `/etc/frp/CREDENTIALS.txt`** (chmod 600) — token, dashboard user/password, **never inline in the toml**. Write the toml with placeholder strings, then re-emit with `cat <<EOF > toml` and the actual secrets, so they never touch your editor buffer.

### Config quirks in v0.69+ (and any later minor)

- `transport.heartbeatInterval` was **removed** in v0.69. Only `transport.tcpKeepalive` and `transport.heartbeatTimeout` remain. Validate with `frps verify -c /etc/frp/frps.toml`.
- `webServer.addr` must be a single IP, not `0.0.0.0`. To keep the dashboard only reachable via reverse proxy, bind to `127.0.0.1`.
- `auth.method = "token"` + `auth.token` works for most use cases. `oidc` is overkill for a single-operator setup.
- `transport.tls.force = true` — encrypts frpc↔frps. Trades a few percent throughput for confidentiality of all proxied traffic.

### Systemd hardening template

```ini
[Unit]
Description=frp server
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=root
ExecStart=/usr/local/bin/frps -c /etc/frp/frps.toml
Restart=on-failure
RestartSec=5
LimitNOFILE=1048576

NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/log/frp /etc/frp

[Install]
WantedBy=multi-user.target
```

Enable + start: `systemctl daemon-reload && systemctl enable --now frps`.

### Fronting the dashboard with nginx + acme.sh

The public port (`7000` for frps, varies by service) is exposed directly via `bindAddr = "0.0.0.0"` and does **not** need nginx. Only the **dashboard** needs HTTPS:

- Use the existing `nginx-reverse-proxy-ssl` pattern (per-domain vhost in `/etc/nginx/conf.d/`).
- The 443 block proxies `127.0.0.1:7500` (or whatever the dashboard port is).
- `client_max_body_size 2m` is plenty for a dashboard (no large POSTs).
- Issue cert with the same `acme.sh --issue --force -d <host> -w /var/www/acme-challenge` flow as in the umbrella skill.

### Authentication choices

- **Auth token**: required for clients. Same value used in the `frpc.toml` client config. Treat like a server root password.
- **Dashboard user/password**: HTTP Basic Auth served by frps itself. Don't add a second layer at nginx unless you specifically want defense in depth — Basic Auth over HTTPS is fine for single-operator.

### Resource envelope

frps is very light. Observed: **9.6 MiB RSS, no swap, ~5% of a single CPU core** at idle. Easy to co-locate with anything else.

### Pitfalls (verified 2026-07-05)

- **`transport.heartbeatInterval` removed in v0.69.** frps refuses to start with `json: unknown field`. Drop the field — heartbeat is now derived from heartbeatTimeout.
- **Dashboard IP binding.** If you bind to `0.0.0.0`, the dashboard is reachable without HTTPS — exactly what you don't want. Always `webServer.addr = "127.0.0.1"` and reverse-proxy from nginx.
- **Public port (7000) doesn't get the same protection as 443.** Firewall and nginx only see `:80`/`:443`. `:7000` reaches `bindAddr` directly. Document this for whoever's adding firewall rules.
- **LE staging via `acme.sh --issue --test`** issues a real staging cert, doesn't dry-run — sign once for real (see `nginx-reverse-proxy-ssl/SKILL.md` pitfalls).
- **Config.toml placeholder double-write.** When generating the toml from a template, write the file once with `REPLACE_AT_RUNTIME` placeholders, then re-emit with `cat <<EOF > toml` and the actual secrets. Don't try to `sed -i 's/placeholder/<secret>/'` — the placeholder can collide with hex chars in the secret.

## Minimal frps.toml skeleton (verified working 2026-07-05)

```toml
bindAddr           = "0.0.0.0"
bindPort           = 7000

transport.tls.force         = true
transport.tcpKeepalive      = 75
transport.heartbeatTimeout  = 90

auth.method  = "token"
auth.token   = "<32-byte-random-hex>"

webServer.addr        = "127.0.0.1"
webServer.port        = 7500
webServer.user        = "admin"
webServer.password    = "<32-byte-random-base64>"
webServer.pprofEnable = false

log.to      = "/var/log/frp/frps.log"
log.level   = "info"
log.maxDays = 7
```

## Verified end-state on host VM-0-9-ubuntu (2026-07-05)

- Binary: `/usr/local/bin/frps` (v0.69.1)
- Config: `/etc/frp/frps.toml` (chmod 600), `/etc/frp/CREDENTIALS.txt` (chmod 600)
- Service: `/etc/systemd/system/frps.service` enabled, running
- Ports: `0.0.0.0:7000` (frpc) + `127.0.0.1:7500` (dashboard)
- Frontend: `https://frps.spannerjun.top` via nginx reverse proxy, LE cert auto-renewing
- Memory: 9.6 MiB RSS

## Rehydration

If asked "is frps still set up here?":

```bash
systemctl is-active frps                          # active
ss -tln | grep -E ':7000|:7500'                  # both listening
ls /etc/frp/CREDENTIALS.txt                      # creds file present (or rotated)
curl -ksS -u admin:$(awk '/Dashboard password/ {print $3}' /etc/frp/CREDENTIALS.txt) \
  https://frps.spannerjun.top/                    # 301 to /static/ = dashboard reachable
crontab -l | grep acme.sh                        # renewal cron present
```
