# Deploy de aplicações com vários serviços (guia para agentes)

Este guia é para um **agent** que recebeu a tarefa de publicar uma aplicação com várias partes (front, API, worker, bancos) na Nebular. Ele usa só a API pública `/v1`. Tudo aqui foi conferido contra a API; o que a plataforma **não** faz está na seção [O que não é suportado](#o-que-não-é-suportado).

## Resumo para LLM

```text
NEBULAR MULTI-SERVIÇO (resumo). Base: https://api.nebularws.com/v1. Auth: Authorization: Bearer $NEBULAR_TOKEN (nunca em URL/log).
MODELO: projeto = grupo de serviços numa rede privada. kind só "app" (seu código/imagem) ou "database" (postgres|mariadb|sqlite). NÃO existe kind worker/cron.
NÃO EXISTE: manifesto declarativo (nebular.json/compose), réplicas/escala horizontal (1 container por serviço), template próprio do time, rollback de grupo, webhooks.
FLUXO: POST /projects -> POST /projects/{p}/databases (espere status=ready) -> POST /projects/{p}/services (visibility private por padrão; o "web" já nasce público, use PATCH) -> PUT .../variables (bulk) e .../shared-variables com ${{svc.VAR}} -> código: POST .../services/{s}/deployments (multipart "file") em ordem api -> worker/web, OU imagem/git + POST /projects/{p}/deploy {} -> GET /projects/{p}/group-deployments/{g} até ready -> verificar public_url -> rollback por serviço: POST .../deployments/{d}/rollback.
REFERÊNCIAS: ${{db.DATABASE_URL}}, ${{api.PRIVATE_URL}} (=http://api:PORT), ${{jobs.LIBSQL_URL}}, ${{shared.X}}. Variável shared só chega ao serviço se ele a referenciar. Resolvidas no deploy. Literal "${{" escreva "$${{".
ESCOPOS: projects:read projects:write deploy env:write databases:write volumes:write domains:write logs:read (env:reveal só humano, não peça).
REGRAS DE RUNTIME: o app escuta 0.0.0.0:$PORT e responde 2xx/3xx em healthcheck_path (worker também precisa de HTTP); disco é somente leitura: grave em /tmp (64 MB) ou num volume em /app/<pasta> (1 por serviço, 1 a 50 GB); sem SMTP direto.
ERROS: 4xx (menos 429) = corrija; 429/503 = espere Retry-After; 401/403/quota_exceeded/project_quota_exceeded = pare e avise a pessoa; 409 deploy_in_progress = acompanhe o deploy em curso; 422 unresolved_reference|reference_cycle = corrija variáveis (nada foi criado).
DEPLOY ASSÍNCRONO: faça polling 2 s dobrando até 30 s. Deploy falho não dá erro HTTP: veja status=failed e error.code/message (activate_readonly_write, activate_no_http, build_storage_unavailable...).
IDEMPOTÊNCIA: Idempotency-Key (8-128 [A-Za-z0-9._:-]) em todo POST que cria/dispara; mesma chave+mesmo corpo em 24 h repete a resposta; corpo diferente = 422 idempotency_conflict.
LIMITES: GET /v1/limits (hobby: 10 serviços, 3 bancos, 20 GB de volume, 4096 MB, 30 deploys/h por time). Cada deploy de cada serviço conta.
DESTRUTIVO: apagar volume/banco/serviço/projeto só com ordem explícita da pessoa. Nunca imprima valor de variável sensível.
```

## O que existe

| Pergunta | Resposta (verificada) |
|---|---|
| Há um arquivo para declarar a aplicação inteira (`nebular.json`, `nebular.toml`, compose)? | **Não.** O fluxo é imperativo: você cria projeto, bancos, serviços e variáveis por chamadas e depois dispara o deploy. Veja [Manifesto declarativo](#manifesto-declarativo-não-existe-ainda). |
| Há várias partes no mesmo projeto? | **Sim.** Um projeto é um grupo de serviços na mesma rede privada, com variáveis `shared` (usadas por referência `${{shared.NOME}}`) e deploy em grupo ordenado pelas referências. |
| Tipos de serviço | Só `app` e `database`. Um worker é um `app` privado; **não existe** `kind` worker nem cron. |
| Réplicas, escala horizontal, balanceamento entre instâncias | **Não existem.** Cada serviço é um único container; o nome privado aponta para ele. O que há: mais memória e CPU por serviço (`resources`). |
| Template com vários serviços | Existe só o **catálogo da plataforma** (hoje `wordpress`: MariaDB + app). O time **não** cadastra templates próprios. |
| Bancos gerenciados | `postgres`, `mariadb` e `sqlite` (libSQL), entregues ao app por variável e referência. |

## Pré-requisitos

1. **Token de API** do time (`nbk_…`), criado por uma **pessoa** com papel admin e confirmação recente de identidade, no painel: Time, Configurações, Tokens de API ([Token de API](/comecar/token/)). Escopos para este guia:

   | Passo | Escopo |
   |---|---|
   | ler token, limites, projetos, serviços, deploys, bancos | `projects:read` |
   | criar projeto e serviços, mudar recursos | `projects:write` |
   | criar bancos | `databases:write` |
   | variáveis e referências | `env:write` (e `env:read` para listar) |
   | volume | `volumes:write` |
   | deploy, grupo, rollback, enviar código | `deploy` |
   | domínio próprio | `domains:write` |
   | logs de build e de runtime | `logs:read` |

   O token **não** pode ser restrito a projetos específicos se você vai criar o projeto: token restrito responde `403` em `POST /v1/projects`. Não peça `env:reveal`: ele devolve segredos em claro e só nasce com confirmação de identidade da pessoa. O app recebe as credenciais por variável; o agent nunca precisa vê-las.
2. Ferramentas: `curl`, `jq`, `tar`. O código de cada serviço no disco (ou imagens públicas com digest, ou repositório GitHub já conectado ao time por uma pessoa).
3. Variáveis de ambiente: `NEBULAR_API=https://api.nebularws.com` e `NEBULAR_TOKEN` (segredo do ambiente; nunca no prompt, em log ou em arquivo versionado).

```bash
H="Authorization: Bearer $NEBULAR_TOKEN"
curl -sS "$NEBULAR_API/v1/me" -H "$H" | jq -e '.token.scopes | index("deploy") != null' >/dev/null
curl -sS "$NEBULAR_API/v1/limits" -H "$H"
```
```json
{"plan": "hobby", "services": {"used": 1, "max": 10}, "memory_mb": {"used": 512, "max": 4096}, "volumes_gb": {"used": 0, "max": 20}, "databases": {"used": 0, "max": 3}, "deploys_per_hour": {"used": 0, "max": 30}}
```

Antes de começar, confirme que cabe: serviços de aplicação, bancos, memória e volume do plano (`GET /v1/limits`). Um deploy completo desta receita usa 3 deploys da cota `deploys_per_hour`.

## Como as peças se encaixam

```mermaid
flowchart LR
  U[Internet] --> W[web: Next.js, public]
  W -- API_URL=${{api.PRIVATE_URL}} --> A[api: Go, private, volume /app/uploads]
  K[worker: Node, private] -- API_URL=${{api.PRIVATE_URL}} --> A
  A -- DATABASE_URL=${{db.DATABASE_URL}} --> D[(db: Postgres)]
  K -- DATABASE_URL=${{db.DATABASE_URL}} --> D
  K -- LIBSQL_URL=${{jobs.LIBSQL_URL}} --> J[(jobs: SQLite libSQL)]
```

Regras do modelo que decidem o desenho:

- **Rede privada.** Serviços do mesmo projeto se resolvem pelo `name`: `api` é alcançável em `http://api:<port>`. Use `${{api.PRIVATE_URL}}`, não monte a URL. Outro projeto nunca é alcançável ([Rede privada](/conectados/rede-privada/)).
- **Bancos não ficam na rede privada.** Moram num nó de banco e são acessados pela URL das variáveis, sempre com TLS. Só os serviços do projeto conseguem abrir a conexão.
- **Visibilidade.** `visibility` omitida = `private`. Só o que precisa de URL pública vira `public` (e então exige `slug`). Todo projeto novo já nasce com um serviço `web` **público**; adapte-o com `PATCH` em vez de criar outro.
- **Ordem do deploy em grupo.** Vem das referências: quem é referenciado sobe antes. Neste exemplo `api` sobe primeiro; `worker` e `web` (que referenciam a `api`) sobem depois, em paralelo (até 4 por camada).
- **Variável compartilhada (`shared`) só chega ao serviço se ele a referenciar** (`${{shared.APP_ENV}}`). Defini-la não a injeta em ninguém.
- **Resolução no deploy.** Referência para algo que não existe só falha ao disparar o deploy, com `422`.

## O exemplo: app "pedidos"

| Serviço | Tipo | Stack | Visibilidade | Porta | O que faz |
|---|---|---|---|---|---|
| `db` | database | Postgres | n/a | n/a | dados dos pedidos |
| `jobs` | database | SQLite (libSQL) | n/a | n/a | estado do worker |
| `api` | app | Go (`builder: go`) | `private` | 8080 | regra de negócio; grava anexos no volume `/app/uploads` |
| `worker` | app | Node (`builder: node`) | `private` | 8081 | consome trabalho em segundo plano; expõe `/healthz` porque a plataforma exige HTTP |
| `web` | app | Node ou Next.js (`builder: node`) | `public` | 3000 | front; chama a `api` **no servidor** |

Para o teste ser real mesmo sem o seu código, os três serviços abaixo usam só a biblioteca padrão e respondem em `/status` dizendo, **sem imprimir segredo**, se as variáveis chegaram, se o banco é alcançável, se o volume é gravável e se um serviço enxerga o outro. Troque pelo seu código depois (Next.js com `"start": "next start"`, driver de Postgres etc.): o contrato de deploy é o mesmo.

Arquivos de `api/` (Go):

```go
// api/main.go
package main

import (
	"fmt"
	"log"
	"net"
	"net/http"
	"os"
	"time"
)

func set(k string) bool { return os.Getenv(k) != "" }

// reach tenta abrir TCP até host:port.
func reach(host, port string) bool {
	if host == "" || port == "" {
		return false
	}
	c, err := net.DialTimeout("tcp", net.JoinHostPort(host, port), 3*time.Second)
	if err != nil {
		return false
	}
	c.Close()
	return true
}

// writable diz se o app consegue gravar em dir.
func writable(dir string) bool {
	f := dir + "/.probe"
	if err := os.WriteFile(f, []byte("ok"), 0o600); err != nil {
		return false
	}
	return os.Remove(f) == nil
}

func main() {
	port := os.Getenv("PORT")
	if port == "" {
		port = "8080"
	}
	mux := http.NewServeMux()
	mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.Write([]byte("ok")) })
	mux.HandleFunc("GET /status", func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		fmt.Fprintf(w, `{"service":"api","app_env":%q,"database_url_set":%t,"db_reachable":%t,"uploads_writable":%t,"tmp_writable":%t}`,
			os.Getenv("APP_ENV"), set("DATABASE_URL"), reach(os.Getenv("PGHOST"), os.Getenv("PGPORT")),
			writable("/app/uploads"), writable("/tmp"))
	})
	log.Fatal(http.ListenAndServe("0.0.0.0:"+port, mux))
}
```

```text
# api/go.mod
module api

go 1.22
```

Arquivos de `worker/` (Node):

```json
{ "name": "worker", "version": "1.0.0", "scripts": { "start": "node worker.js" } }
```

```js
// worker/worker.js
const http = require("node:http");
const net = require("node:net");
const set = (k) => Boolean(process.env[k]);

const tcp = (host, port) =>
  new Promise((resolve) => {
    if (!host || !port) return resolve(false);
    const s = net.connect({ host, port: Number(port), timeout: 3000 });
    s.on("connect", () => { s.destroy(); resolve(true); });
    s.on("error", () => resolve(false));
    s.on("timeout", () => { s.destroy(); resolve(false); });
  });

// http1: qualquer status HTTP conta como "alcançável".
async function http1(url, path) {
  try {
    const r = await fetch(url + path, { signal: AbortSignal.timeout(3000) });
    return { ok: true, status: r.status, body: await r.json().catch(() => null) };
  } catch { return { ok: false }; }
}

async function status() {
  const api = process.env.API_URL ? await http1(process.env.API_URL, "/status") : { ok: false };
  const jobs = process.env.LIBSQL_URL ? await http1(process.env.LIBSQL_URL, "/") : { ok: false };
  return {
    service: "worker", app_env: process.env.APP_ENV || "",
    database_url_set: set("DATABASE_URL"), db_reachable: await tcp(process.env.PGHOST, process.env.PGPORT),
    libsql_set: set("LIBSQL_URL") && set("LIBSQL_AUTH_TOKEN"), libsql_reachable: jobs.ok,
    api_reachable: api.ok, api_status: api.body,
  };
}

http.createServer(async (req, res) => {
  if (req.url === "/healthz") return res.end("ok");
  if (req.url === "/status") { res.setHeader("Content-Type", "application/json"); return res.end(JSON.stringify(await status())); }
  res.statusCode = 404; res.end();
}).listen(Number(process.env.PORT || 8081), "0.0.0.0");

// laço do worker: troque pelo seu consumidor de fila.
setInterval(async () => console.log("tick", JSON.stringify({ api_reachable: (await status()).api_reachable })), 60000);
```

Arquivos de `web/` (Node; num Next.js real o `start` é `next start`):

```json
{ "name": "web", "version": "1.0.0", "scripts": { "start": "node server.js" } }
```

```js
// web/server.js
const http = require("node:http");

http.createServer(async (req, res) => {
  if (req.url === "/healthz") return res.end("ok");
  if (req.url === "/status") {
    let api = null;
    try {
      const r = await fetch(process.env.API_URL + "/status", { signal: AbortSignal.timeout(3000) });
      api = await r.json();
    } catch { /* api inalcançável: devolve null */ }
    res.setHeader("Content-Type", "application/json");
    return res.end(JSON.stringify({ service: "web", app_env: process.env.APP_ENV || "", api }));
  }
  res.end("pedidos: ok");
}).listen(Number(process.env.PORT || 3000), "0.0.0.0");
```

## Passo a passo

Os blocos abaixo formam **uma sessão de shell** (as variáveis `P`, `DB`, `API` etc. continuam entre eles). Se a sessão cair, recapture os ids com `GET /v1/projects` e `GET /v1/projects/{p}/services`. Os ids são opacos (ULID de 26 caracteres).

### 1. Preparar a sessão e o projeto

`R` é o sufixo único da execução: nome do projeto, `slug` público e prefixo das `Idempotency-Key`. Reexecutar com o **mesmo** `R` repete as respostas em vez de duplicar (24 h).

```bash
H="Authorization: Bearer $NEBULAR_TOKEN"; J="Content-Type: application/json"; B="$NEBULAR_API/v1"
R="pedidos-$(date +%s)"

# espera URL: polling com backoff até ready; falha em estado terminal ruim ou depois de ~10 min
espera() {
  local d=2 st
  for i in $(seq 1 40); do
    st=$(curl -sS "$1" -H "$H" | jq -r .status)
    case "$st" in
      ready) return 0 ;;
      failed|blocked|canceled|superseded|error) echo "terminou em $st: $1" >&2; return 1 ;;
    esac
    sleep "$d"; d=$(( d * 2 > 30 ? 30 : d * 2 ))
  done
  echo "tempo esgotado: $1" >&2; return 1
}

P=$(curl -sS -X POST "$B/projects" -H "$H" -H "$J" -H "Idempotency-Key: $R-projeto" \
  -d "{\"name\": \"$R\"}" | jq -r .id)
echo "projeto $P"
```

Erros: `403` (falta `projects:write`, ou o token é restrito a projetos), `409 project_quota_exceeded` (limite de projetos do time: pare e avise), `409 slug_taken` (outro nome).

### 2. Criar os bancos e esperar ficarem prontos

`quota_mb` é a cota de cada banco; acima dela o banco fica somente leitura. Os bancos nascem em `provisioning`: **só siga quando ambos estiverem `ready`**, porque as referências a um banco não pronto não resolvem.

```bash
DB=$(curl -sS -X POST "$B/projects/$P/databases" -H "$H" -H "$J" -H "Idempotency-Key: $R-db" \
  -d '{"name": "db", "engine": "postgres", "quota_mb": 1024}' | jq -r .id)
JOBS=$(curl -sS -X POST "$B/projects/$P/databases" -H "$H" -H "$J" -H "Idempotency-Key: $R-jobs" \
  -d '{"name": "jobs", "engine": "sqlite", "quota_mb": 256}' | jq -r .id)
espera "$B/projects/$P/databases/$DB" && espera "$B/projects/$P/databases/$JOBS"
```

Erros: `422 quota_exceeded` (limite de bancos do plano: pare e avise), `503 unavailable` (nó de banco indisponível: repita com backoff), `403` (falta `databases:write`).

### 3. Criar os serviços de aplicação

`source: {"type": "upload"}` significa que o código chega por upload (passo 7). `healthcheck_path` precisa responder 2xx ou 3xx. O `web` já existe: use `PATCH`.

```bash
API=$(curl -sS -X POST "$B/projects/$P/services" -H "$H" -H "$J" -H "Idempotency-Key: $R-api" \
  -d '{"name": "api", "kind": "app", "visibility": "private", "port": 8080,
       "source": {"type": "upload"}, "build": {"builder": "go"}, "healthcheck_path": "/healthz"}' | jq -r .id)

WORKER=$(curl -sS -X POST "$B/projects/$P/services" -H "$H" -H "$J" -H "Idempotency-Key: $R-worker" \
  -d '{"name": "worker", "kind": "app", "visibility": "private", "port": 8081,
       "source": {"type": "upload"}, "build": {"builder": "node"}, "healthcheck_path": "/healthz"}' | jq -r .id)

WEB=$(curl -sS -X PATCH "$B/projects/$P/services/web" -H "$H" -H "$J" \
  -d "{\"visibility\": \"public\", \"slug\": \"$R\", \"port\": 3000,
       \"source\": {\"type\": \"upload\"}, \"build\": {\"builder\": \"node\"}, \"healthcheck_path\": \"/healthz\"}" | jq -r .id)

# volume da api: 1 por serviço, em subpasta de /app (o resto de /app é somente leitura)
curl -sS -X PUT "$B/projects/$P/services/$API/volume" -H "$H" -H "$J" \
  -d '{"mount_path": "/app/uploads", "size_gb": 1}'
```

Campos opcionais do serviço: `start_command` e `build_command` (até 1024 caracteres, substituem o padrão), `resources` (`memory_mb`, `cpus`; padrão 512 MB e 0,5 CPU, máximo do plano), `auto_deploy` (deploy por git) e, nas fontes `git`, `root_dir` e `watch_paths`. `build.builder` aceita `auto`, `node`, `go`, `rust`, `python`, `static` e `dockerfile`.

Erros: `409 name_taken` (já existe serviço com esse nome; todo projeto traz um `web`), `409 slug_taken`, `422 quota_exceeded` (limite de serviços do plano: pare), `400 invalid_request` (campo desconhecido, p.ex. `memory_mb` solto: use `"resources": {"memory_mb": ...}`; ou campo fora das regras: `name` deve casar `^[a-z][a-z0-9-]{0,30}$`; `slug` em serviço `private` também é recusado), `400` no volume (`mount_path` proibido como `/app` inteiro, `/tmp`, `/etc`; ou `size_gb` fora de 1 a 50).

### 4. Variáveis e referências

Variáveis `shared` valem para o projeto, mas **cada serviço precisa referenciá-las**. O `PUT` em lote grava de 1 a 200 variáveis, tudo ou nada. Marque `sensitive: true` no que for segredo ou aponta para segredo; o valor deixa de voltar nas leituras.

```bash
curl -sS -X PUT "$B/projects/$P/shared-variables" -H "$H" -H "$J" -d '{"variables": [
  {"name": "APP_ENV",   "value": "production"},
  {"name": "LOG_LEVEL", "value": "info"}]}' >/dev/null

curl -sS -X PUT "$B/projects/$P/services/$API/variables" -H "$H" -H "$J" -d '{"variables": [
  {"name": "APP_ENV",      "value": "${{shared.APP_ENV}}"},
  {"name": "LOG_LEVEL",    "value": "${{shared.LOG_LEVEL}}"},
  {"name": "DATABASE_URL", "value": "${{db.DATABASE_URL}}", "sensitive": true},
  {"name": "PGHOST",       "value": "${{db.PGHOST}}"},
  {"name": "PGPORT",       "value": "${{db.PGPORT}}"}]}' >/dev/null

curl -sS -X PUT "$B/projects/$P/services/$WORKER/variables" -H "$H" -H "$J" -d '{"variables": [
  {"name": "APP_ENV",           "value": "${{shared.APP_ENV}}"},
  {"name": "DATABASE_URL",      "value": "${{db.DATABASE_URL}}", "sensitive": true},
  {"name": "PGHOST",            "value": "${{db.PGHOST}}"},
  {"name": "PGPORT",            "value": "${{db.PGPORT}}"},
  {"name": "LIBSQL_URL",        "value": "${{jobs.LIBSQL_URL}}"},
  {"name": "LIBSQL_AUTH_TOKEN", "value": "${{jobs.LIBSQL_AUTH_TOKEN}}", "sensitive": true},
  {"name": "API_URL",           "value": "${{api.PRIVATE_URL}}"}]}' >/dev/null

curl -sS -X PUT "$B/projects/$P/services/$WEB/variables" -H "$H" -H "$J" -d '{"variables": [
  {"name": "APP_ENV",  "value": "${{shared.APP_ENV}}"},
  {"name": "API_URL",  "value": "${{api.PRIVATE_URL}}"},
  {"name": "HOSTNAME", "value": "0.0.0.0"}]}' >/dev/null
```

Referências disponíveis:

| Referência | Valor |
|---|---|
| `${{api.PRIVATE_URL}}` | `http://api:8080` (nome e porta do serviço) |
| `${{api.PRIVATE_HOST}}`, `${{api.PORT}}` | `api` e `8080` |
| `${{web.PUBLIC_URL}}`, `${{web.PUBLIC_DOMAIN}}` | `https://<slug>.<domínio de apps>` e o domínio (vazios se o serviço é privado) |
| `${{db.DATABASE_URL}}`, `${{db.PGHOST}}`, `${{db.PGPORT}}`, `${{db.PGUSER}}`, `${{db.PGPASSWORD}}`, `${{db.PGDATABASE}}` | Postgres, com `sslmode=verify-full` |
| `${{mariadb.DATABASE_URL}}`, `…MYSQL_HOST/PORT/USER/PASSWORD/DATABASE` | MariaDB, com `ssl=true` |
| `${{jobs.LIBSQL_URL}}`, `${{jobs.LIBSQL_AUTH_TOKEN}}`, `${{jobs.DATABASE_URL}}` | SQLite (libSQL): `DATABASE_URL` é igual a `LIBSQL_URL` |
| `${{shared.NOME}}` | variável compartilhada do projeto |

Para escrever literalmente `${{` num valor, use `$${{`. Referências só enxergam o **próprio projeto**, aceitam aninhamento até 16 níveis e cada valor expandido tem até 64 KiB (ambiente total até 256 KiB). Mudar um valor referenciado marca os dependentes com `needs_redeploy`: faça deploy deles.

Confira o grafo: `GET /v1/projects/$P/services/$WORKER` traz `depends_on` (`["api","db","jobs"]` depois das variáveis) e `needs_redeploy`.

Erros: `403` (falta `env:write`), `400 invalid_request` (nome fora de `^[A-Za-z_][A-Za-z0-9_]{0,255}$`), `409 sensitive_lowered` (uma variável sensível nunca volta a não sensível).

### 5. Enviar o código e implantar, em ordem

Serviço `upload` só tem o que implantar depois de **receber um arquivo**, e o envio **é** o deploy do serviço (`multipart/form-data`, campo `file`, `.tar.gz` ou `.zip`, até 512 MB). Como o `worker` e o `web` referenciam a `api`, implante nesta ordem: `api`, depois `worker` e `web`. A `Idempotency-Key` abaixo inclui o hash do arquivo: reenviar o **mesmo** arquivo em 24 h repete o deploy anterior em vez de criar outro; para forçar de propósito, mude a chave.

```bash
# empacote sem lixo (sem .git, node_modules, .env)
tar --exclude=.git --exclude=node_modules --exclude=.env -czf /tmp/api.tgz    -C ./api .
tar --exclude=.git --exclude=node_modules --exclude=.env -czf /tmp/worker.tgz -C ./worker .
tar --exclude=.git --exclude=node_modules --exclude=.env -czf /tmp/web.tgz    -C ./web .

# envia SERVICO_ID ARQUIVO: dispara o deploy e imprime o id do deployment
envia() {
  curl -sS -X POST "$B/projects/$P/services/$1/deployments" -H "$H" \
    -H "Idempotency-Key: $R-up-$1-$(sha256sum "$2" | cut -c1-12)" -F "file=@$2" | jq -r .id
}

D=$(envia "$API" /tmp/api.tgz);    espera "$B/projects/$P/services/$API/deployments/$D"
D1=$(envia "$WORKER" /tmp/worker.tgz); D2=$(envia "$WEB" /tmp/web.tgz)
espera "$B/projects/$P/services/$WORKER/deployments/$D1"
espera "$B/projects/$P/services/$WEB/deployments/$D2"
```

Se `espera` falhar, **leia o motivo antes de tentar de novo** (passo 9 e [Tratamento de erros](#tratamento-de-erros)). Um deploy que termina `failed` não devolve erro HTTP.

### 6. Variante: imagens ou git, e deploy em grupo

Com fonte `image` ou `git`, não há passo de envio: o **deploy em grupo** sobe tudo na ordem certa com uma chamada. Para `image`, fixe por digest (`repo:tag@sha256:…`; veja [Imagem pública](/guia/imagem-publica/)). Para `git`, uma pessoa precisa ter instalado a integração GitHub no time e passar o `installation_id` ([Monorepo](/guia/monorepo/)).

O bloco abaixo é **executável de ponta a ponta** (cria um projeto de teste com a mesma topologia, usando imagens) e termina disparando o grupo:

```bash
SUF=$(date +%s)-$RANDOM
H="Authorization: Bearer $NEBULAR_TOKEN"; J="Content-Type: application/json"; B="$NEBULAR_API/v1"
P=$(curl -sS -X POST "$B/projects" -H "$H" -H "$J" -H "Idempotency-Key: dms-proj-$SUF" \
  -d "{\"name\": \"dms-$SUF\"}" | jq -r .id)
curl -sS -X POST "$B/projects/$P/databases" -H "$H" -H "$J" -H "Idempotency-Key: dms-db-$SUF" \
  -d '{"name": "db", "engine": "postgres", "quota_mb": 1024}' >/dev/null
curl -sS -X POST "$B/projects/$P/databases" -H "$H" -H "$J" -H "Idempotency-Key: dms-jobs-$SUF" \
  -d '{"name": "jobs", "engine": "sqlite", "quota_mb": 256}' >/dev/null
API=$(curl -sS -X POST "$B/projects/$P/services" -H "$H" -H "$J" -H "Idempotency-Key: dms-api-$SUF" \
  -d '{"name": "api", "kind": "app", "visibility": "private", "port": 8080, "healthcheck_path": "/healthz",
       "source": {"type": "image", "ref": "ghcr.io/org/api:1.0"}}' | jq -r .id)
WORKER=$(curl -sS -X POST "$B/projects/$P/services" -H "$H" -H "$J" -H "Idempotency-Key: dms-worker-$SUF" \
  -d '{"name": "worker", "kind": "app", "visibility": "private", "port": 8081, "healthcheck_path": "/healthz",
       "source": {"type": "image", "ref": "ghcr.io/org/worker:1.0"}}' | jq -r .id)
WEB=$(curl -sS -X PATCH "$B/projects/$P/services/web" -H "$H" -H "$J" \
  -d "{\"visibility\": \"public\", \"slug\": \"dms-$SUF\", \"port\": 3000,
       \"source\": {\"type\": \"image\", \"ref\": \"ghcr.io/org/web:1.0\"}}" | jq -r .id)
curl -sS -X PUT "$B/projects/$P/services/$API/variables" -H "$H" -H "$J" -d '{"variables": [
  {"name": "DATABASE_URL", "value": "${{db.DATABASE_URL}}", "sensitive": true}]}' >/dev/null
curl -sS -X PUT "$B/projects/$P/services/$WORKER/variables" -H "$H" -H "$J" -d '{"variables": [
  {"name": "DATABASE_URL", "value": "${{db.DATABASE_URL}}", "sensitive": true},
  {"name": "LIBSQL_URL", "value": "${{jobs.LIBSQL_URL}}"},
  {"name": "API_URL", "value": "${{api.PRIVATE_URL}}"}]}' >/dev/null
curl -sS -X PUT "$B/projects/$P/services/$WEB/variables/API_URL" -H "$H" -H "$J" \
  -d '{"value": "${{api.PRIVATE_URL}}"}' >/dev/null

curl -sS -X POST "$B/projects/$P/deploy" -H "$H" -H "$J" -H "Idempotency-Key: dms-grp-$SUF" -d '{}'
```
```json
{"id": "01j9zk0dst6zttk3bamcgshaxf", "project_id": "01j9zkk07fhzv5zsmn996cdwpb", "status": "running", "items": [{"service_id": "01j9zkff8y1dmcwa49fanx9qak", "deployment_id": "01j9zkxg2h4dbcfe129fwypfsb", "status": "queued", "layer": 0}]}
```

Como o grupo se comporta (do contrato da API e do orquestrador):

- **Ordem em camadas.** A camada N só começa quando a N-1 está `ready`. Dentro de uma camada, até 4 serviços em paralelo. Cada item traz `layer`.
- **Falha propaga.** Se um serviço falha, os que dependem dele ficam `blocked`; os que já estavam no ar continuam.
- **Serviço sem o que implantar** (por exemplo `upload` que nunca recebeu arquivo) vira item `skipped` com `reason`, e o grupo segue. Se **tudo** é `skipped`, o grupo termina `ready` sem implantar nada. Citar o serviço em `services` não o pula: sem fonte, o deploy dele falha.
- **Subconjunto.** `{"services": ["api", "web"]}` (ids ou nomes, até 100) implanta só esses; dependências fora da seleção são assumidas no ar.
- **Grupo novo substitui o grupo em andamento.** Um segundo `POST …/deploy` no mesmo projeto enquanto outro roda **cancela** o anterior (`superseded`; o que estava construindo termina `canceled`). Não dispare dois seguidos: espere o primeiro ou cancele.
- **Grupo com `upload`** reimplanta o **último arquivo enviado** de cada serviço. Serve para propagar mudança de variável; **código novo** exige enviar o arquivo de novo (passo 5).
- Nada é criado se houver referência inexistente ou ciclo: o `POST` responde `422` antes (`unresolved_reference`, `reference_cycle`, com `details.references`).
- Cada serviço do grupo consome 1 da cota `deploys_per_hour` do time.

### 7. Acompanhar o grupo

Um único `GET` mostra o grupo e cada serviço. `G` é o `id` devolvido pelo `POST …/deploy`. Não existe listagem de grupos: guarde o id.

```bash
G=$(curl -sS -X POST "$B/projects/$P/deploy" -H "$H" -H "$J" -H "Idempotency-Key: $R-grupo-1" -d '{}' | jq -r .id)

d=2
for i in $(seq 1 40); do
  S=$(curl -sS "$B/projects/$P/group-deployments/$G" -H "$H")
  echo "$S" | jq -r '"grupo \(.status): " + ([.items[] | "\(.service_id[-6:])=\(.status)"] | join(" "))'
  case "$(echo "$S" | jq -r .status)" in
    ready) break ;;
    failed|canceled|superseded) echo "$S" | jq '{status, error, items}' >&2; exit 1 ;;
  esac
  sleep "$d"; d=$(( d * 2 > 30 ? 30 : d * 2 ))
done
```

Estados do grupo: `running`, `ready`, `failed`, `canceled`, `superseded`. Estados dos itens: `queued`, `building`, `deploying`, `ready`, `failed`, `blocked`, `canceled`, `skipped`. Item `failed`: pegue o `deployment_id` e leia o log (passo 9). Para **cancelar** um grupo em andamento: `POST /v1/projects/{p}/group-deployments/{g}/cancel` (escopo `deploy`; grupo já terminado volta inalterado).

### 8. Domínios

Todo serviço `public` já tem `https://<slug>.<domínio de apps>` (campo `public_url`). Serviço `private` não aceita domínio (`400`). Para um domínio próprio, o agent cria o registro e a **pessoa** configura o DNS:

```bash
curl -sS -X POST "$B/projects/$P/services/$WEB/domains" -H "$H" -H "$J" -H "Idempotency-Key: $R-dominio" \
  -d '{"hostname": "pedidos.exemplo.com.br"}' | jq '{status, dns, verification}'
# a pessoa cria o CNAME/A e o TXT _nebular-verify.<host> no DNS (Cloudflare: "DNS only", nuvem cinza)
# depois: POST .../domains/{id}/verify (sem corpo) e polling em GET .../domains/{id} até status=active
```

Erros: `409 domain_taken`, `403` (falta `domains:write`), `422 rate_limited` no `verify` (espere `details.retry_after_seconds`). Detalhes: [Domínio próprio](/dominios/dominio-proprio/).

### 9. Verificar

```bash
URL=$(curl -sS "$B/projects/$P/services/$WEB" -H "$H" | jq -r .public_url)
curl -sS "$URL/status" | jq .
# esperado: api.db_reachable=true, api.uploads_writable=true, api.database_url_set=true
curl -sS "$B/projects/$P/services/$WORKER" -H "$H" | jq '{status, needs_redeploy, depends_on}'
```

Leia o que cada serviço escreveu em runtime (`logs:read`; valores sensíveis são mascarados):

```bash
curl -sS -G "$B/projects/$P/services/$API/logs" -H "$H" --data-urlencode "since=2026-10-11T00:00:00Z" | jq -r .text
curl -sS "$B/projects/$P/services/$API/deployments/$D/logs" -H "$H" | jq -r .text   # log de build
```

`GET /v1/projects/{p}/services/{s}` devolve `status` (`idle`, `deploying`, `running`, `failed`) e `needs_redeploy`. `worker` com `api_reachable=false` no `/status` quase sempre é a porta errada em `api` (`port` do serviço diferente da porta em que o processo escuta).

### 10. Nova versão e rollback

- **Código novo de um serviço:** envie o arquivo de novo (passo 5), sempre na ordem das dependências. Um deploy de serviço enquanto outro do **mesmo** serviço roda responde `409 deploy_in_progress`.
- **Só variável mudou:** `needs_redeploy` lista quem está defasado; dispare o grupo (`services` com os afetados) ou o deploy de cada um.
- **Rollback é por serviço, não existe rollback de grupo.** Liste os deploys, pegue um `ready` anterior e reimplante-o **com as variáveis daquela versão**:

```bash
curl -sS "$B/projects/$P/services/$API/deployments" -H "$H" | jq -r '.items[] | "\(.id)  \(.status)  \(.created_at)"'
curl -sS -X POST "$B/projects/$P/services/$API/deployments/$ANTERIOR/rollback" -H "$H" -H "$J" \
  -H "Idempotency-Key: $R-rb-$ANTERIOR" -d '{}'
```

Se a versão problemática mudou o contrato entre serviços, faça rollback também dos dependentes, na ordem inversa (`web` e `worker`, depois `api`). Rollback **não desfaz dados** nem migrations: veja [Migrações](/bancos/migracoes/). Erros: `409 nothing_to_rollback` (o destino não está `ready`), `409 deploy_in_progress`, `429 rate_limited` (conta na cota por hora).

## Tratamento de erros

Formato fixo: `{"error": "<codigo>", "message": "..."}`; decida pelo `error`. Tabela completa em [Tabela de erros](/erros/tabela/).

| Situação | Código | O que fazer |
|---|---|---|
| Token ausente, expirado ou revogado | `401 unauthorized` | **Pare.** Peça token novo a uma pessoa. |
| Falta escopo, ou token restrito a projetos criando projeto | `403 forbidden` | **Pare.** Diga qual escopo falta (tabela dos pré-requisitos). |
| Recurso inexistente ou de outro time | `404 not_found` | Confira o id listando com `GET`. Não varie ids. |
| Nome ou slug repetido | `409 name_taken`, `409 slug_taken` | Escolha outro nome. |
| Limite de projetos do time | `409 project_quota_exceeded` | **Pare** e avise a pessoa. |
| Limite de serviços, bancos ou volume do plano | `422 quota_exceeded` | **Pare** e avise; consulte `GET /v1/limits`. |
| Referência para algo que não existe / ciclo | `422 unresolved_reference` / `reference_cycle` (`details.references`) | Corrija as variáveis e dispare de novo. Nada foi criado. Com banco ainda `provisioning` a resolução também falha (o erro exato pode variar): espere `ready` antes do deploy. |
| Valor expandido grande demais, referência profunda, sintaxe inválida | `422 value_too_large` / `reference_too_deep` / `invalid_reference` | Reduza ou corrija. |
| Já há deploy do serviço rodando | `409 deploy_in_progress` | Acompanhe o deploy em curso; não dispare outro. |
| Mesma `Idempotency-Key` com outro corpo | `422 idempotency_conflict` | Gere chave nova. |
| Corpo com `Content-Type` errado | `415 unsupported_media_type` | JSON com `application/json`; upload com `multipart/form-data`. |
| Rate limit, ou `deploys_per_hour` do plano | `429 rate_limited` | Espere `Retry-After` segundos e repita. |
| Armazenamento de build indisponível | `503 unavailable` com `details.error_code` = `build_storage_unavailable` (ou `build_disk_full`) e `details.deployment_id` | Um deploy `failed` ficou no histórico. Repita em alguns minutos; persistindo, **pare** e avise. |
| Banco ou nó indisponível | `503 unavailable` | Repita com backoff (5, 10, 20, 40 s). |

### Quando o deploy termina `failed` (sem erro HTTP)

Leia `GET …/deployments/{d}`: `error.code` e `error.message`. Depois o log de build (`…/deployments/{d}/logs`) e o de runtime (`…/services/{s}/logs`).

| `error.code` | Causa | Correção |
|---|---|---|
| `activate_readonly_write` | o app tentou gravar sob `/app` (somente leitura) | Grave em `/tmp` ou monte um volume na subpasta (`PUT …/volume` com `mount_path: "/app/<pasta>"`) e redeploy. |
| `activate_no_http` | o processo roda mas não responde HTTP na `port` do serviço | Escute em `0.0.0.0:$PORT`; worker também precisa de um `/healthz`; confira `port` e `healthcheck_path`. |
| `activate_app_crashed`, `crashed_after_start` | o app encerra logo ao iniciar | Leia a primeira linha de erro na `message` e no log de runtime (variável faltando, banco recusando conexão). |
| `image_requires_root` | a imagem exige root | Declare `build.user` (`uid[:gid]`, nunca root) no serviço ou troque a imagem. |
| `unresolved_reference`, `reference_cycle` | a referência só falhou no build | Corrija as variáveis. |
| `build_oom`, `build_timeout` | build sem memória ou lento demais | Reduza o build ou aumente `resources`. |
| `build_storage_error`, `build_disk_full`, `build_storage_unavailable` | problema de armazenamento da plataforma, não do seu código | Repita em alguns minutos; persistindo, avise a pessoa. |
| `team_app_limit` | o time já roda o máximo de apps do plano (`message`: "limite de apps em execução do time atingido (N de M)...") | **Pare**: peça à pessoa para parar/remover um app ou ampliar o limite. Repetir não adianta. |
| `deploy_failed` (genérico) | outra falha | Leia a `message`. |

Regra de ouro: máximo de 3 tentativas por serviço, **mudando algo** a cada uma. Repetir o mesmo envio sem corrigir a causa só gasta a cota por hora.

## Idempotência

Todo `POST` que cria ou dispara aceita `Idempotency-Key` (8 a 128 caracteres de `[A-Za-z0-9._:-]`). Mesma chave e mesmo pedido em 24 h devolvem a mesma resposta com `Idempotent-Replay: true`; mesma chave com pedido diferente dá `422 idempotency_conflict`; chave malformada dá `400`. Só respostas de sucesso são gravadas, então repetir após uma falha executa de verdade. Padrão recomendado: `"$R-<recurso>"` com um `R` único por execução do plano, para que reexecutar o script inteiro após uma queda não duplique serviços nem deploys. `PUT` (variáveis, volume) e `PATCH` já são naturalmente repetíveis.

## Boas práticas

- **Segredos.** Nunca imprima, registre ou cole valores de variáveis sensíveis, credenciais de banco ou o token. Entregue segredo ao app **por variável** (de preferência por referência, como `${{db.DATABASE_URL}}`), nunca no código, na imagem ou em `NEXT_PUBLIC_*` (que vai para o bundle do browser). Marque `sensitive: true`.
- **Credenciais só com a pessoa.** `POST …/databases/{s}/credentials` e `env:reveal` exigem confirmação de identidade (step-up). Não os peça nem contorne: o app não precisa deles, a plataforma injeta as variáveis no deploy.
- **Disco.** O sistema de arquivos do app é somente leitura. Temporários em `/tmp` (64 MB, em memória, some a cada restart). Dados que precisam sobreviver: volume em subpasta de `/app` (`/app/uploads`, `/app/data`), 1 por serviço, 1 a 50 GB, que **sobrevive a redeploy**. `/app` inteiro, `/app/.next` e `/app/.next/cache` não podem ser volume (o cache do Next já é um tmpfs da plataforma). Banco de dados de verdade vai em banco gerenciado, não em arquivo no volume.
- **Rede.** Fale com outro serviço pelo nome (`${{api.PRIVATE_URL}}`). No Next.js, chame a `api` no servidor (route handler ou server component), nunca do browser. Deixe `private` tudo o que não precisa de URL pública.
- **Saúde.** Todo serviço, inclusive worker, escuta em `0.0.0.0:$PORT` e responde 2xx/3xx em `healthcheck_path`. Sem isso o deploy termina `failed`.
- **Agendamento.** Não há cron para serviços do time. Rode o agendador dentro do worker (`setInterval`, biblioteca de cron) ou chame um endpoint público de um agendador externo.
- **Ordem.** Bancos `ready`, depois `api`, depois quem depende dela. Deploy em grupo cuida da ordem entre apps; bancos você espera com `GET`.
- **Cota.** Antes de começar leia `GET /v1/limits`; cada deploy de cada serviço, rollback e grupo conta em `deploys_per_hour`.
- **Destrutivo.** Apagar projeto, serviço, volume, banco ou domínio só com ordem explícita da pessoa. Ordem de remoção: volume antes do serviço (`409 volume_attached`); banco com backup final por padrão (`final_backup: true`); apagar projeto remove tudo e é irreversível. Com token, o escopo é a única barreira: a confirmação tem de vir da pessoa.
- **Limpeza de testes.** Se criou um projeto só para teste, apague-o ao final (com autorização), para não consumir a cota do time.

## O que não é suportado

- **Manifesto declarativo.** Não há arquivo único que descreva a aplicação.
- **Réplicas e escala horizontal.** Um container por serviço; sem autoscale, sem balanceador entre instâncias, sem `replicas`.
- **Zero downtime não é garantia de réplica:** a troca de versão sobe o container novo, move o nome privado e aposenta o antigo depois de um período de drenagem, mas há uma única instância servindo.
- **Tipos `worker` e `cron`.** Só `app` e `database`. Sem agendador de tarefas para serviços do time (o agendador interno existe apenas dentro de templates da plataforma).
- **Templates do time.** O catálogo (`GET /v1/templates`) é da plataforma, embutido no servidor, hoje só `wordpress`; aceita só imagens fixadas por digest. Não é possível publicar um template multi-serviço próprio.
- **Rollback de grupo**, **dry-run/plan** do deploy, **listagem de deploys de grupo** e **webhooks de deploy**: não existem. Acompanhe por polling.
- **Mais de um volume por serviço**, volume compartilhado entre serviços, ou mais de uma porta por serviço.
- **Serviço sem HTTP.** Todo serviço de aplicação é validado por HTTP na porta declarada.
- **Banco acessível de fora da plataforma** (notebook, outro provedor): só os serviços do projeto conseguem conectar.
- **E-mail por SMTP direto** (bloqueado; use um provedor por API HTTPS).
- **Comunicação entre projetos.** Cada projeto é uma rede isolada.

## Manifesto declarativo: não existe ainda

Hoje o "manifesto" é o roteiro de chamadas acima. Se o time precisar repetir a topologia (por exemplo, criar um ambiente de teste igual ao de produção), guarde o **script do passo a passo** no repositório do app e reexecute-o com outro `R`. Este guia não promete data nem formato para um arquivo declarativo.

## Ver também

[Front, back e banco](/conectados/front-back-db/), [Variáveis e referências](/conectados/variaveis-e-referencias/), [Rede privada](/conectados/rede-privada/), [Volumes](/conectados/volumes/), [Deploys e status](/operacao/deploys-e-status/), [Rollback](/operacao/rollback/), [Limites e cotas](/operacao/limites-e-cotas/), [Escala e recursos](/operacao/escala-e-recursos/), [Tabela de erros](/erros/tabela/), [Monorepo](/guia/monorepo/).
