Operations
This page covers running p2proxy as a long-lived service.
systemd
A minimal unit file:
# /etc/systemd/system/p2proxy.service
[Unit]
Description=Bitping p2proxy
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
Environment=BITPING_API_KEY=<your-key>
ExecStart=/usr/local/bin/p2proxy --no-ui --config /etc/p2proxy/Config.yaml
Restart=on-failure
RestartSec=5
DynamicUser=yes
StateDirectory=p2proxy
[Install]
WantedBy=multi-user.target
Enable and start:
sudo systemctl daemon-reload
sudo systemctl enable --now p2proxy
journalctl -u p2proxy -f
DynamicUser=yes runs the daemon as a transient unprivileged user. StateDirectory=p2proxy gives it /var/lib/p2proxy for the sticky-peer file.
WireGuard mode needs no extra privileges — the tunnel is terminated in userspace, so there’s no NET_ADMIN, no TUN device and no kernel module to load. The default 51820 is above 1024, so the unit above runs it unchanged.
If you need to bind a listener below port 1024, either pick a high port (recommended) or grant the binary the capability:
sudo setcap 'cap_net_bind_service=+ep' "$(which p2proxy)"
Docker
The official image is ghcr.io/bitpingapp/p2proxy:latest. NO_UI=true is set by default so the daemon doesn’t try to draw a TUI without a TTY.
docker run -d --name p2proxy \
-p 1080:1080 \
-p 9091:9091 \
-e BITPING_API_KEY=<your-key> \
-v "$PWD/Config.yaml:/app/Config.yaml:ro" \
ghcr.io/bitpingapp/p2proxy:latest
You need to expose:
- Every per-server port (
-p 1080:1080, etc.) so clients on the host or network can reach it. AWireGuardserver is UDP — publish it as-p 51820:51820/udp, and sethost: "0.0.0.0"on that server so Docker’s port forwarder can reach the listener. - The metrics port (
-p 9091:9091) if you want to scrape Prometheus metrics from outside the container. - If you pin a fixed libp2p port via
listen_addrs, publish that port too — not strictly required, but it improves direct-vs-relay connectivity.
docker-compose
A reference docker-compose.yml ships in the repo. Typical shape:
services:
p2proxy:
image: ghcr.io/bitpingapp/p2proxy:latest
restart: unless-stopped
environment:
BITPING_API_KEY: $BITPING_API_KEY
ports:
- "1080:1080"
- "9091:9091" # metrics
volumes:
- ./Config.yaml:/app/Config.yaml:ro
p2proxy picks its own libp2p listen ports by default, so there’s nothing fixed to publish. If you set
listen_addrsto a fixed port, publish that port too (it improves direct-vs-relay connectivity).
Prometheus metrics
p2proxy exposes Prometheus metrics on http://localhost:9091/metrics by default (set metrics_port to change it). Useful series:
| Series | Type | Notes |
|---|---|---|
p2proxy_peers_connected |
gauge | Connected libp2p peers. |
p2proxy_sessions_active |
gauge | In-flight SOCKS5 sessions. |
p2proxy_socks_connections_total, p2proxy_sessions_initialized_total |
counter | Accepted / fully-established sessions. |
p2proxy_upload_bytes_total, p2proxy_download_bytes_total |
counter | Bytes relayed each direction. |
p2proxy_session_errors_total{stage} |
counter | Session failures by stage (handshake / request / peer-connection / data-transfer). |
p2proxy_stream_opened_total, p2proxy_stream_acquire_failed_total |
counter | Peer-stream opens / failures. |
p2proxy_ping_rtt_seconds |
histogram | Peer round-trip latency. |
p2proxy_socks_rejected_no_peer_total |
counter | Sessions rejected — no exit peer available. |
WireGuard servers add:
| Series | Type | Notes |
|---|---|---|
p2proxy_wg_tcp_flows_active, p2proxy_wg_udp_flows_active |
gauge | Flows currently bridged out of the tunnel. |
p2proxy_wg_flows_accepted_total, p2proxy_wg_tcp_flows_total, p2proxy_wg_udp_flows_total |
counter | Flows accepted from the tunnel. |
p2proxy_wg_udp_peer_incapable_total, p2proxy_wg_udp_fallback_dns_total |
counter | Exit peers that only speak the TCP forwarder, and DNS queries retried over TCP as a result. |
p2proxy_wg_udp_dropped_total, p2proxy_wg_udp_dropped_full_total, p2proxy_wg_udp_at_cap_total |
counter | UDP discarded — unsupported by the exit, a full queue, or the session cap. |
p2proxy_wg_decap_errors_total |
counter | Datagrams that failed to decrypt. A steady rate means a key or preshared-key mismatch, or unsolicited internet traffic hitting the port. |
p2proxy_wg_spoofed_source_total |
counter | Packets whose source address wasn’t in the sending peer’s allowed_ips. |
p2proxy_wg_egress_unrouted_total |
counter | Return packets with no matching peer allowed_ips — usually a too-narrow CIDR. |
p2proxy_wg_rejected_no_peer_total, p2proxy_wg_udp_no_peer_total |
counter | Flows dropped because no exit peer was available. |
A minimal scrape config:
scrape_configs:
- job_name: p2proxy
static_configs:
- targets: ["localhost:9091"]
For dashboards, p2proxy_session_errors_total{stage} is the series you’ll watch most — a rising rate is the leading indicator of peer trouble.
Upgrading
- Homebrew:
brew upgrade --cask BitpingApp/tap/p2proxy - Debian/Ubuntu/RHEL/Alpine: download the new package from the releases page and reinstall.
- Docker: pull the new tag and recreate the container —
docker compose pull && docker compose up -d. - From source:
git pull && cargo build --release -p p2proxy.
Restart the daemon to pick up the new binary. Config.yaml is read at startup, so config changes also require a restart.
Peer identity and state on disk
p2proxy’s libp2p identity is derived deterministically from your API key, so there’s no keypair file to back up or lose — the same key always yields the same peer ID.
The one file it does write is sticky_peers.json (the remembered exit peer per listen port), relative to the working directory. Run separate instances from separate working directories, and under systemd give the unit a WorkingDirectory=/var/lib/p2proxy to go with StateDirectory=p2proxy. Deleting it just means the next start re-discovers an exit.
Next
- WireGuard / VPN — tunnel setup, gotchas and its own metrics.
- Troubleshooting — symptom-driven matrix.
- Support — bug reports, security, billing.