# Deploy

> Use when deploying anything onto a Zeabur-rented server — a public image like nginx, a website, an app. This skill picks the path; read it before installing anything on the machine. Use when the user says "run nginx on my server", "host my site on the VPS", or "deploy X" after renting.

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

---


# Deploy to a Zeabur Server

If what the user wants to deploy is **their own source code** (a Git repo) rather than an existing image, build it into an image first with the **build-image** skill — then deploy that `imageReference` through the paths below like any other image.

## Path selection — ask, and recommend k3s

- The user **named a stack** (docker / docker compose / 1Panel / 寶塔 / bare systemd…) → respect it, don't second-guess it. Follow the **self-managed branch** below.
- The user **didn't specify how** → **ask before installing anything**, presenting the common options with the recommendation first. For example:

  > How should this machine run its services?
  >
  > 1. **ZeaburOS (k3s, recommended)** — Zeabur's suggested setup: k3s + the Zeabur operator; declarative deployments, auto-renewed HTTPS, machine resources visible in the Zeabur dashboard, and Zeabur projects deployable later
  > 2. **Vanilla k3s** — upstream open-source k3s; fully independent machine, zero Zeabur components, Zeabur only provides the server and domain
  > 3. **docker compose** — single-machine container orchestration, one YAML for everything
  > 4. **1Panel / 寶塔** — a web control panel, if you want click-to-manage later
  > 5. **Bare install** — apt-install things directly on the OS, the classic way
  >
  > The machine is yours — any of these works, and other ideas are welcome too.

  (Present the question in the user's language. If the agent environment has a structured question tool, use it with these options instead of prose.) "You decide" or picking option 1 → the Kubernetes path, ZeaburOS variant. Option 2 → the Kubernetes path, vanilla variant. Options 3–5 or any other named stack → self-managed branch. Ask **once per machine** — the choice sticks for later deployments on the same server unless the user changes it. Note the honest distinction: option 1 installs Zeabur's operator and connects the machine to Zeabur's control plane; option 2 leaves zero Zeabur components on the box.

The recommended path gives every deployment the same shape — declarative workloads, an ingress with automated TLS renewal, and room to add more apps to the same machine later — instead of a pile of hand-run commands. That's the pitch; the user still decides.

## Kubernetes path (ZeaburOS or vanilla k3s)

On the ZeaburOS variant, read [`../../references/zeaburos.md`](../../references/zeaburos.md) before touching the cluster — it is the operating contract: what the platform owns, what reverts if you change it, and the ingress-controller's routing/TLS rules (hosts route to a single backend and paths are ignored; host collisions are destructive; numeric service ports only).

### 1. A server with k3s

If there is no server yet, rent one with the **server-rent** skill. Either variant goes **only on a blank machine** (fresh rent or fresh reinstall; k3s claims ports and rewrites iptables, so on a machine the user already set up, reinstall first — see **server-manage**).

**ZeaburOS variant** — install through the Zeabur API. Despite the mutation's name, this installs k3s **plus the Zeabur operator** (which manages platform components like cert-manager) and registers the cluster with Zeabur:

```bash
OP_ID=$(uuidgen)
zapi "$(jq -n --arg op "$OP_ID" --arg id "<server-id>" '{query: "mutation($id: ObjectID!, $op: String){ installK3s(serverID: $id, operationID: $op) }", variables: {id: $id, op: $op}}')"
```

Poll until `hasK3s` is true (installation takes a few minutes; read `events` if stuck):

```bash
zapi '{"query":"query($id: ObjectID!){ server(_id: $id) { hasK3s provisioningStatus events { message time severity } } }","variables":{"id":"<server-id>"}}'
```

**Vanilla k3s variant** — install upstream k3s over SSH; no Zeabur components touch the machine:

```bash
ssh <sshUsername>@<ip> -p <sshPort> "curl -sfL https://get.k3s.io | sudo sh -"
```

### 2. kubectl access

Fetch the kubeconfig over SSH and point it at the public IP:

```bash
ssh <sshUsername>@<ip> -p <sshPort> "sudo cat /etc/rancher/k3s/k3s.yaml" > ./kubeconfig
sed -i.bak "s/127\.0\.0\.1/<ip>/" ./kubeconfig && rm -f ./kubeconfig.bak
export KUBECONFIG="$PWD/kubeconfig"
kubectl get nodes
```

Treat the kubeconfig like a credential: keep it out of git and out of chat output.

### 3. Deploy the workload

Use the **kubernetes-workloads** skill for the mechanics (fragments, server-side apply, rollout verification). One mapping note: those skills describe machines that also host Zeabur project services, with `environment-<id>` namespaces and Zeabur identity labels. **A toolkit-only deployment has no Zeabur project** — use a plain namespace (`default` is fine) and plain names (`nginx`), and skip the Zeabur ID injection entirely. The boundary, mutation loop, and verification discipline still apply as written.

Minimal example (nginx):

```bash
kubectl create deployment nginx --image=nginx --port=80
kubectl expose deployment nginx --port=80
```

### 4. Expose it

Exposure is its own fork. If the user already named a hostname (or said "no domain"), follow that. Otherwise **ask**, recommendation first:

> How should it be reachable from outside?
>
> 1. **zeabur.app subdomain (recommended)** — instantly get `<your-label>.zeabur.app` with DNS pointed at this machine, plus auto-issued, auto-renewed HTTPS
> 2. **Your own domain** — create an A record at your DNS provider pointing to `<server-ip>`; the rest of the flow is identical
> 3. **Bare IP for now** — no domain, `http://<ip>` for testing (no HTTPS)
> 4. **Not public yet** — internal to the cluster/machine only

(Present the question in the user's language.)

HTTPS comes with options 1–2 by default — skip it only if the user explicitly says so. Then, order matters — DNS first, then certificate (Let's Encrypt HTTP-01 must reach the hostname):

1. **Get DNS pointing at the machine**: option 1 → the **subdomain** skill (`<label>.zeabur.app` → this server's IP, wait for `ACTIVE`); option 2 → the user creates an A record at their DNS provider, then verify with `dig +short <host>` before proceeding.
2. **The ingress controller — per variant.** On **vanilla k3s**, the bundled traefik is there out of the box; use `ingressClassName: traefik`. On **ZeaburOS**, **Zeabur ships its own ingress-controller** — that is exactly why the bundled traefik is disabled (`traefik.yaml.skip`). It claims the node's 80/443, serves **every** Ingress resource regardless of `ingressClassName` (so **omit** that field), speaks HTTP/3, and also routes Zeabur's own monitoring/log endpoints (`*.servers.onzeabur.com`) that the dashboard depends on.

   **Never enable the bundled traefik on ZeaburOS.** If traefik grabs 80/443 first, Zeabur's ingress-controller sits Pending forever and the dashboard's server monitoring breaks with it. It may take a few minutes to appear after `installK3s` finishes — wait for it instead of installing something else:

```bash
kubectl get pods -A | grep -i ingress   # wait until Zeabur's ingress-controller is Running
```

   (If a machine already has traefik enabled from older guidance: restore the `traefik.yaml.skip` marker, delete the traefik HelmChart/deployment, and Zeabur's controller will schedule as soon as 80/443 free up.)
3. **TLS — the variants differ completely.**

   **ZeaburOS: do nothing.** The Zeabur ingress-controller has ACME built in (HTTP-01 against Let's Encrypt): the first HTTPS request for an Ingress host triggers issuance (that first handshake is served a self-signed placeholder — poll until the real cert lands), renewal is automatic, HTTP redirects to HTTPS, and HTTP/3 is on. **Do NOT use cert-manager for public TLS here** — the controller intercepts `/.well-known/acme-challenge/*` globally, so a cert-manager HTTP-01 solver always sees 404 and the two fight over the same ACME authorization until issuance fails. A preinstalled cert-manager on the cluster belongs to Zeabur's internal components — leave it alone. Bring-your-own-cert and other fine print: see `../../references/zeaburos.md`.

   **Vanilla k3s: install cert-manager** and a Let's Encrypt issuer:

```bash
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/latest/download/cert-manager.yaml
kubectl -n cert-manager wait --for=condition=Available deploy --all --timeout=180s
kubectl apply -f - <<'EOF'
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    privateKeySecretRef:
      name: letsencrypt-account-key
    solvers:
      - http01:
          ingress:
            ingressClassName: traefik
EOF
```

4. **The Ingress.** On **ZeaburOS**, this plain Ingress is the whole job — no `ingressClassName`, no annotations, no `spec.tls`; the controller picks it up and handles TLS by itself:

```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: nginx
spec:
  rules:
    - host: <label>.zeabur.app
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: nginx
                port:
                  number: 80
```

   On **vanilla k3s**, add to the same manifest: `ingressClassName: traefik` under `spec`, the annotation `cert-manager.io/cluster-issuer: letsencrypt`, and a `tls` block (`hosts: [<label>.zeabur.app]`, `secretName: <label>-zeabur-app-tls`).

5. **Verify from outside.** ZeaburOS: the first HTTPS request can take a minute while the controller finishes issuance — poll until 200:

```bash
until [ "$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 https://<label>.zeabur.app)" = "200" ]; do sleep 10; done
curl -sI "https://<label>.zeabur.app" | head -1
```

   Vanilla k3s: `kubectl wait --for=condition=Ready certificate <label>-zeabur-app-tls --timeout=180s`, then the same external curl.

## Self-managed branch (user chose their own stack)

SSH in and set up exactly what the user asked for (e.g. `apt-get install docker.io && docker run -d -p 80:80 nginx`). For the hostname, use the **subdomain** skill the same way; for HTTPS, run `certbot` on the machine (`certbot --nginx` or `certbot certonly --standalone`) — DNS points straight at this server and port 80 is reachable, so HTTP-01 just works, with auto-renewal via the certbot timer.

## Either way, finish with verification

Report: what is running, where (namespace/unit), the URL, and cert expiry/renewal story. `curl -sI https://<host>` from outside the machine is the completion check — an applied manifest or a started container is not yet a deployment.

