# 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**:

```text
players ──TCP 25565──▶  proxy Worker   (1 unit, one public port)
                           │  private names, never on the internet
                           ├──▶ u1.lobby:25565
                           ├──▶ u2.lobby:25565      lobby Worker  (N units, no public port)
                           └──▶ u3.lobby:25565
                           └──▶ u1.games:25565      games Worker  (N units, no public port)
```

- 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](/units/private-names), `<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](/units), [Public ports](/units/endpoints) and [Private names](/units/private-names).

:::note{title="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](/guides/containers-deploy) shows how to deploy from an image), and a personal API token. The calls below use the helper from the [Units API overview](/guides/units-api/overview#where-a-request-goes): `$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](/units/private-names#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:

```bash
export PROXY="<proxy service instance uuid>"
export LOBBY="<lobby service instance uuid>"
```

## 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**

```bash
curl -sS -X POST "$ZAROZ_API/projects/$PROJECT/service-instances/$PROXY/endpoints" \
  -H "$AUTH" -H "Content-Type: application/json" \
  -d '{"kind":"port","internal_port":25565,"protocol":"tcp"}'
# {"id": "...", "public_ip": "203.0.113.10", "public_port": 30421, "state": "active", ...}
```

**TypeScript**

```ts
const port = await call("POST", `/projects/${PROJECT}/service-instances/${PROXY}/endpoints`, {
  kind: "port", internal_port: 25565, protocol: "tcp",
});
console.log(`${port.body.public_ip}:${port.body.public_port}`, port.body.state);
```

**Python**

```python
status, port = call("POST", f"/projects/{PROJECT}/service-instances/{PROXY}/endpoints",
                    {"kind": "port", "internal_port": 25565, "protocol": "tcp"})
print(f"{port['public_ip']}:{port['public_port']}", port["state"])
```

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-bill)). The request is refused with `not_ready` until the proxy unit is running, so deploy first and open the port after.

:::note{title="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**

```bash
curl -sS -X PATCH "$ZAROZ_API/projects/$PROJECT/service-instances/$LOBBY/units/policy" \
  -H "$AUTH" -H "Content-Type: application/json" \
  -d '{"mode":"fixed","min_units":4,"max_units":4}'
```

**TypeScript**

```ts
await call("PATCH", `/projects/${PROJECT}/service-instances/${LOBBY}/units/policy`,
  { mode: "fixed", min_units: 4, max_units: 4 });
```

**Python**

```python
call("PATCH", f"/projects/{PROJECT}/service-instances/{LOBBY}/units/policy",
     {"mode": "fixed", "min_units": 4, "max_units": 4})
```

If you want names you choose, such as `survival` and `skyblock`, or a unit per minigame match, use [`code` mode](/guides/units-api/keys-and-lifecycle) 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`:

```toml
bind = "0.0.0.0:25565"
player-info-forwarding-mode = "modern"

[servers]
u1 = "u1.lobby:25565"
u2 = "u2.lobby:25565"
u3 = "u3.lobby:25565"
u4 = "u4.lobby:25565"
try = ["u1", "u2", "u3", "u4"]
```

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](/guides/units-api/principals) 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**

```bash
P="$ZAROZ_API/projects/$PROJECT/service-instances/$PROXY/principal"
curl -sS -X PUT "$P" -H "$AUTH"
curl -sS -X PUT "$P/grants" -H "$AUTH" -H "Content-Type: application/json" -d "{
  \"grants\": [{\"target_service_instance_id\": \"$LOBBY\", \"capability\": \"units.read\"}]
}"
```

**TypeScript**

```ts
const P = `/projects/${PROJECT}/service-instances/${PROXY}/principal`;
await call("PUT", P);
await call("PUT", `${P}/grants`, {
  grants: [{ target_service_instance_id: LOBBY, capability: "units.read" }],
});
```

**Python**

```python
P = f"/projects/{PROJECT}/service-instances/{PROXY}/principal"
call("PUT", P)
call("PUT", f"{P}/grants", {"grants": [
    {"target_service_instance_id": LOBBY, "capability": "units.read"},
]})
```

`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**

```bash
curl -sS "$ZAROZ_API_URL/projects/$ZAROZ_PROJECT_ID/service-instances/$LOBBY/units" \
  -H "Authorization: Bearer $(cat /zaroz/credentials/token)" \
  | jq -r '.data[] | select(.state == "running" and .private_name != null) | "\(.key) \(.private_name)"'
# u1 u1.lobby
# u2 u2.lobby
```

**TypeScript**

```ts
import { readFileSync } from "node:fs";

const token = () => readFileSync("/zaroz/credentials/token", "utf8").trim();
const base = `${process.env.ZAROZ_API_URL}/projects/${process.env.ZAROZ_PROJECT_ID}`;

type Unit = { key: string; private_name: string | null; state: string; labels: Record<string, string> };

export async function backends(lobbyId: string): Promise<{ name: string; address: string }[]> {
  const res = await fetch(`${base}/service-instances/${lobbyId}/units`, {
    headers: { Authorization: `Bearer ${token()}` },
  });
  if (!res.ok) throw new Error(`units: ${res.status}`);
  const { data } = (await res.json()) as { data: Unit[] };
  return data
    .filter((u) => u.state === "running" && u.private_name)
    .map((u) => ({ name: u.key, address: `${u.private_name}:25565` }));
}
```

**Python**

```python
import os
import requests

def token() -> str:
    with open("/zaroz/credentials/token") as f:
        return f.read().strip()

def backends(lobby_id: str) -> list[dict]:
    base = f"{os.environ['ZAROZ_API_URL']}/projects/{os.environ['ZAROZ_PROJECT_ID']}"
    r = requests.get(f"{base}/service-instances/{lobby_id}/units",
                     headers={"Authorization": "Bearer " + token()}, timeout=10)
    r.raise_for_status()
    return [
        {"name": u["key"], "address": f"{u['private_name']}:25565"}
        for u in r.json()["data"]
        if u["state"] == "running" and u["private_name"]
    ]
```

Filter by label to keep different kinds of server apart: give lobby units `{"kind":"lobby"}` (see [labels](/guides/units-api/labels-and-user-data)) and list with `?label=kind:lobby`.

### React to changes

Poll the list every few seconds, or follow the [event stream](/guides/units-api/events) 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](/guides/units-api/webhooks) 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.

:::note{title="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**:

```bash
curl -sS -X POST "$ZAROZ_API/projects/$PROJECT/service-instances/$PROXY/endpoints" \
  -H "$AUTH" -H "Content-Type: application/json" \
  -d '{"kind":"port","internal_port":19132,"protocol":"udp"}'
```

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:

```bash
curl -sS -X POST "$ZAROZ_API/projects/$PROJECT/service-instances/$LOBBY/endpoints" \
  -H "$AUTH" -H "Content-Type: application/json" \
  -d '{"kind":"unit","internal_port":25565,"protocol":"tcp"}'
```

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](/units/billing) and the [endpoint rules](/units/endpoints). **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:

1. A Worker's plan price covers its **included** unit (one) and is paid in advance.
2. 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.
3. 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.
4. 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](/guides/units-api/principals)) |
| the token file is missing | the principal was just enabled; it applies as the proxy unit restarts |
