Pushes :edge for main and :x.y.z, :x.y and :latest for release tags, optionally mirrored to another registry. The release workflow no longer builds the image itself, so a tag does not publish it twice. The README covers running the image anywhere and the settings the workflows read. 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
The image is git.veillax.com/veillax/spinner for linux/amd64 and linux/arm64. It runs as a non-root user, keeps its config in the /data volume, and writes a starter spinner.json there on first start. The admin page on port 8080 is the quickest way to add servers; keep it private or behind HTTPS.
| Tag | Meaning |
|---|---|
latest |
The newest release |
1.2.3, 1.2 |
An exact release, or the newest patch of a minor version |
edge |
The latest commit on main |
sha-<commit> |
One specific commit |
Run it anywhere Docker runs:
docker run -d --name spinner --restart unless-stopped \
-p 25565:25565 -p 127.0.0.1:8080:8080 \
-e SPINNER_ADMIN_TOKEN=change-me \
-v spinner-data:/data \
git.veillax.com/veillax/spinner:latest
Or with Compose: cp .env.example .env, set SPINNER_ADMIN_TOKEN, then docker compose -f deploy/docker-compose.yml up -d. Backends on the Docker host are reachable at host.docker.internal (add --add-host host.docker.internal:host-gateway on Linux), or put Spinner and the Minecraft containers on one Docker network and use their container names.
If the registry is private, run docker login git.veillax.com first.
Releases
Push a tag to publish a release and its image:
git tag -a v0.1.0 -m "spinner v0.1.0" && git push origin v0.1.0
Two workflows run on the tag:
.gitea/workflows/release.ymlbuilds archives withdeploy/build.shand uploads them withdeploy/publish-release.sh..gitea/workflows/docker.ymltests, builds both Linux binaries and pushes the image. It also pushesedgeon every push tomain, and can be run by hand from the Actions tab.
Settings, all optional:
| Name | Kind | Use |
|---|---|---|
REGISTRY_TOKEN, REGISTRY_USER |
secret, variable | Token with package write permission, if the job token cannot push |
RELEASE_TOKEN |
secret | Token that can write releases, if the job token cannot |
MIRROR_IMAGE, MIRROR_USER, MIRROR_TOKEN |
variable, variable, secret | Also push to another registry, for example ghcr.io/you/spinner or docker.io/you/spinner |
PUBLISH_DOCKER |
variable | Set to false to skip the image |
The runner needs Docker with buildx. To build by hand: sh deploy/build.sh puts binaries and archives in dist/, then docker buildx build . packages the linux binary for your platform.
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.