Run a Minecraft network on Workers
A game network has one door and many rooms. Players connect to one public address; behind it a proxy (Velocity or BungeeCord) sends each player to a lobby, a minigame or a survival server. On Zaroz that is two kinds of Worker in one project:
Code
- The proxy Worker runs one unit and owns the only public port.
- Each backend Worker runs as many units as you need. It has no public address: the proxy reaches every unit by its private name,
<key>.<slug>. - The proxy finds the backends through the Units API, so scaling a backend up or down needs no config edit.
A Worker is the product for any long-running program that is not a website. It can hold raw TCP and UDP public ports and it can run many units. HTTPS endpoints are for websites and are not offered on a Worker. The terms used below are explained in Units, Public ports and Private names.
Before you start
You need an organization with a project, a Minecraft proxy image and a backend image from a registry (the Workload deploy guide shows how to deploy from an image), and a personal API token. The calls below use the helper from the Units API overview: $ZAROZ_API, $AUTH, $PROJECT.
1. Create the two Workers in one project
Create both Workers in the same project, because private names only work inside a project. Give each a slug that starts with a letter, since the slug becomes the name (see the rules):
| Worker | slug | units | public |
|---|---|---|---|
| Velocity proxy | proxy | 1 | TCP 25565 (and UDP 19132 for Bedrock) |
| Lobbies | lobby | 4 | none |
Pick a plan whose memory fits the JVM: a Velocity proxy is light, a Paper server wants its heap plus headroom. Zaroz sets MEMORY_LIMIT_MIB in every unit's environment, which your launch command can use to size the heap.
Export the two service ids, shown in the console URL of each Worker:
Code
2. Open the one public port on the proxy
Open a raw TCP port. internal_port is the port Velocity listens on inside the container; Zaroz assigns the public address and number and returns them.
curl
Code
TypeScript
Code
Python
Code
Players join at public_ip:public_port. Each raw port costs EUR 1 per month, billed by the hour it is held (see the bill). The request is refused with not_ready until the proxy unit is running, so deploy first and open the port after.
API status
The endpoint routes are used by the console and are console-only in the API reference: they accept your session token or a personal API token, and refuse a service principal. Do this step yourself, or from the console, not from the proxy.
The backends get no endpoint at all. They stay private.
3. Scale the backend to N units
A fixed policy runs exactly N units. Their keys are u1 to uN, and their private names u1.lobby to uN.lobby:
curl
Code
TypeScript
Code
Python
Code
If you want names you choose, such as survival and skyblock, or a unit per minigame match, use code mode and create units by key: a unit created with key skyblock is reachable as skyblock.lobby. The rest of this guide works the same either way, because the proxy reads the names from the API.
4. Point Velocity at the backends
The simplest proxy config names the backends statically. In velocity.toml:
Code
Each backend must listen on 25565 inside its container and trust Velocity's forwarding secret (forwarding.secret on the proxy, [proxies.velocity] in Paper's global config). Because the backends have no public address, online-mode=false on them is safe: only your own project can connect.
The name lobby alone is also valid and reaches every ready unit, but a Minecraft proxy needs one address per server so that it can send a player to a specific one, so list the keyed names.
5. Discover the backends dynamically
A static list breaks the moment you scale to five. Instead, give the proxy Worker a service principal with units.read on the lobby Worker, and let a plugin or sidecar script on the proxy list the units and register one Velocity server per ready unit.
Create the principal and the grant
curl
Code
TypeScript
Code
Python
Code
units.read is enough to discover backends. Use units.control only if the proxy should also start and stop servers (for example waking a stopped minigame unit when a player queues). Enabling a principal changes the Worker's template, so it applies as the proxy unit restarts.
Every proxy unit now has ZAROZ_API_URL and ZAROZ_PROJECT_ID in its environment and a token at /zaroz/credentials/token. The token rotates: read the file on every request.
List the units
GET .../units returns each unit's key, private_name, state, labels and accepting. A backend is usable when it has a private_name and is running:
curl
Code
TypeScript
Code
Python
Code
Filter by label to keep different kinds of server apart: give lobby units {"kind":"lobby"} (see labels) and list with ?label=kind:lobby.
React to changes
Poll the list every few seconds, or follow the event stream with unit.ready, unit.draining, unit.stopped and unit.destroyed (with wait=30 the request returns the moment something happens), and re-list when one arrives. The stream is the better fit than a webhook here, because webhooks are delivered to a public URL and the proxy only exposes its game port. On each change, diff the list against the registered servers and call Velocity's registerServer / unregisterServer for the difference. unit.ready carries the unit's private name, so you do not even need a second read.
Helper library
zaroz-minecraft-helper, a Velocity and Paper plugin that does steps 4 and 5 for you, is coming. Until it is published, the calls above are everything it does.
Bedrock players with Geyser
Geyser translates Bedrock players to Java, so they use the same backends. Run it as a plugin on the proxy (Geyser-Velocity) and open a second port on the proxy Worker:
Code
This is one more raw port, EUR 1 per month. (tcp_udp is one endpoint that carries both protocols over a single public number, for when Java and Bedrock must share a port number.)
Bedrock traffic is UDP, and UDP has no connection, so the edge keeps each player on the same backend by flow: a new flow picks one backend from the last byte of the player's IPv4 address, and a flow that has started stays where it is until it ends, even if the set of backends changes. For a game this means two things: keep the proxy at one unit, which is what the Worker plan includes and what makes the question moot, and do not expect a UDP player to be moved between units mid-session. Between the proxy and your Java backends there is no UDP at all, because Geyser speaks Java protocol over TCP to them.
When players must reach one specific server
Sometimes a server needs its own public address: a creative server a streamer shares, a tournament server whose address you hand out. A private name cannot do that, because it is never public. Open a per-unit endpoint (kind: unit) on the backend Worker instead:
Code
Every unit of the Worker then gets its own public address and port for that container port, reaching that unit alone, and a new unit gets its own when it is added. Each unit's public_endpoints field (public_ip, public_port, protocol, internal_port) tells you the address, and it stays the same across the unit's restarts. It is billed as one port per unit, so four units are four ports; closing the endpoint removes the port from every unit. Use it for the few servers that need it, not for every backend: the proxy plus private names is cheaper and keeps the backends off the internet.
The bill
Every figure below comes from the rules in What units cost and the endpoint rules. The plan prices are placeholders (the Worker's price is still being set, the same example EUR 7.20 per month plan used on the billing page, including one unit); read your plan's real price on its page or from the quote endpoint. The structure of the bill does not change when the price does.
The rules:
- A Worker's plan price covers its included unit (one) and is paid in advance.
- Every unit above the included ones is an extra unit, billed for the hours it actually runs at the plan's monthly price / 720, with no surcharge, and at least 5 minutes per start.
- Every public raw port is EUR 1 per month, billed by the hour it is held (EUR 1 / 720 per hour), with no free ports on a Worker.
- Private names, the Units API, events and service principals are free. Backends with no public port cost no port.
The network above, run for a whole 30-day month (720 hours):
| line | arithmetic | EUR |
|---|---|---|
| proxy plan (1 unit included) | placeholder plan price | 7.20 |
| proxy TCP port 25565 | 1 port x EUR 1.00 | 1.00 |
| lobby plan (1 unit included) | placeholder plan price | 7.20 |
| 3 extra lobby units | 3 x 720 h x (7.20 / 720) | 21.60 |
| total | 37.00 |
Add Bedrock and the total is EUR 38.00 (one more port). Make a creative server public with a per-unit endpoint on a four-unit lobby Worker and it is four more ports, EUR 4.00. If you scale the lobby between 1 and 4 with a policy that holds three extra units for 100 hours in the month, the extra units cost 3 x 100 x 0.01 = EUR 3.00 that month instead of EUR 21.60. A stopped unit costs nothing, so a minigame unit stopped between rounds costs only the hours it ran.
Check the real figure before you commit: POST .../units/policy/quote returns what a policy could cost at most, without saving it.
Troubleshooting
| symptom | cause |
|---|---|
private_name is null | the Worker's slug starts with a digit, rename it |
u2.lobby does not resolve | that unit is not ready yet, or the proxy is in another project |
| Velocity says "connection refused" | the backend does not listen on the port you wrote, or it listens only on 127.0.0.1; bind 0.0.0.0 |
opening the port answers not_ready | the proxy unit is not running yet |
ports_not_offered | the product you chose offers no public ports; pick a Worker |
| the units list is refused | the principal has no units.read grant on that Worker (see Service principals) |
| the token file is missing | the principal was just enabled; it applies as the proxy unit restarts |
