From an empty machine to a player on a server¶
By the end of this page a kind cluster runs the Spawnery operator, a lobby
group of Paper servers, and one Velocity proxy in front of them, and your own
Minecraft client is standing on one of those servers. Every command below, and
the output shown beside most of them, comes from a real run of exactly this
path.
You will need kind, kubectl, helm, a Minecraft client, and 7-8Gi of
memory free. Part of that is exact, from the manifest in step 3: a Paper
server pod requests 2Gi, the proxy requests 1Gi, and (as step 4 explains)
you end up with two Paper servers instead of one, so the pods alone request
5Gi. The rest is an allowance, not a measurement: roughly 1Gi for kind's own
control plane, and 1-2Gi for the Minecraft client itself, which runs alongside
the cluster instead of inside it and is easy to forget when counting. Budget
for the client: leaving it out makes 4Gi look like enough right up to the
step where it is not.
1. Create the cluster¶
docs/tutorial/kind-config.yaml, in full:
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
extraPortMappings:
# Routes the host to the proxy's NodePort (network.yaml's
# spec.expose.nodePort.port) so a client outside the cluster can reach
# it. Without this mapping the NodePort only answers inside the kind
# node's own network namespace.
- containerPort: 30001
hostPort: 30001
protocol: TCP
Read the extraPortMappings block and its comment before you copy this: it
is how a client running outside the cluster
reaches a proxy running inside it. Save it as kind-config.yaml and create
the cluster:
kind create cluster --name spawnery-tutorial --config kind-config.yaml
2. Install the operator¶
helm install spawnery oci://ghcr.io/spawnery/charts/spawnery \
--version 0.34.0 \
--namespace spawnery-system --create-namespace
NAME: spawnery
LAST DEPLOYED: Tue Sep 15 14:23:40 2026
NAMESPACE: spawnery-system
STATUS: deployed
REVISION: 1
DESCRIPTION: Install complete
TEST SUITE: None
NOTES:
spawnery 0.16.0 installed as release spawnery in namespace spawnery-system.
kubectl -n spawnery-system rollout status deployment/spawnery-operator
ONE STEP REMAINS, AND SKIPPING IT FAILS SILENTLY.
Every namespace that will hold a Network needs the forwarding-secret reader
grant. This chart cannot template it: the game namespaces do not exist yet
when the chart is installed, which is why the file stays outside the chart.
Apply it once per game namespace, after that namespace exists:
kubectl apply -n <game-namespace> -f config/rbac/forwarding-secret-reader.yaml
Nothing refuses a grant that binds a ServiceAccount which does not exist: the
apply succeeds, the Network is still Accepted, and its ServerGroups and
ProxyGroups keep scheduling. What is lost is forwarding-secret rotation
detection in that namespace, reported only on the Network itself:
kubectl -n <game-namespace> get network <name> \
-o jsonpath='{.status.conditions[?(@.type=="ForwardingSecretResolved")]}'
Uninstalling leaves the four CRDs standing, and with them every Network,
ServerGroup, ProxyGroup and Server in the cluster. charts/spawnery/README.md
has both procedures in full.
That "one step" is about detecting a rotated secret later, which a namespace this tutorial deletes at the end will never need. Skip it here, and see Getting started for the real procedure on a network you keep. Wait for the rollout instead:
kubectl -n spawnery-system rollout status deployment/spawnery-operator
Waiting for deployment "spawnery-operator" rollout to finish: 0 of 1 updated replicas are available...
deployment "spawnery-operator" successfully rolled out
3. Apply the network¶
docs/tutorial/network.yaml, in full:
apiVersion: v1
kind: Namespace
metadata:
name: spawnery-tutorial
---
apiVersion: v1
kind: Secret
metadata:
name: velocity-forwarding-secret
namespace: spawnery-tutorial
stringData:
# This namespace is deleted at the end of the tutorial, so a fixed value is
# fine here. For a real network, generate one instead:
# head -c 32 /dev/urandom | base64
secret: tutorial-forwarding-secret
---
apiVersion: spawnery.cloud/v1alpha1
kind: Network
metadata:
name: tutorial
namespace: spawnery-tutorial
spec:
forwardingSecretRef:
name: velocity-forwarding-secret
defaults:
minecraftVersion: "26.3"
resources:
requests:
cpu: 500m
memory: 2Gi
limits:
memory: 2Gi
---
apiVersion: spawnery.cloud/v1alpha1
kind: ServerGroup
metadata:
name: lobby
namespace: spawnery-tutorial
spec:
networkRef:
name: tutorial
type: Ephemeral
image: ghcr.io/spawnery/purpur:26.3-0.34.0
maxPlayers: 20
scaling:
minReplicas: 1
maxReplicas: 3
spareSlots: 5
---
apiVersion: spawnery.cloud/v1alpha1
kind: ProxyGroup
metadata:
name: gateway
namespace: spawnery-tutorial
spec:
networkRef:
name: tutorial
replicas: 1
image: ghcr.io/spawnery/velocity:4.2.0-0.34.0
# Velocity does not need a backend's heap; overriding the Network's
# defaults keeps the proxy off the 2Gi a Paper server needs.
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
memory: 1Gi
expose:
type: NodePort
nodePort:
# Fixed rather than left to allocate: kind-config.yaml's
# extraPortMappings maps one host port to this exact number, and a
# mapping cannot point at a port nobody can predict.
port: 30001
routing:
fallbackGroups:
- lobby
config:
# The custom resource field, not a configOverlay: internal/render
# reasserts the keys it owns after merging an overlay, so this is the
# only place online-mode can be moved from. It lets a reader's own
# licensed client join without a real Mojang session.
onlineMode: false
playerLimit: 50
motd: "Spawnery tutorial"
One Network, one ephemeral ServerGroup of Paper backends, one ProxyGroup
in front of it. The manifest creates its own namespace, so save it as
network.yaml and apply it directly:
kubectl apply -f network.yaml
4. Watch it come up¶
kubectl get servergroups -n spawnery-tutorial
NAME TYPE PHASE READY REPLICAS PLAYERS FREE SLOTS BOOSTED AGE
lobby Ephemeral Ready 2 2 0 40 0 76s
Two servers, not the one minReplicas: 1 asked for, and no join caused it
(nobody has joined yet). A server this new hasn't shown its pod to the
operator's cache for one pass, so the group briefly builds a spare instead of
risking coming up short, and sheds it again a few minutes later, well past where
this tutorial ends. See ServerGroup for
the field (scaleDownStabilizationSeconds) that governs the timing.
kubectl get servers -n spawnery-tutorial
kubectl get pods -n spawnery-tutorial
NAME GROUP PHASE PLAYERS SLOTS REGISTERED AGE
lobby-d2hf lobby Ready 0 20 true 76s
lobby-nd79 lobby Ready 0 20 true 54s
NAME READY STATUS RESTARTS AGE
gateway-jgkq 1/1 Running 0 77s
lobby-d2hf 1/1 Running 0 77s
lobby-nd79 1/1 Running 0 55s
Three pods, matching the three pod requests the memory figure above was built from: two servers at 2Gi and one proxy at 1Gi.
5. Join with your own client¶
kubectl get proxygroups -n spawnery-tutorial
kubectl get svc -n spawnery-tutorial
NAME PHASE READY ADDRESS PLAYERS AGE
gateway Ready 1 10.89.0.41:30001 0 76s
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
gateway NodePort 10.96.56.131 <none> 25565:30001/TCP 77s
ADDRESS is the proxy's address inside the cluster's own network, which your
desktop cannot reach. The 30001 in both lines is the same NodePort
kind-config.yaml mapped to a host port in step 1, so point your client at
localhost:30001 instead.
Open your Minecraft client, add localhost:30001 as a server, and connect.
The proxy runs with spec.config.onlineMode: false because this is a local
cluster on your own machine with no Mojang session to check. The same field
lets this project drive this exact path in its own CI. A licensed
client connects to it exactly as it would to any other server.
If you want confirmation before you open a client: this project's own automated check joins the same address the same way, with a test-only tool that logs in and prints what it saw instead of rendering a world:
{"protocol":777,"username":"spawnery_probe","uuid":"bcc1dc19-a5eb-33a1-aa1b-4e3907d5e22f","compressed":true}
6. Watch the group notice you¶
kubectl get proxygroups -n spawnery-tutorial
NAME PHASE READY ADDRESS PLAYERS AGE
gateway Ready 1 10.89.0.44:30001 1 104s
PLAYERS moves the moment your client is registered by the proxy. The lobby
ServerGroup's own onlinePlayers counts you a step later: a backend counts
a player from the moment they are standing in the world, not from login. That
second counter is not shown here, because the tool from step 5 stops at Login
Acknowledged and so is counted by the proxy and never by the backend, while
your own client walks into the world and is counted by both.
7. Tear it down¶
kind delete cluster --name spawnery-tutorial
One command: the cluster is the whole footprint, so nothing else is left to uninstall or clean up once it is gone.