Files
VeillaxandClaude Sonnet 5.5 8f01c6213f
CI / test (push) Successful in 1m56s
Docker / image (push) Successful in 1m56s
Add a Docker workflow that publishes multi-arch images
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>
2026-10-26 23:13:54 -06:00

5.4 KiB

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.com matches any subdomain depth but not the apex; longer wildcards win. Matching ignores case, a trailing dot and Forge's \0FML\0 suffix.
  • Failover: a server may list several addresses; they are tried in order.
  • PROXY protocol v2: set send_proxy_protocol per 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. bind and admin_bind need 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.yml builds archives with deploy/build.sh and uploads them with deploy/publish-release.sh.
  • .gitea/workflows/docker.yml tests, builds both Linux binaries and pushes the image. It also pushes edge on every push to main, 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.