Expose a local port to the internet, across several machines, with automatic failover when one of them is off. Your GPUs become capacity your deployed app can use — whenever they happen to be on.
Your GPU boxes can't be found — they sit behind NAT and get switched off. So they don't wait to be reached. Each one dials out to a broker and holds the connection open. "Find another computer" then stops being a search: the broker already knows which machines are alive, because the dead ones stopped checking in.
No port forwarding, no static IP, no router configuration. The GPU machine never listens on a public port.
| Part | Runs where | Job |
|---|---|---|
| agent | each GPU machine | dials out, health-checks your local app, proxies requests to it |
| broker | one always-on host | keeps the live registry, resolves app keys, relays streams |
| your app | anywhere | points at the broker and forgets the rest |
A single self-contained executable — no Node install needed on the GPU machine.
npx gpupool --help
Anything that speaks HTTP. gpupool neither knows nor cares what it is — Ollama, vLLM, ComfyUI, a FastAPI service, your own code.
ollama serve # listens on 127.0.0.1:11434
The token comes from whoever runs the broker. Credentials are written to
~/.gpupool/credentials.json and reused from then on.
gpupool login --broker https://your-broker.fly.dev \
--token at_REPLACE_WITH_YOUR_OWN \
--label office-3090
Create gpupool.yaml next to wherever you'll run the agent. The
health path is polled, so a crashed app drops out of the pool while
the machine stays in it.
environments:
chatapp:
port: 11434
health: /api/tags
maxConcurrency: 4
Omit health and the agent falls back to a TCP connect check, which
catches the app having exited but not the app being broken.
This is the step that matters. Without it the agent dies when you close the terminal and never comes back after a reboot — so the machine only serves when someone remembered to start it.
gpupool service install # starts at logon, no admin needed gpupool service install --system # starts at boot, needs admin/root gpupool service install --dry-run # print the definition, change nothing
| Platform | Mechanism | Behaviour |
|---|---|---|
| Windows | Task Scheduler | restarts every minute on failure; keeps running on battery |
| Linux | systemd | Restart=always, RestartSec=5 |
| macOS | launchd | KeepAlive, RunAtLoad |
On Linux, a user service still stops at logout unless you enable lingering:
sudo loginctl enable-linger $USER
$ gpupool status --admin-key <key> office-3090 (0/4 busy) chatapp :11434 ready home-4070 (2/4 busy) chatapp :11434 ready imagegen :9999 DOWN - connect ECONNREFUSED 127.0.0.1:9999
Health is per environment, not per machine — a crashed app is pulled while everything else on that box keeps serving.
The broker is a transparent HTTP proxy, so whatever your local app speaks, your app speaks. If it's Ollama, every OpenAI-compatible client works with a one-line change and no SDK at all.
import OpenAI from 'openai';
const openai = new OpenAI({
baseURL: 'https://your-broker.fly.dev/v1',
apiKey: process.env.GPUPOOL_KEY, // pk_chatapp_...
});
const stream = await openai.chat.completions.create({
model: 'gpt-oss-64k:latest',
messages: [{ role: 'user', content: 'hello' }],
stream: true,
});
Nothing in your app knows which machine answered, or that a machine went away between two requests.
Each key resolves to exactly one environment, and that binding is the only thing
deciding where a request lands. The chatapp key cannot reach
imagegen's port down any path. Repointing or revoking an app means
editing the broker's key map — the app itself never changes.
WebSocket upgrades tunnel too, both directions, with binary frames and server-initiated pushes intact — ComfyUI and notebook kernels report progress that way. Browsers can't set headers on a WebSocket, so the key may travel as a query parameter, which is stripped before the path reaches your app:
new WebSocket('wss://your-broker.fly.dev/ws?key=pk_wsapp_...');
Send x-gpupool-session and the broker prefers the machine that served
that session last, so a warm cache or loaded model isn't thrown away. It's a
preference, not a pin: if that machine is gone the request goes elsewhere, because
falling back beats failing.
You get an immediate 503 rather than a hang:
{ "error": { "message": "No machine available for environment \"chatapp\" right now. Try again later.",
"type": "no_capacity" } }
Two variants, because they need different fixes. "No machine available … try again later" means a machine declares this environment but none is ready — a temporary condition. "No machine is serving …" means nothing in the pool declares it at all — a config mistake.
Point this at a running broker. It sends a streaming chat completion and renders tokens as they arrive, so you can see the whole path working: browser → broker → agent → your local model.
/v1. Must be https:// — see the note below.http:// broker as mixed content. For the demo your broker needs TLS —
which you get for free on Fly, Railway, or behind Cloudflare. A
http://localhost broker is exempt from that rule in Chrome and Firefox,
so local testing works.
Access-Control-Allow-Origin: * by default. If you've set an allowlist,
add https://wshuv-o.github.io to GPUPOOL_CORS_ORIGIN.
It's the only piece that needs hosting: a small stateful service that relays bytes
and never computes, so the smallest instance your platform sells is enough. A
Dockerfile and fly.toml are in the repo.
Tokens and app keys are opaque strings you generate yourself — there's no registration step. Make them unguessable:
node -e "console.log('at_'+require('crypto').randomBytes(16).toString('hex'))" # agent token
node -e "console.log('pk_'+require('crypto').randomBytes(16).toString('hex'))" # app key
fly launch --no-deploy
fly secrets set \
GPUPOOL_AGENT_TOKENS=at_REPLACE_WITH_YOUR_OWN,at_REPLACE_FOR_SECOND_MACHINE \
GPUPOOL_APP_KEYS='{"pk_chatapp_REPLACE_ME":"chatapp"}' \
GPUPOOL_ADMIN_KEY=pick_something_long
fly deploy
fly.toml sets auto_stop_machines = false
for that reason. Vercel, Lambda and Cloudflare Workers are not suitable hosts;
Durable Objects would be.
| Env var | Meaning |
|---|---|
PORT | listen port (default 8787) |
GPUPOOL_AGENT_TOKENS | comma-separated; one per machine |
GPUPOOL_APP_KEYS | JSON object of app key → environment |
GPUPOOL_ADMIN_KEY | guards /_status |
GPUPOOL_CORS_ORIGIN | *, or a comma-separated allowlist |
GPUPOOL_HEARTBEAT_MS | ping interval (default 10s) |
GPUPOOL_STALE_MS | evict a silent agent after this (default 30s) |
GPUPOOL_REQUEST_TIMEOUT_MS | per-request ceiling (default 10 min; 0 disables) |
GPUPOOL_SESSION_HEADER | session id header (default x-gpupool-session) |
GPUPOOL_SESSION_TTL_MS | how long affinity survives idleness (default 10 min) |
| Situation | Result |
|---|---|
| Machine powers off | socket closes, out of the pool immediately; next request goes elsewhere |
| Machine freezes | no ping for GPUPOOL_STALE_MS, evicted |
| Local app crashes, machine fine | health check fails, that environment only is pulled |
| Machine dies mid-stream | client connection is cut — a partial response can't be retried |
| Client hangs up | cancel reaches the local app, so an abandoned chat stops using the GPU |
| Nothing available | immediate 503 |
| Machine wakes up | reconnects with backoff capped at 30s, rejoins on its own |
Worth knowing before you build on it.