# Custom domains, path routing, game subdomains and Domain Connect

A service is always reachable on a free platform address. This guide is how you make it answer on a **name of your own**. Which of these your service has depends on its product, so ask for its [capabilities](/concepts/capabilities) first: `custom_domain` and `external_access` appear when it supports them. The samples use the helper from [Using the Zaroz API](/guides/api/overview).

| You want | Use |
|---|---|
| `www.example.com` in front of a website or API | [Custom domain](#custom-domains) |
| `example.com/api` to reach a different service than `example.com` | [Path routing](#path-routing) |
| `play.yourgame.zaroz.cloud` for a game server | [Game subdomain](#game-subdomains) |
| Your DNS provider to create the records for you | [Domain Connect](#domain-connect) |

:::note
Domains that need a certificate use automatic TLS, so the name must already point at Zaroz in DNS before the certificate can be issued. A `pending` domain is normal while you wait for DNS; it is not an error.
:::

## Custom domains

### Prove you own the domain

Unless the name is one single label under a parent domain Zaroz itself registered for you, the organization must first prove control of the domain with a DNS TXT record: see [Verify a domain](/guides/organizations/members#verify-a-domain). Names inside Zaroz's own DNS zone are refused for good, and a name another organization already holds is refused with the same answer as one you have not proven, so this API cannot be used to find out who owns what.

### Check, attach, wait, detach

Ask first whether a name could be attached; it changes nothing.

**curl**

```bash
curl -sS "$ZAROZ_API/service-instances/$SERVICE/custom-domains/availability?hostname=www.example.com" -H "$AUTH"
# {"status": "available"}     or taken | invalid | platform_zone | not_verified

curl -sS -X POST "$ZAROZ_API/service-instances/$SERVICE/custom-domains" -H "$AUTH" \
  -H "Content-Type: application/json" -d '{"hostname": "www.example.com"}'
# {"id": "...", "hostname": "www.example.com", "status": "pending", "url": "https://www.example.com"}

curl -sS "$ZAROZ_API/service-instances/$SERVICE/custom-domains" -H "$AUTH"
curl -sS -X DELETE "$ZAROZ_API/service-instances/$SERVICE/custom-domains/$ROUTE" -H "$AUTH"
```

**TypeScript**

```ts
import { call } from "./zaroz";

const avail = await call("GET", `/service-instances/${SERVICE}/custom-domains/availability?hostname=www.example.com`);
if (avail.body.status !== "available") throw new Error(avail.body.status);

await call("POST", `/service-instances/${SERVICE}/custom-domains`, { hostname: "www.example.com" });

// pending until DNS points here and the certificate is issued
for (let i = 0; i < 60; i++) {
  const { body } = await call("GET", `/service-instances/${SERVICE}/custom-domains`);
  const mine = body.domains.find((d: any) => d.hostname === "www.example.com");
  console.log(mine.status);
  if (mine.status !== "pending") break;
  await new Promise((r) => setTimeout(r, 30_000));
}
```

**Python**

```python
import time
from zaroz import call

_, avail = call("GET", f"/service-instances/{SERVICE}/custom-domains/availability?hostname=www.example.com")
assert avail["status"] == "available", avail["status"]

call("POST", f"/service-instances/{SERVICE}/custom-domains", {"hostname": "www.example.com"})

for _ in range(60):  # pending until DNS points here and the certificate is issued
    _, body = call("GET", f"/service-instances/{SERVICE}/custom-domains")
    mine = next(d for d in body["domains"] if d["hostname"] == "www.example.com")
    print(mine["status"])
    if mine["status"] != "pending":
        break
    time.sleep(30)
```

- Create a **CNAME** (`www.example.com` to the platform host the listing returns as `platform_host`) at your DNS provider. A bare domain (`example.com`) cannot hold a CNAME: use your provider's ALIAS, ANAME or CNAME-flattening record to the same target.
- `status` goes `pending`, then `active` (route and certificate live) or `failed` (usually the certificate: it cannot be issued until the DNS points here; fix the record and it retries).
- Attaching is idempotent: a name already attached to this service is re-applied and returned. A service holds a limited number of domains (`limit` in the listing), and a service that publishes no HTTP cannot take one.
- Detaching removes the route, the certificate and your claim on the name, so you can attach it again later. The free platform host cannot be detached.

### Domains on an HTTPS endpoint

A Workload publishes websites and APIs as **HTTPS endpoints** (see [Public ports and endpoints](/units/endpoints) and [Add a public port](/guides/containers-public-port)). Their domains are managed on the endpoint:

**curl**

```bash
E=/projects/$PROJECT/service-instances/$SERVICE/endpoints
curl -sS -X POST "$ZAROZ_API$E/$ENDPOINT/domains" -H "$AUTH" -H "Content-Type: application/json" \
  -d '{"domain": "www.example.com"}'
# {"id": "<route id>", "domain": "www.example.com", "status": "pending",
#  "dns": {"record_type": "CNAME", "name": "www.example.com", "target": "<free host>"}}
curl -sS -X DELETE "$ZAROZ_API$E/$ENDPOINT/domains/$ROUTE" -H "$AUTH"
```

**TypeScript**

```ts
const E = `/projects/${PROJECT}/service-instances/${SERVICE}/endpoints/${ENDPOINT}`;
const d = await call("POST", `${E}/domains`, { domain: "www.example.com" });
console.log(d.body.dns); // create exactly this record at your DNS provider
```

**Python**

```python
E = f"/projects/{PROJECT}/service-instances/{SERVICE}/endpoints/{ENDPOINT}"
_, d = call("POST", f"{E}/domains", {"domain": "www.example.com"})
print(d["dns"])  # create exactly this record at your DNS provider
```

The response tells you the exact DNS record to create (`dns`). A refusal names its reason before the colon in `details`: `invalid_domain` (400), or `platform_domain`, `domain_taken`, `domain_not_verified`, `domain_limit`, `not_ready` (422). A `failure` code explains a `failed` domain.

## Path routing

Once a domain is on an HTTPS endpoint you can send some paths elsewhere, so `app.com` reaches one service and `app.com/api` another. The target must be an open HTTPS endpoint of a service **in the same project**. The longest matching path wins; the endpoint the domain is attached to answers everything else. The call **replaces the whole set**, and an empty list removes every path.

**curl**

```bash
curl -sS -X PUT "$ZAROZ_API$E/$ENDPOINT/domains/$ROUTE/paths" -H "$AUTH" -H "Content-Type: application/json" -d '{
  "paths": [
    {"path": "/api", "strip_prefix": true,
     "target_service_instance_id": "<api service uuid>", "target_endpoint_id": "<its https endpoint uuid>"}
  ]
}'

# how does this domain route now, and where would /api/users go?
curl -sS "$ZAROZ_API$E/$ENDPOINT/domains/$ROUTE/routing?path=/api/users" -H "$AUTH"
```

**TypeScript**

```ts
const routing = await call("PUT", `${E}/domains/${ROUTE}/paths`, {
  paths: [
    { path: "/api", strip_prefix: true, target_service_instance_id: API_SERVICE, target_endpoint_id: API_ENDPOINT },
  ],
});
for (const r of routing.body.rules) console.log(r.order, r.rule, r.status); // serving | starting | shadowed | broken

const sample = await call("GET", `${E}/domains/${ROUTE}/routing?path=/api/users`);
console.log(sample.body.sample); // { path, winning_order, upstream_path: "/users" }
```

**Python**

```python
_, routing = call("PUT", f"{E}/domains/{ROUTE}/paths", {"paths": [
    {"path": "/api", "strip_prefix": True,
     "target_service_instance_id": API_SERVICE, "target_endpoint_id": API_ENDPOINT},
]})
for r in routing["rules"]:
    print(r["order"], r["rule"], r["status"])   # serving | starting | shadowed | broken

_, sample = call("GET", f"{E}/domains/{ROUTE}/routing?path=/api/users")
print(sample["sample"])   # {"path", "winning_order", "upstream_path": "/users"}
```

With `strip_prefix: true` the target receives `/users`; with `false` it receives `/api/users`. Each rule has a `status`: `serving`, `starting` (its target is not live yet), `shadowed` (another rule with the same path always wins, so this one never serves) or `broken` (its target cannot answer). Refusals: `invalid_path` and `target_not_found` (400, a missing, closed or other-project target all answer alike), and `path_limit`, `duplicate_path`, `target_not_http` (422).

## Game subdomains

A game server can take a free name under a platform zone (`<name>.<zone>`), pointing at its public address, with an SRV record for games that read a port from it. A service holds one.

**curl**

```bash
curl -sS "$ZAROZ_API/service-instances/$SERVICE/game-subdomains" -H "$AUTH"
# {"limit": 1, "subdomains": [], "zones": [{"id": "<zone uuid>", "name": "<zone>"}]}

curl -sS "$ZAROZ_API/service-instances/$SERVICE/game-subdomains/availability?parent_domain_id=$ZONE&subdomain=myserver" -H "$AUTH"
curl -sS -X POST "$ZAROZ_API/service-instances/$SERVICE/game-subdomains" -H "$AUTH" -H "Content-Type: application/json" \
  -d '{"parent_domain_id": "<zone uuid>", "subdomain": "myserver"}'
# 201 {"hostname": "myserver.<zone>", "status": "active", "has_srv_record": true, ...}
```

**TypeScript**

```ts
const list = await call("GET", `/service-instances/${SERVICE}/game-subdomains`);
const zone = list.body.zones[0];
const free = await call("GET",
  `/service-instances/${SERVICE}/game-subdomains/availability?parent_domain_id=${zone.id}&subdomain=myserver`);
if (free.body.status === "available") {
  const sub = await call("POST", `/service-instances/${SERVICE}/game-subdomains`, {
    parent_domain_id: zone.id,
    subdomain: "myserver",
  });
  console.log(sub.body.hostname, sub.body.status);
}
```

**Python**

```python
_, listing = call("GET", f"/service-instances/{SERVICE}/game-subdomains")
zone = listing["zones"][0]
_, free = call("GET", f"/service-instances/{SERVICE}/game-subdomains/availability"
                      f"?parent_domain_id={zone['id']}&subdomain=myserver")
if free["status"] == "available":
    _, sub = call("POST", f"/service-instances/{SERVICE}/game-subdomains",
                  {"parent_domain_id": zone["id"], "subdomain": "myserver"})
    print(sub["hostname"], sub["status"])
```

`status` is `pending` (records being created), `active`, `failed` (retried automatically) or `deleting`. `PATCH .../game-subdomains/{id}` with a new name replaces it (the answer carries a **new id**), and `DELETE` releases it. A name stays taken for a while after its previous holder released it. Refusals: `zone_unavailable`, `invalid_subdomain`, `subdomain_taken`, `already_has_subdomain` (422).

For a FiveM server behind your **own** domain, see [custom domain in the server list](/fivem/custom-domain-in-server-list).

## Domain Connect

If your DNS provider supports [Domain Connect](https://www.domainconnect.org/), Zaroz can send you there to approve the exact records instead of you copying them. It never writes your DNS itself. Only products that declare a template (game servers with an SRV record) offer it, and a deployment may not run it at all: check `domain_connect` in [what the deployment offers](/guides/api/overview#what-a-deployment-offers).

**curl**

```bash
curl -sS "$ZAROZ_API/service-instances/$SERVICE/domain-connect" -H "$AUTH"
# {"supported": true, "links": []}

curl -sS -X POST "$ZAROZ_API/service-instances/$SERVICE/domain-connect/discover" -H "$AUTH" \
  -H "Content-Type: application/json" -d '{"domain": "example.com", "host": "play"}'
# {"id": "...", "status": "pending", "apply_url": "https://dns-provider.example/...", "hostname": "play.example.com"}

# send the customer's browser to apply_url, then:
curl -sS -X POST "$ZAROZ_API/service-instances/$SERVICE/domain-connect/$LINK/verify" -H "$AUTH"
curl -sS -X DELETE "$ZAROZ_API/service-instances/$SERVICE/domain-connect/$LINK" -H "$AUTH"
```

**TypeScript**

```ts
const state = await call("GET", `/service-instances/${SERVICE}/domain-connect`);
if (state.body.supported) {
  const link = await call("POST", `/service-instances/${SERVICE}/domain-connect/discover`, {
    domain: "example.com",
    host: "play", // "" points the apex at the server
  });
  console.log("Open", link.body.apply_url);
  // after the customer approves at their DNS provider:
  const v = await call("POST", `/service-instances/${SERVICE}/domain-connect/${link.body.id}/verify`);
  console.log(v.body.status); // active | pending | failed
}
```

**Python**

```python
_, state = call("GET", f"/service-instances/{SERVICE}/domain-connect")
if state["supported"]:
    _, link = call("POST", f"/service-instances/{SERVICE}/domain-connect/discover",
                   {"domain": "example.com", "host": "play"})   # "" points the apex at the server
    print("Open", link["apply_url"])
    # after the customer approves at their DNS provider:
    _, v = call("POST", f"/service-instances/{SERVICE}/domain-connect/{link['id']}/verify")
    print(v["status"])   # active | pending | failed
```

`verify` checks live DNS: `active` once the SRV record is published and points at the expected host and port, `failed` when a different record is found, `pending` while nothing has propagated. Releasing a link does not touch your DNS provider; remove the records there yourself. A service holds at most 20 links, and a provider without a synchronous flow cannot be used (create the records by hand).

## Raw ports with an HTTP domain

A service with external access can also publish a **domain in front of one TCP port**, with a managed certificate, through `POST /service-instances/{id}/external-access/{allocation_id}/http` (body `{"domain": "..."}`). It follows the same rules as a custom domain (`DELETE` on the same path removes it). Opening, closing and quota of the ports themselves are in [Public ports and endpoints](/units/endpoints).
