Routes Java Edition connections by the hostname in the handshake, with wildcard domains, backend failover, PROXY protocol v2, cached server-list status from health probes, hot-reloaded config, and an admin page and metrics endpoint. Includes CI, tagged releases and a container image. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Spinner
A small Minecraft Java reverse proxy. One public port, many backend servers, chosen by the
hostname the player typed (survival.example.com and creative.example.com can share an IP and port).
Spinner only reads the handshake, then pipes bytes, so it never touches the encrypted session and works
with online-mode servers, mods and any client version.
go build -o spinner ./cmd/spinner
cp spinner.example.json spinner.json
cp .env.example .env # set SPINNER_ADMIN_TOKEN
./spinner -config spinner.json
What it does
| Situation | Server list | Login |
|---|---|---|
| Host matches a server, backend up | cached real MOTD, version and player count (refreshed every probe_interval_secs) |
proxied |
| Host matches, backend unreachable | that server's offline_motd |
disconnect with offline_motd |
| Host matches no server | unknown_motd |
disconnect with unknown_motd |
Server at max_connections, or IP at max_conns_per_ip |
Server is full / closed |
Server is full / closed |
- Routing: exact domains win;
*.example.commatches any subdomain depth but not the apex; longer wildcards win. Matching ignores case, a trailing dot and Forge's\0FML\0suffix. - Failover: a server may list several
addresses; they are tried in order. - PROXY protocol v2: set
send_proxy_protocolper server so the backend sees real player IPs (the backend must be configured to expect it). - Hot reload: edit the config file (or use the admin UI) and it applies within 2 seconds. Invalid edits are logged and ignored.
bindandadmin_bindneed a restart.
Config
JSON, see spinner.example.json. Top level: bind, admin_bind (empty disables the admin),
handshake_timeout_secs, connect_timeout_secs, probe_interval_secs, max_conns_per_ip, unknown_motd, servers.
Per server: name, domains, addresses, send_proxy_protocol, offline_motd, max_connections.
MOTDs accept § colour codes and \n.
Admin
With admin_bind and SPINNER_ADMIN_TOKEN set, http://<admin_bind>/ shows servers, health, connections and traffic,
and edits the config. Bind it to localhost or a private network and put TLS in front if it must be public.
| Endpoint | |
|---|---|
GET /api/overview |
totals plus per-server health and stats |
GET/PUT /api/config |
read or replace the config (validated, written atomically) |
GET /metrics |
Prometheus text format |
All except / need Authorization: Bearer <token>.
Docker
cp .env.example .env # set SPINNER_ADMIN_TOKEN
docker compose -f deploy/docker-compose.yml up -d
The image is git.veillax.com/veillax/spinner (linux/amd64 and linux/arm64). It keeps its config in the /data volume and writes a starter spinner.json there on first start, so the admin page on port 8080 is the quickest way to add servers. Keep that port private or behind HTTPS.
Releases
Push a tag to build archives, publish a release and push the image:
git tag -a v0.1.0 -m "spinner v0.1.0" && git push origin v0.1.0
.gitea/workflows/release.yml runs deploy/build.sh, uploads the archives with deploy/publish-release.sh, then builds and pushes the image. Set the repository variable PUBLISH_DOCKER to false to skip the image. If the job token cannot write releases or packages, add the secrets RELEASE_TOKEN and REGISTRY_TOKEN (and the variable REGISTRY_USER).
To build by hand: sh deploy/build.sh puts binaries and archives in dist/, then docker buildx build . packages the linux binary.
Not in v1
Bedrock Edition, legacy (pre-1.7) pings, up/down webhooks, self-update and WireGuard backends
(Reef has selfupdate and wg ready for these), and ending player sessions on config reload (existing
connections keep running until they close).
Development
go vet ./... && go test ./...
node scripts/lint-design.mjs
Reef is fetched from the server as git.veillax.com/Veillax/reef; set GOPRIVATE=git.veillax.com if it is private. To work against a local Reef checkout, use a go.work file (ignored by git).
The admin page follows the Lightwell design language, see docs/design.md.
Licence: AGPL-3.0.