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.
Resumo para LLM
Seção intitulada “Resumo para LLM”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
Seção intitulada “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. |
| 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
Seção intitulada “Pré-requisitos”-
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:readcriar projeto e serviços, mudar recursos projects:writecriar bancos databases:writevariáveis e referências env:write(eenv:readpara listar)volume volumes:writedeploy, grupo, rollback, enviar código deploydomínio próprio domains:writelogs de build e de runtime logs:readO token não pode ser restrito a projetos específicos se você vai criar o projeto: token restrito responde
403emPOST /v1/projects. Não peçaenv: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. -
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). -
Variáveis de ambiente:
NEBULAR_API=https://api.nebularws.comeNEBULAR_TOKEN(segredo do ambiente; nunca no prompt, em log ou em arquivo versionado).
H="Authorization: Bearer $NEBULAR_TOKEN"curl -sS "$NEBULAR_API/v1/me" -H "$H" | jq -e '.token.scopes | index("deploy") != null' >/dev/nullcurl -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.
Como as peças se encaixam
Seção intitulada “Como as peças se encaixam”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 emhttp://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.
visibilityomitida =private. Só o que precisa de URL pública virapublic(e então exigeslug). Todo projeto novo já nasce com um serviçowebpúblico; adapte-o comPATCHem vez de criar outro. - Ordem do deploy em grupo. Vem das referências: quem é referenciado sobe antes. Neste exemplo
apisobe primeiro;workereweb(que referenciam aapi) 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”
Seção intitulada “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):
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))}module api
go 1.22Arquivos de worker/ (Node):
{ "name": "worker", "version": "1.0.0", "scripts": { "start": "node 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" } }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
Seção intitulada “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
Seção intitulada “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).
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 minespera() { 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
Seção intitulada “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.
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
Seção intitulada “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.
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
Seção intitulada “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.
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/nullReferê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
Seção intitulada “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.
# 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 deploymentenvia() { 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.
6. Variante: imagens ou git, e deploy em grupo
Seção intitulada “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). 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:
SUF=$(date +%s)-$RANDOMH="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/nullcurl -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/nullAPI=$(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/nullcurl -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/nullcurl -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 trazlayer. - 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
uploadque nunca recebeu arquivo) vira itemskippedcomreason, e o grupo segue. Se tudo éskipped, o grupo terminareadysem implantar nada. Citar o serviço emservicesnã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 …/deployno mesmo projeto enquanto outro roda cancela o anterior (superseded; o que estava construindo terminacanceled). Não dispare dois seguidos: espere o primeiro ou cancele. - Grupo com
uploadreimplanta 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
POSTresponde422antes (unresolved_reference,reference_cycle, comdetails.references). - Cada serviço do grupo consome 1 da cota
deploys_per_hourdo time.
7. Acompanhar o grupo
Seção intitulada “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.
G=$(curl -sS -X POST "$B/projects/$P/deploy" -H "$H" -H "$J" -H "Idempotency-Key: $R-grupo-1" -d '{}' | jq -r .id)
d=2for 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 ))doneEstados 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
Seção intitulada “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:
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=activeErros: 409 domain_taken, 403 (falta domains:write), 422 rate_limited no verify (espere details.retry_after_seconds). Detalhes: Domínio próprio.
9. Verificar
Seção intitulada “9. Verificar”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=truecurl -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):
curl -sS -G "$B/projects/$P/services/$API/logs" -H "$H" --data-urlencode "since=2026-10-11T00:00:00Z" | jq -r .textcurl -sS "$B/projects/$P/services/$API/deployments/$D/logs" -H "$H" | jq -r .text # log de buildGET /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
Seção intitulada “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_redeploylista quem está defasado; dispare o grupo (servicescom os afetados) ou o deploy de cada um. - Rollback é por serviço, não existe rollback de grupo. Liste os deploys, pegue um
readyanterior e reimplante-o com as variáveis daquela versão:
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).
Tratamento de erros
Seção intitulada “Tratamento de erros”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). |
Quando o deploy termina failed (sem erro HTTP)
Seção intitulada “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
Seção intitulada “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
Seção intitulada “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 emNEXT_PUBLIC_*(que vai para o bundle do browser). Marquesensitive: true. - Credenciais só com a pessoa.
POST …/databases/{s}/credentialseenv:revealexigem 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./appinteiro,/app/.nexte/app/.next/cachenã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 aapino servidor (route handler ou server component), nunca do browser. Deixeprivatetudo o que não precisa de URL pública. - Saúde. Todo serviço, inclusive worker, escuta em
0.0.0.0:$PORTe responde 2xx/3xx emhealthcheck_path. Sem isso o deploy terminafailed. - 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, depoisapi, depois quem depende dela. Deploy em grupo cuida da ordem entre apps; bancos você espera comGET. - Cota. Antes de começar leia
GET /v1/limits; cada deploy de cada serviço, rollback e grupo conta emdeploys_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
Seção intitulada “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
workerecron. Sóappedatabase. 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
Seção intitulada “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
Seção intitulada “Ver também”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.