One UI to expose home-hosted services — game servers on raw ports, web apps on 80/443 — without opening a single port on your router.
🌐 proxyctl.cc · 🚀 v0.6.23 · 🔗 Sibling: GameCTL — deploys & manages the game servers ProxyCTL publishes
ProxyCTL is a single Go binary with an embedded web UI. It runs two different routing planes, side by side, because the right tool depends on the protocol:
- 🎮 Game / L4 entries — raw TCP/UDP on arbitrary ports (Source-engine games, Minecraft, Valheim, anything non-HTTP) tunneled through your $4 droplet over WireGuard to an in-cluster gateway.
- 🌐 Web / L7 routes — one-click Cloudflare Tunnel setup via the Cloudflare API.
cloudflaredruns in your cluster and dials Cloudflare's edge outbound, so 80/443 traffic to any in-cluster Service rides Cloudflare's free TLS + WAF + DDoS protection — no droplet, no public IP, no certs on your side.
Neither plane needs a credential you hand over. ProxyCTL generates its own
ed25519 SSH key for the droplet (the private half never leaves the app, and no
API returns it), and reaches your cluster through its own in-cluster
ServiceAccount — read-only for discovery, namespaced for the gateways it
manages. Nothing runs in the background: SSH and kubectl fire only when you
click Apply. Full model: SECURITY.md.
Sibling project to GameCTL. GameCTL deploys and manages dedicated game servers on your cluster; ProxyCTL is how you host them on the public internet, securely — plus any web app you self-host. Same single-binary design, same bootstrap-token first-run setup, same one-command install. Use either alone; together they take a game from "deployed on my homelab" to "friends are connecting from anywhere" without a single opened router port.
One command, against your current kubectl context:
curl -fsSL https://proxyctl.cc/install.sh | bashZero config — it pulls the public image, detects your cluster's networking (ingress / MetalLB), picks how to expose the UI, applies the manifest, and hands you to first-run setup.
Optional overrides — set any of these before the command (or export
them) to customise; all are optional:
PROXYCTL_HOST=proxyctl.example.com \
PROXYCTL_EXPOSE=ingress \
bash -c "$(curl -fsSL https://proxyctl.cc/install.sh)"| Env | Default | Purpose |
|---|---|---|
PROXYCTL_HOST |
— | UI hostname for Ingress mode (must resolve to your ingress controller). |
PROXYCTL_EXPOSE |
auto |
How to expose the UI: auto detects what your cluster has; override to force ingress or a LoadBalancer. |
PROXYCTL_INGRESS_CLASS |
auto-detected | ingressClassName to use when exposing via Ingress. |
PROXYCTL_IMAGE |
ghcr.io/gamectl-hq/proxyctl:latest |
Override the image — e.g. a private/homelab registry. |
PROXYCTL_NAMESPACE |
proxyctl |
Namespace to install into. |
PROXYCTL_STORAGE_CLASS |
nfs-ssd |
StorageClass for ProxyCTL's own data PVC. |
PROXYCTL_DATA_NFS |
— | server:/path — headless escape hatch that deploys straight onto an NFS share, skipping the wizard's Storage step. |
PROXYCTL_MANIFEST |
fetched from the public repo | Path or URL to an alternate k8s/proxyctl.yaml. |
PROXYCTL_ASSUME_YES |
0 |
Set to 1 to skip interactive prompts (CI/automation). |
kubectl delete -f k8s/proxyctl.yamlRemoves the namespace and everything in it (Deployment, Service, Ingress, PVC,
ProxyCTL-created proxyctl-auth Secret). Redeploying returns you to the
bootstrap-token step with a fresh token.
Two things, both cheap, both one-time:
- ☁️ A DigitalOcean droplet — the cheapest $4/mo Basic tier is plenty for most home game servers; size up only if you actually saturate it. Sign up: digitalocean.com.
-
The exact plan to pick when creating the droplet:
Setting Value Image Ubuntu 24.04 (LTS) x64 Plan type Basic (Shared CPU) CPU option Regular (Disk: SSD) Size $4/mo — 1 vCPU · 512 MB RAM · 10 GB SSD · 500 GB transfer (slug s-1vcpu-512mb-10gb, ~$0.006/hr)Auth Add your own SSH key at creation (recommended — it's how you get a root shell). ProxyCTL doesn't use it: the Setup wizard generates ProxyCTL's own keypair and gives you a one-line command to install its public half -
That's the whole spec — the droplet runs WireGuard + iptables only, which is nearly idle even with a full server; 512 MB is plenty. ProxyCTL only logs in for Apply and the one-time Prepare step. (what that looks like in the DigitalOcean creator)
-
- 🌐 A Cloudflare-managed domain — for the public subdomain players/users connect to. Register or transfer at Cloudflare Registrar, or use any registrar and point its NS at Cloudflare.
- Domains run ~$8-10/year.
- One A record per game points
<game>.example.com→ droplet IP — and with the Cloudflare API hooked up (next section) ProxyCTL creates and manages those records for you; you never open the Cloudflare dashboard per server.
That's it. You do not configure anything on the droplet by hand. The only
manual step is pasting one command that installs ProxyCTL's generated public
key — from there its in-app Setup wizard SSHes in and installs wireguard /
iptables / conntrack, persists kernel sysctls, generates the WireGuard
keypair, and brings up wg-quick@wg0. Idempotent — safe to re-run any time.
You probably also want a home Kubernetes cluster where your game pods actually live — k3s is what ProxyCTL (and its sibling GameCTL) are developed and run on: a single-binary, lightweight Kubernetes that installs on any Linux box with one command.
Hook a scoped Cloudflare API token into ProxyCTL and DNS stops being a separate chore: you control and associate domains to servers entirely inside the app. Concretely, with the token configured ProxyCTL will:
- Game / L4 entries — auto-create/update the grey-cloud A record
(
<game>.example.com→ droplet IP, DNS-only,proxied: false) when you Apply an entry, and optionally delete it when you delete the entry. - Web / L7 routes — create the Cloudflare Tunnel itself via the API,
fetch its run token for the in-cluster
cloudflared, push ingress rules, and upsert the proxied (orange-cloud) CNAME (app.example.com→<tunnel-id>.cfargotunnel.com). If a conflicting A record exists on that hostname, ProxyCTL removes it first (a name can't hold both). - Everywhere — list your zones so domain dropdowns are pre-filled, and verify the token with a Save & test button before anything is applied.
-
Log in at dash.cloudflare.com → click your avatar (top right) → My Profile → API Tokens → Create Token → scroll down and choose Custom token → Get started. (Don't use a pre-made template — none matches this exact scope set — and never use the Global API Key: it can do anything to your whole account and can't be restricted.)
-
Name it something recognizable, e.g.
proxyctl. -
Add exactly these four permissions (row by row — the three dropdowns per row are scope · resource · access level):
Scope Permission Access Why ProxyCTL needs it Account Cloudflare Tunnel Edit Create the tunnel, read its status, fetch the cloudflaredrun token, and push per-hostname ingress configurations (/accounts/…/cfd_tunnel*).Account Account Settings Read List your accounts once to resolve the account ID the tunnel lives under ( GET /accounts). No settings are ever changed.Zone Zone Read List your zones and resolve example.com→ zone ID, and pre-fill the domain dropdowns (GET /zones).Zone DNS Edit Create/update/delete the A records (games) and proxied CNAMEs (web routes) ( /zones/…/dns_records). -
Account Resources —
Include→ your account (pick it by name; if you only have one account, select it explicitly anyway). -
Zone Resources —
Include→ All zones is simplest. If you prefer least-privilege, choose Specific zone and add each domain you'll route through ProxyCTL — but note only those zones will appear in the app, and Applies against any other domain will fail with a permissions error. -
(Optional hardening) Client IP Address Filtering — restrict the token to your home IP (where ProxyCTL runs). TTL — give the token an expiry and rotate it; ProxyCTL re-verifies on save, so pasting a fresh token later is a 10-second job.
-
Continue to summary → Create Token, and copy the token — Cloudflare shows it once.
Two ways, both fine:
- In the UI (recommended) — first-run Setup wizard, Cloudflare step:
paste the token and hit Save & test. ProxyCTL calls
GET /user/tokens/verifyand shows the token's status (active / expired / disabled) before accepting it. - Env var — set
CF_API_TOKEN(orCLOUDFLARE_API_TOKEN) on the ProxyCTL Deployment; it's read once at startup.
Either way the token is held in memory only — never written to disk, never logged, never sent to the browser. That also means a pasted token doesn't survive a pod restart: re-paste it, or use the env var if you want it permanent.
- Save & test fails → the token itself is invalid/expired (step 7 copy mistake, or TTL lapsed).
- Web-route Apply fails creating the tunnel → missing
Account · Cloudflare Tunnel · Edit, or Account Resources didn't include the right account. - "zone not found" on Apply → missing
Zone · Zone · Read, or the domain isn't in the token's Zone Resources include list. - DNS record create/update fails → missing
Zone · DNS · Editfor that zone.
No token at all is also fine — game entries still work; you just create the A records in the Cloudflare dashboard by hand, and the one-click web-tunnel plane is unavailable.
- Two transport planes, one UI — raw game ports through your droplet's WireGuard tunnel, HTTP apps through a Cloudflare Tunnel with edge protection. Pick the right one per service.
- One-click Cloudflare Tunnel — provisions the tunnel via the CF API, deploys
cloudflaredin your cluster, pushes ingress rules, upserts proxied CNAMEs. No certs, no router config, no public IP. - Domains managed in-app — hook up a scoped Cloudflare API token and ProxyCTL creates/updates the DNS records itself: grey-cloud A records for game servers, proxied CNAMEs for web apps. Associate a domain with a server without ever opening the Cloudflare dashboard.
- No home ports opened — both planes dial outbound from your cluster; your home IP stays private.
- No credentials handed over — ProxyCTL uses its own generated SSH key (private half never leaves the app, never returned by any API) and its own namespaced ServiceAccount. It never holds your
ssh-agent, your kubeconfig, or cluster-admin. - Hardened SSH, guided — one-click control tunnel, tunnel-only SSH lockdown, fail2ban with a "let me back in" button, and detection of leftover manual UFW rules that would strand you on an IP change.
- Your own WireGuard access — register personal devices as tunnel peers (QR code for phones, one-paste block for headless Linux) so lockdown never locks you out.
- Monitoring built in — per-entry heartbeat bars and latency trends, history that survives restarts, and Discord alerts when something goes unreachable.
- Live target picker — browse namespaces → Services in your cluster (type, ClusterIP, ports, pod readiness) and click one; the proxy is associated directly with the in-cluster Service, not a port-forward. (see it)
- Made for GameCTL servers — the picker sees the Services GameCTL creates, so publishing a game server is: pick the Service, pick the ports, Apply. Live per-tunnel traffic counters show players connecting.
- One binary — Go + embedded HTML/CSS/JS, single
//go:embeddeploy.

The Tunnels dashboard — live entries with per-tunnel traffic counters, plus the guided add-entry form (DNS, cluster target picker, ports)

The live target picker — browse your cluster's actual Services (type, ClusterIP, ports, pod readiness) and click one. The tunnel binds directly to the in-cluster Service — no NodePort, no port-forward hops, no copy-pasting IPs.
![]() First-run setup wizard — ProxyCTL preps the droplet itself: WireGuard, iptables, sysctls, keypair. Idempotent. |
![]() The whole cloud bill — DigitalOcean's $4/mo Basic droplet is all the tunnel needs. |
For raw UDP/TCP on arbitrary ports. Cloudflare's free tunnel won't carry these, so this is the path for Source-engine games, Minecraft, Valheim, anything non-HTTP.
flowchart LR
P["🎮 Player"]
subgraph CLOUD["☁️ Public Droplet (DigitalOcean)"]
D["DNAT public ports → tunnel<br/>WG 10.8.0.1"]
end
subgraph HOME["🏠 Home k3s cluster (no inbound ports)"]
G["wg-gateway pod<br/>10.8.0.2 · DNAT → target svc<br/>(FORWARD locked)"]
S["Game Service<br/>ClusterIP → game pod"]
end
P -- "game ports (TCP/UDP)" --> D
D == "WireGuard UDP 51820 (encrypted)" ==> G
G -- "in-cluster only" --> S
G -. "dials OUT (keepalive)" .-> D
For 80/443 apps — dashboards, Jellyfin, anything HTTP. No droplet involved, no public IP, no certs to manage on your side.
flowchart LR
U["👤 Browser (HTTPS)"]
subgraph CF["☁️ Cloudflare edge"]
E["TLS terminate · WAF · DDoS"]
end
subgraph HOME["🏠 Home k3s cluster (no inbound ports)"]
C["cloudflared pod<br/>dials OUT to Cloudflare"]
WS["Web Service<br/>(in-cluster ClusterIP)"]
end
U == "HTTPS 443" ==> E
E == "Cloudflare Tunnel (outbound)" ==> C
C -- "in-cluster only" --> WS
- No credential of yours is stored. ProxyCTL generates its own ed25519
droplet key at setup; the private half lives 0600 on its PVC and is never
returned by an API, logged, or sent to the browser. It never holds your
ssh-agent, your kubeconfig, or cluster-admin. - Never sees a WireGuard private key. Each gateway pod self-generates its own keypair on a small PVC — ProxyCTL reads back only the public half. The droplet's own private key is re-spliced on the droplet and never crosses the wire.
- The Cloudflare token is memory-only unless you set
CF_API_TOKEN. Never written to disk by default, never logged, never sent to the browser. - The auth Secret (
proxyctl-auth) is written by ProxyCTL itself at first-run setup via its own ServiceAccount — same bootstrap-token flow as GameCTL. - Cluster picker is read-only (
kubectl getverbs only), only fires on click, never in the background. - Nothing runs unattended. There is no reconcile loop — SSH and
kubectlexecute only when you click Apply, and every command's exit code and verbatim stdout/stderr is shown back to you.
Full model — RBAC footprint, the two SSH-lockdown options, fail2ban, and the
leftover-UFW-rule recovery: SECURITY.md.
- Game / L4 routing is port + protocol only (Source-engine clients don't send hostnames). Subdomain/service fields on each entry are cosmetic reminders.
- This repo is a clean evolution of the internal
gameproxyapp — the in-process userspace forwarder is gone, replaced by the tunnel. ProxyCTL doesn't manage UFW day-to-day, but the Setup wizard can detect and safely remove a leftover pre-ProxyCTL UFW rule that restricts SSH to a specific IP (seeSECURITY.md). - The UI is React 19 + Vite, built to
server/web/distand compiled into the binary with//go:embed— so the deploy shape stays a single artifact, exactly like GameCTL. Styling is hand-written CSS rather than Tailwind, tracking GameCTL's look so a future merge stays natural. - Apply is scoped and incremental. Proxy Entries and Web Apps apply independently, and an apply reconciles only the gateways that are new, edited, or missing a key — the ones it skipped are named. Full reconcile forces everything through, for state that drifted outside ProxyCTL.
- Entries and web routes are editable in place; changing a label no longer tears down a tunnel.

