# Subdomain

> Use when binding a zeabur.app subdomain to a server — claiming <label>.zeabur.app, pointing it at a VPS IP, getting an HTTPS certificate for it, changing the target IP, or deleting a binding. Use when the user says "give my server a domain", "bind a subdomain", "I want HTTPS on my VPS", or "change where my subdomain points".

- Skill: `zeabur/subdomain` (Agent Skill)
- Install (CLI): `npx skillmds@latest add zeabur/subdomain`
- Raw SKILL.md: https://api.skillmd.com/api/skills/zeabur/subdomain/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: zeabur (https://skillmd.com/u/zeabur)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/zeabur/subdomain

---


# Zeabur.app Subdomain

Bind `<label>.zeabur.app` to any public server IP — the DNS record points **directly at the machine** (this is not a proxy, and no Zeabur project is involved). The target does not have to be a Zeabur-rented server: any VPS the user owns works. Because DNS resolves straight to the server and port 80 is reachable, Let's Encrypt HTTP-01 issuance works out of the box for HTTPS.

Set up the `zapi` helper from the **auth** skill first.

**Quota per workspace:** Free 5 · Dev 20 · Pro 100 · Team 500 bindings.

## 1. Claim a subdomain

`label` is the part before `.zeabur.app`:

```bash
zapi '{"query":"mutation($label: String!, $t: ZeaburAppBindingTargetInput!){ createZeaburAppBinding(label: $label, target: $t) { _id hostname dnsStatus } }","variables":{"label":"myapp","t":{"ipv4":"203.0.113.10"}}}' \
  | jq '.data.createZeaburAppBinding'
```

`ipv6` is optional in the target. If the label is taken or invalid, the call returns an error — pick another label. For a team workspace, add `ownerID`.

## 2. Wait for DNS to go ACTIVE

```bash
zapi '{"query":"query($id: ObjectID!){ zeaburAppBinding(id: $id) { hostname dnsStatus dnsErrorCode } }","variables":{"id":"<binding-id>"}}' \
  | jq '.data.zeaburAppBinding'
```

Poll every ~10 seconds until `dnsStatus` is `ACTIVE` (`PENDING` → `ACTIVE`; on `ERROR`, surface `dnsErrorCode`). Then verify HTTP works — assuming something is listening on port 80:

```bash
curl -sI "http://<label>.zeabur.app" | head -1
```

## 3. HTTPS

Pick by how the server is run (the **deploy** skill decides this):

- **ZeaburOS (the recommended path)** — nothing to set up: the Zeabur ingress-controller auto-issues and renews the certificate for every Ingress host (built-in ACME). Do not add cert-manager for public TLS there — it fights the controller over the ACME challenge. Full details in the **deploy** skill.
- **Vanilla k3s** — cert-manager with an HTTP-01 ClusterIssuer; the certificate lives in a k8s Secret referenced by the Ingress and renews automatically. Recipe in the **deploy** skill.
- **Self-managed machine** (docker / plain nginx / …) — run `certbot` on the server (`certbot --nginx`, or `certbot certonly --standalone` if nothing serves port 80 yet). Auto-renewal comes from the certbot systemd timer.

Either way, the binding must be `ACTIVE` **before** issuance — HTTP-01 validates by fetching from the hostname. Verify afterwards:

```bash
curl -sI "https://<label>.zeabur.app" | head -1
```

### Zeabur-issued certificate (CSR flow) — currently broken, do not use

The API also has `refreshCertificateWithCSR(csr)`, meant to let Zeabur sign a certificate for your hostname while the private key stays on your server. **It does not yet recognize zeabur.app bindings** (it only checks project-bound domains) and returns `NOT_FOUND: domain not found` for any binding hostname. Until the backend fix lands, use the cert-manager/certbot paths above. When it is fixed, this becomes the option for machines where port 80 cannot be exposed — renewal is manual (re-run the exchange; check expiry with `openssl x509 -enddate -noout -in <cert>`).

## Manage bindings

```bash
# List
zapi '{"query":"{ zeaburAppBindings { edges { node { _id hostname ipv4 ipv6 dnsStatus } } } }"}' \
  | jq -r '.data.zeaburAppBindings.edges[].node | "\(._id)\t\(.hostname)\t→ \(.ipv4)\t\(.dnsStatus)"'

# Repoint to a new IP (e.g. after re-renting a server)
zapi '{"query":"mutation($id: ObjectID!, $t: ZeaburAppBindingTargetInput!){ updateZeaburAppBindingTarget(id: $id, target: $t) { hostname dnsStatus } }","variables":{"id":"<binding-id>","t":{"ipv4":"203.0.113.99"}}}'

# Delete (confirm with the user first — the hostname stops resolving)
zapi '{"query":"mutation($id: ObjectID!){ deleteZeaburAppBinding(id: $id) { hostname } }","variables":{"id":"<binding-id>"}}'
```

After updating a target, `dnsStatus` cycles back through `PENDING` — poll it to `ACTIVE` again. The certificate is tied to the hostname, not the IP, so repointing does not require a new certificate.

