Pular para o conteúdo

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.

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.
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.
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.
  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). 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).

Janela do terminal
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"
{"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.

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).
  • 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.
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):

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))
}
api/go.mod
module api
go 1.22

Arquivos de worker/ (Node):

{ "name": "worker", "version": "1.0.0", "scripts": { "start": "node worker.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):

{ "name": "web", "version": "1.0.0", "scripts": { "start": "node server.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");

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).

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).

Janela do terminal
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).

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.

Janela do terminal
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).

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.

Janela do terminal
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).

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.

Janela do terminal
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).

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.

Janela do terminal
# 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). Um deploy que termina failed não devolve erro HTTP.

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). Para git, uma pessoa precisa ter instalado a integração GitHub no time e passar o installation_id (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:

Janela do terminal
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 '{}'
{"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.

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.

Janela do terminal
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).

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:

Janela do terminal
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.

Janela do terminal
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):

Janela do terminal
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).

  • 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:
Janela do terminal
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. Erros: 409 nothing_to_rollback (o destino não está ready), 409 deploy_in_progress, 429 rate_limited (conta na cota por hora).

Formato fixo: {"error": "<codigo>", "message": "..."}; decida pelo error. Tabela completa em Tabela de erros.

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).

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.

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.

  • 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.
  • 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.

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.

Front, back e banco, Variáveis e referências, Rede privada, Volumes, Deploys e status, Rollback, Limites e cotas, Escala e recursos, Tabela de erros, Monorepo.