Wizard CLI — passo a passo por perfil de repo

O wizard do runner detecta o perfil do seu repositório e gera o .deploy.yml proporcional ao que ele encontra. Este guia mostra cada perfil em ação, com input do repo, prompts respondidos e resultado final.

Tutoriais validados via matriz CI/CD tests/cicd-matrix/ (72 cells, 0 fail). Toda saída abaixo é real — copiada de execuções da matriz contra os branches devborlot/ccs-deploy-test/dist-*.

Perfis cobertos

ID Perfil Quando Esperado
A Repo já tem .deploy.yml App pronta pra deploy Wizard refuse, runner usa o yml direto
B Dockerfile (com EXPOSE) App nova com Docker Generator emite yml completo zero-touch
C docker-compose.yml Migrando de docker-compose Generator converte; warning de port mapping
D composer.json + Dockerfile + .env.example (Laravel) Apps PHP/Laravel Generator detecta secrets + production profile
E README.md (bare) Repo sem nada Wizard falha com instrução
F Image-only (registry) Imagem pronta de registry runner wizard --answers com type: image
G Dockerfile SEM EXPOSE Dockerfile minimalista Wizard avisa; usuário override port:
H Image + IP-restricted (127.0.0.1:8081:80) Serviço interno (VPC/loopback) .deploy.yml com ports: + bind validado
I .env.example com valores raw em chaves de secret Setup desleixado Wizard auto-classifica em secrets:

Perfil A — repo já tem `.deploy.yml`

Conteúdo do repo

ccs-deploy-test/dist/
├── .deploy.yml      ← já existe
├── Caddyfile
├── index.html
└── package.json

Comando

runner add --repo devborlot/ccs-deploy-test \
  --branch dist \
  --ckey "minha-ckey-32-chars-secure-random" \
  --instance production

O que acontece

[generator] Profile A: já tem .deploy.yml — usando direto, não gera nada.
[ADD] ccs-deploy-test/sys registered at /opt/runner/apps/ccs-deploy-test

Se você rodar o wizard standalone:

runner wizard --path /tmp/seu-repo
# ↓
Error: manifest_already_exists: "/tmp/seu-repo/.deploy.yml" contém .deploy.yml.
       O wizard não sobrescreve manifestos existentes (perfil A usa direto).
       Para regenerar do zero: remova o arquivo manualmente e rode novamente.

Quando usar

  • App estável que já passou pelo wizard antes
  • Multi-team: .deploy.yml é a fonte de verdade no repo

Perfil B — só Dockerfile

Conteúdo do repo

meu-app/
├── Dockerfile         ← FROM nginx:1, EXPOSE 8080
├── index.html
└── .env.example       ← LOG_LEVEL=info, DB_HOST=mysql

Comando (zero-touch)

runner add --repo user/meu-app \
  --branch main \
  --ckey "..." \
  --instance production

O que o generator faz

[generator] Profile B detected. Generating .deploy.yml from inputs: Dockerfile, .env.example
[generator] Source: repo tem Dockerfile + docker-compose.yml. Build do Dockerfile
            foi usado como source-of-truth (compose só pra env vars).
            Para usar a imagem do compose em vez de buildar, edite type: image + image:
[--branch] propagated to instance 'production': Some("main") -> main

`.deploy.yml` gerado

project: meu-app
system: api
build:
  context: .
  dockerfile: Dockerfile
port: 8080
networks:
  - public
environment:
  DB_HOST: "mysql"
  LOG_LEVEL: "info"

healthcheck:
  mode: http       # ← W2: nginx detectado → HTTP
  path: /
  port: 8080
  interval: 30s
  timeout: 10s
  retries: 3

version:
  source: git-commit  # ← W3 default: deploys auto-versionados via SHA

instances:
  production:
    domain: meu-app.example.com
    source:
      type: branch
      branch: main    # ← W2: --branch propagado
    keep_versions: 3

Próximos passos

runner deploy meu-app    # build + run + healthcheck

Perfil C — só docker-compose

Conteúdo do repo

meu-app/
└── docker-compose.yml
    services:
      web:
        image: nginx:1
        ports: ["8080:8080"]
        environment: { LOG_LEVEL: info }
      postgres:
        image: postgres:16

Comando

runner add --repo user/meu-app --branch main --ckey "..." --instance production

O que acontece

[generator] Profile D: docker-compose só detected. Generating .deploy.yml from inputs: docker-compose.yml
[generator] Profile D: usando service 'web' do compose. Confira .deploy.yml gerado antes do deploy.
[generator] Port: usando porta 8080 do mapping no compose.
            ATENÇÃO: este é mapping host:container — se o container internamente
            escuta em outra porta (nginx :80, php-fpm :9000), ajuste port: no .deploy.yml.

`.deploy.yml` gerado

project: meu-app
system: api
image: nginx:1
port: 8080
networks: [public]
environment:
  LOG_LEVEL: "info"

healthcheck:
  mode: http       # ← nginx → HTTP
  path: /
  port: 8080
  ...

instances:
  production:
    domain: meu-app.example.com
    source: { type: branch, branch: main }
    keep_versions: 3

Ação necessária do operador

O warning W1 te avisou: nginx:1 na verdade escuta na porta 80 internamente, não 8080. Edite manualmente:

sed -i 's/port: 8080/port: 80/' /opt/runner/apps/meu-app/.deploy.yml
sed -i 's/port: 8080$/port: 80/' /opt/runner/apps/meu-app/.deploy.yml  # healthcheck

Ou refaça o runner add com o port correto via answers (perfil F abaixo).

Por que não auto-detectar? Análise estática não consegue ver dentro do container. W6 (smoke-probe rodando o container brevemente) está no backlog.


Perfil D — Laravel (composer + Dockerfile + .env.example)

Conteúdo do repo

meu-laravel/
├── composer.json    ← laravel/framework
├── Dockerfile       ← FROM php:8.2-fpm, EXPOSE 9000
└── .env.example     ← APP_KEY=, DB_PASSWORD=, JWT_SECRET=, MAIL_*, AWS_*, ...

Comando recomendado (com answers para preset Laravel)

cat > /tmp/laravel.json <<'EOF'
{
  "system": "sys",
  "type": "docker-build",
  "skip_env": ["AWS_*", "MAIL_*", "MEMCACHED_*"],
  "secret_keys": ["APP_KEY", "DB_PASSWORD", "JWT_SECRET", "REDIS_PASSWORD"]
}
EOF

runner wizard --path /caminho/meu-laravel --answers /tmp/laravel.json --ckey "..."

O que o wizard faz

  1. Detecta Laravel via composer.jsonsystem: sys, type: docker-build
  2. .env.example (31 vars típicas)
  3. Aplica production profile: APP_DEBUG=true → false, APP_ENV=local → production, LOG_LEVEL=debug → info
  4. Prune AWS_*/MAIL_*/MEMCACHED_* placeholders vazios
  5. Auto-classifica APP_KEY, DB_PASSWORD, JWT_SECRET, REDIS_PASSWORD em secrets:
  6. Encripta os secrets em .runner/secrets.enc com a --ckey
  7. W2 detectou php-fpmmode: tcp tcp: 9000

`.deploy.yml` gerado

project: meu-laravel
system: sys
type: docker-build
build: { context: ., dockerfile: Dockerfile }
port: 9000

healthcheck:
  mode: tcp        # ← W2: php-fpm → TCP (FastCGI, não HTTP)
  tcp: 9000
  interval: 30s
  ...

instances:
  production:
    domain: meu-laravel.example.com
    source: { type: branch, branch: main }
    keep_versions: 3

environment:
  APP_DEBUG: false
  APP_ENV: production
  APP_NAME: Laravel
  DB_CONNECTION: mysql
  DB_HOST: 127.0.0.1
  DB_DATABASE: laravel
  LOG_LEVEL: info     # ← Production profile: era debug
  ...

secrets:
  APP_KEY: "{{::APP_KEY}}"        # ← prompts interativos no deploy
  DB_PASSWORD: "{{::DB_PASSWORD}}"
  JWT_SECRET: "{{::JWT_SECRET}}"
  REDIS_PASSWORD: "{{::REDIS_PASSWORD}}"

Perfil E — repo bare (só README)

Conteúdo do repo

meu-projeto/
└── README.md

Comando

runner add --repo user/meu-projeto --branch main --ckey "..."

O que acontece

Error: no_deploy_profile: repo sem perfil de deploy detectado.
       Adicione um Dockerfile ou .deploy.yml no repo.
       Documentação: https://docs.runner.ccs.systems/getting-started

Em modo TTY, o runner oferece executar o runner wizard interativamente para você criar .deploy.yml na hora.

Ação

  1. Crie um Dockerfile mínimo no repo:

    FROM nginx:1
    EXPOSE 80
    COPY index.html /usr/share/nginx/html/
  2. Re-rode runner add — agora cai no perfil B/C.


Perfil F — image-only (deploy de imagem do registry)

Quando usar

  • App distribuída como imagem pronta (Hub Docker, GHCR, ECR)
  • Sem código no repo, só configuração de deploy

Setup

Crie um repo com .deploy.yml:

project: minha-imagem
system: sys
type: image
image: ghcr.io/myorg/myapp:v1.2.3
port: 8080

healthcheck:
  mode: http
  path: /health    # app precisa implementar este endpoint; nginx default seria '/'
  interval: 30s

version:
  source: git-commit

instances:
  production:
    domain: myapp.com.br
    source:
      type: image
      image: ghcr.io/myorg/myapp:v1.2.3
    keep_versions: 2

Comando

runner add --repo user/minha-imagem-config --branch main --ckey "..." --instance production
runner deploy minha-imagem

Resultado

Runner pulla ghcr.io/myorg/myapp:v1.2.3 do registry e roda — sem docker build.


Perfil G — Dockerfile sem EXPOSE

Conteúdo do repo

FROM nginx:1
COPY index.html /usr/share/nginx/html/
# (sem EXPOSE)

Comando

runner add --repo user/meu-app --branch main --ckey "..." --instance production

O que acontece

[generator] Profile C: Dockerfile só detected.
[generator] Profile C: Dockerfile sem env detectado. Adicione environment: ...
[generator] Port: Dockerfile sem EXPOSE; assumindo 8080.
            Se o container escuta em outra porta, ajuste port: no .deploy.yml
            antes do deploy (ex: nginx default 80, php-fpm 9000).

Ação obrigatória

O warning W1 te diz exatamente o que precisa. Para nginx que escuta em 80:

sed -i 's/port: 8080/port: 80/' /opt/runner/apps/meu-app/.deploy.yml
runner deploy meu-app

Próxima versão (W6 backlog): smoke-probe vai detectar a porta real automaticamente, eliminando esse passo manual.


Perfil H — image + porta restrita ao IP interno

Quando usar

  • Serviço interno que só pode ser acessado da VPC
  • Banco/cache exposto só pra apps no mesmo host (loopback)
  • Bind explícito a 127.0.0.1 para que Traefik (no host) seja a única ponte pública

Setup

Em vez de wizard, crie .deploy.yml direto:

project: meu-cache
system: sys
type: image
image: redis:7
port: 6379
ports:
  - "127.0.0.1:6379:6379"   # ← bind apenas ao loopback do host

healthcheck:
  mode: tcp
  tcp: 6379

version: { source: git-commit }

instances:
  production:
    domain: cache-internal.local       # ← não expõe via Traefik público
    source: { type: image, image: redis:7 }
    keep_versions: 1

Ou via answers:

{
  "type": "image",
  "image": "redis:7",
  "port": 6379,
  "instance_domain": "cache-internal.local",
  "healthcheck_mode": "tcp"
}

Validação

docker inspect <container> --format '{{json .HostConfig.PortBindings}}'
# → {"6379/tcp":[{"HostIp":"127.0.0.1","HostPort":"6379"}]}

Apenas processos no host conseguem acessar; o mundo externo não.


Perfil I — `.env.example` com secrets em valores raw

Conteúdo do repo (anti-pattern comum)

# .env.example
APP_NAME=meuapp
LOG_LEVEL=info
DB_PASSWORD=actual-real-secret-leak           # ← MAL!
JWT_SECRET=ey...token-pretendendo-ser-real    # ← MAL!
API_KEY=ak_live_NotReallyAStripeKey           # ← MAL!

Comando

runner add --repo user/meu-app --branch main --ckey "..."

O que o wizard faz (auto-detection)

looks_like_secret() casa por padrão de nome (*PASSWORD*, *SECRET*, *KEY*, *TOKEN*...). Esses 3 vars saem automaticamente do bloco environment: e vão pra secrets::

environment:
  APP_NAME: "meuapp"        # ← não-sensível
  LOG_LEVEL: "info"         # ← não-sensível

secrets:
  DB_PASSWORD: "{{::DB_PASSWORD}}"  # ← prompt no deploy
  JWT_SECRET: "{{::JWT_SECRET}}"    # ← prompt no deploy
  API_KEY: "{{::API_KEY}}"          # ← prompt no deploy

E os valores literais ficam em .runner/secrets.enc (encriptados com a --ckey).

Validação

grep -E "DB_PASSWORD|JWT_SECRET|API_KEY" /opt/runner/apps/meu-app/.deploy.yml
# →   DB_PASSWORD: "{{::DB_PASSWORD}}"
# →   JWT_SECRET: "{{::JWT_SECRET}}"
# →   API_KEY: "{{::API_KEY}}"
# (NÃO aparece no environment:)

ls /opt/runner/apps/meu-app/.runner/
# → secrets.enc  (encriptado, commitable)

Se você tinha valores reais no .env.example e ele estava commitado no git, rotacione esses secrets agora. O wizard não te salva de leak histórico — só evita NOVOS leaks.


Resumo: qual perfil cair?

Repo tem .deploy.yml?                                  → A (usa direto)
Repo tem Dockerfile com EXPOSE + .env?                  → B (zero-touch)
Repo tem Dockerfile com EXPOSE mas sem .env?            → C (gera + warning env)
Repo tem Dockerfile SEM EXPOSE?                         → G (gera + warning port — você ajusta)
Repo tem só docker-compose?                             → C/D (gera + warning port)
Repo tem composer/package/requirements + Dockerfile?    → B com type-detection (Laravel/React/Flask)
Repo é bare?                                            → E (falha clara, instrutiva)
Quer rodar imagem do registry sem código no repo?       → F (.deploy.yml manual ou answers)
Quer bind a 127.0.0.1?                                  → H (ports: ["127.0.0.1:X:Y"])
.env.example tem valores reais em DB_PASSWORD/etc?      → I (auto-classifica em secrets:)

Validar tudo

A matriz CI/CD do runner roda os 9 cenários acima end-to-end (add → deploy → healthcheck → status → edit → fetch → unregister) automaticamente:

cd runner-client/tests/cicd-matrix
./matrix.sh
# → 90 cells, 73 pass, 0 fail, 17 n/a (~115s) — v2.19.0+

Use isso depois de qualquer mudança no wizard/generator pra garantir que nada regrediu.


v2.19.0 — Port allocation autônomo

A partir do v2.19.0 o runner aloca porta livre automaticamente quando configurado, eliminando a única intervenção manual restante no fluxo runner add.

Setup (uma vez por server)

# Interactive — runner init pergunta sobre VPC + strategy
runner init
# → "Esse server fica em uma VPC interna? [s/N]"
# → "IP detectado: 10.108.0.5 (interface eth1). Confirma? [Y/n]"
# → "Strategy [random/sequential/explicit] (default: random)"

Resultado em /opt/runner/config.yml:

network:
  vpc_ip: 10.108.0.5         # opcional; sem isso bind cai pra 127.0.0.1
  publish_strategy: random   # primeira deploy sorteia; depois sticky
  port_range: [8000, 8999]

Comportamento

Primeira deploy:

[deploy] [NETWORK] Allocated 10.108.0.5:8473 (strategy=Random, source=FreshAllocation)
[deploy] Container healthy at http://10.108.0.5:8473

Operador atualiza Traefik externo apontando pra 10.108.0.5:8473. Done.

Re-deploys (commit novo, runner fetch --deploy, runner deploy):

[deploy] [NETWORK] Allocated 10.108.0.5:8473 (strategy=Random, source=State)
                                                              ^^^^^^^^^^^^^^
                                                              porta sticky reusada

Traefik continua válido. Zero ação humana.

Quando a porta muda:

Comando Comportamento
runner deploy X (normal) mantém
runner fetch X --deploy (cron) mantém
runner reset X --hard libera state → próximo deploy realoca
runner unregister X libera porta de volta ao pool
runner ports realloc X força nova alocação (operador atualiza Traefik)
runner deploy X --port 9000 override permanente (sobrescreve state)

Inspecionar portas

# Listar portas alocadas em todas apps
runner ports list
# APP  VPC_IP  PORT  STRATEGY  ALLOCATED_AT
# middleware-241  10.108.0.5  8473  random  2026-05-06T12:30:00Z
# sweepapex       10.108.0.5  8501  random  2026-05-06T14:15:00Z

# JSON pra automação
runner ports list --json | jq

# Status de uma app específica (consumido pelo CCS)
runner status middleware-241 --json | jq .runtime.network
# {
#   "vpc_ip": "10.108.0.5",
#   "published_port": 8473,
#   "endpoint": "http://10.108.0.5:8473",
#   "allocation": {
#     "strategy": "random",
#     "allocated_at": "2026-05-06T12:30:00Z",
#     "sticky": true,
#     "source": "state"
#   }
# }

Sem `network.vpc_ip`

Comportamento bind 127.0.0.1 automático — útil quando Traefik está no mesmo host (loopback alcança):

# config.yml — sem vpc_ip
network:
  publish_strategy: random
  port_range: [8000, 8999]
[deploy] [NETWORK] Allocated 127.0.0.1:8473 (strategy=Random, source=FreshAllocation)

Pre-flight de secrets vazios

Outra adição do v2.19.0: o runner aborta o deploy se .deploy.yml::secrets: declara um secret cujo valor está vazio em .runner/secrets.enc. Antes (v2.18.x) o deploy "passava" silenciosamente e o app subia quebrado em runtime — o feedback do middleware-241 mostrou APP_KEY vazio, healthcheck /up retornando 200 (Laravel native, bypassa encryption), e / retornando 500.

Agora:

runner deploy middleware-241
# Error: deploy_blocked: secrets declared in .deploy.yml have empty values: ["APP_KEY"]
#        Bypass: rerun with --insecure (NOT recommended).
#        Or fill the values:
#          runner env set <app> --key APP_KEY --secret --value <value>

Resolução:

runner env set middleware-241 --key APP_KEY --secret --value "base64:$(openssl rand -base64 32)"
runner deploy middleware-241  # agora passa

Bypass: runner deploy middleware-241 --insecure (não recomendado — só use se conscientemente quer um deploy degradado pra debug).

Backwards compat

Apps registradas em v2.18.x continuam rodando exatamente como antes:

  • Sem bloco network: em config.yml → strategy default = explicit → lê ports: do .deploy.yml (comportamento idêntico ao v2.18.x)
  • Apps com ports: ["VPC_IP:porta:porta"] no .deploy.yml continuam funcionando sem precisar migrar

Pra migrar uma app existente pro modo random:

# 1. Adicionar bloco network: em /opt/runner/config.yml
# 2. Liberar a porta antiga + remover ports: do .deploy.yml
runner ports release middleware-241
# 3. Próximo deploy aloca porta nova random
runner deploy middleware-241 --force
# 4. Atualizar Traefik externo pra apontar na porta nova

v2.20.0 — Multi-app polish

5 features que fecham fricções identificadas em deploys multi-service complexos (LiveKit + agent Python + frontend Vite):

`build.args:` — Vite/Next/Angular zero-touch

Antes de v2.20: VITE_* impossível injetar via runner — precisava buildar local + commitar dist/.

Agora:

build:
  context: frontend
  dockerfile: Dockerfile
  args:
    VITE_SUPABASE_URL: "https://test.supabase.co"
    VITE_LIVEKIT_WS_URL: "wss://livekit.example.com"
    BUILD_ENV: production

Cada entry vira --build-arg KEY=VAL no docker build. App responsabilidade: declarar ARG VITE_* no Dockerfile e mover pra ENV antes do RUN npm run build.

Limitação MVP v2.20.0: valores são literais. Interpolation {{::Var}} (igual environment:) é follow-up v2.21.

Auto-create docker network

Antes: precisava rodar docker network create meet manualmente antes do primeiro deploy.

Agora:

networks:
  - meet

Runner cria a network antes do docker run se não existir. Idempotente. Skipa builtins (bridge/host/none).

`traefik.mode: external` — bypass do dynamic file

Útil em multi-server (Traefik em outro host) — evita orphan local config.

# /opt/runner/config.yml
traefik:
  mode: external      # none | local (default) | external
Mode Comportamento
local (default) Escreve em <traefik_dynamic_dir> (v2.19.x)
external Skip write — operador trata roteamento externo
none Mesmo bypass — apps internas sem HTTP público

v2.20 = só bypass. Snippet output, write_to, etc. são roadmap.

`runner status --json` schema split

CCS dashboard agora consome 3 blocos lógicos separados:

runner status meet-livekit --json | jq .manifest    # repo, branch, ckey_fingerprint, ...
runner status meet-livekit --json | jq .config      # type, port, ports, networks, hosts, ...
runner status meet-livekit --json | jq .runtime     # status, current_version, network endpoint

Aditivo — campos legados top-level (project, system, instances, state) preservados pra v2.19.x consumers. Marker _deprecated_top_level: true sinaliza CCS pra migrar pro caminho novo.

`hosts:` map (DNS aliases)

Resolve host.docker.internal em Linux Docker (que não tem por default). Ou qualquer alias custom:

hosts:
  host.docker.internal: host-gateway
  internal-api: 10.0.0.5
  livekit-pub: 10.108.0.5

Cada entry vira --add-host KEY:VAL no docker run. Map (não array) — last-wins em duplicatas.

Migration

Apps existentes opt-in — sem precisar unregister ou --force. Adiciona o campo no .deploy.yml e roda runner deploy --force.

# Frontend Vite ganhando build_args
sed -i '/dockerfile:/a\  args:\n    VITE_SUPABASE_URL: "https://..."' .deploy.yml
git push origin dist-frontend && runner fetch meet-frontend --deploy

# Agent Linux Docker ganhando hosts
echo -e "hosts:\n  host.docker.internal: host-gateway" >> .deploy.yml
git push origin dist-agent && runner fetch meet-agent --deploy

v2.21.0 — Multi-app follow-ups

5 features completando MVPs/slots do v2.20.0 + 1 UX safety net + 1 doc fix.

`{{::Var}}` em `build.args:` (interpolation real)

v2.20.0 passava literais. v2.21.0 interpola de .env/.secrets:

build:
  args:
    VITE_SUPABASE_URL: "{{::SUPABASE_URL}}"
    VITE_LIVEKIT_WS_URL: "{{::LIVEKIT_WS_URL?wss://default.com}}"
    BUILD_ENV: production       # literal continua funcionando

Ordem: .secrets primeiro (mais específico), .env fallback. Sem TTY no build → não pede prompt; se var não resolve, erro instrutivo:

Error: failed to resolve build_arg `VITE_SUPABASE_URL`
Caused by: unresolved build_arg template `{{::SUPABASE_URL}}`: not found in .env or .secrets.
           Set the value first: runner env set <app> --key SUPABASE_URL --value <V>

`depends_on:` cross-app

Resolve o caso multi-app: meu-agent precisa esperar meu-livekit healthy:

instances:
  production:
    depends_on:
      - app: meu-livekit
        condition: healthy           # healthy | started
        timeout: 300s
      - app: meu-redis
        instance: production
        condition: started

Runner polla docker inspect por cada dep até atingir condition OR timeout. Bypass: --insecure ignora.

`traefik.external` snippet output

v2.20 só tinha o bypass. v2.21 emite snippet pronto pra colar no Traefik externo:

# /opt/runner/config.yml
traefik:
  mode: external
  external:
    write_snippet_to: "/opt/traefik-snippets/{app}.yml"   # opcional
    print_snippet: true                                    # default

Output do deploy:

✅ DEPLOY CONCLUÍDO
   Endpoint: http://10.0.0.5:8473

📋 SNIPPET TRAEFIK (cole no <traefik-server>:/etc/traefik/dynamic/<file>.yml):

http:
  routers:
    meu-frontend-production:
      rule: "Host(`app.example.com`)"
      entryPoints: ["websecure"]
      tls: { certResolver: myresolver }
      service: meu-frontend-production
  services:
    meu-frontend-production:
      loadBalancer:
        servers:
          - url: "http://127.0.0.1:8473"

ℹ️  Snippet salvo em /opt/traefik-snippets/meu-frontend.yml

`runner add` detecta repo duplicado

runner add --repo usuario/meu-app --branch main --ckey "..." --instance production
# Error: duplicate_repo: Repo usuario/meu-app já está registrado
#        como `meu-app-livekit` (branch: deploy/livekit, status: registered).
#        Opções:
#          --force-duplicate  permite criar SEGUNDA app pro mesmo repo
#          runner unregister meu-app-livekit --force  remove a anterior

Workaround quando intencional:

runner add --repo X --branch Y --ckey ... --instance production --force-duplicate

`generate-config` template completo

runner generate-config agora mostra TODOS os campos válidos: build.args, hosts, depends_on, secrets block, etc. Use como referência completa do schema.

Migration

Mudança de comportamento ÚNICA: runner add em repo duplicado falha. Apps existentes não-duplicadas: zero impacto. Pra apps com duplicates pré-v2.21:

# Opção A: limpar duplicates (recomendado)
runner unregister <app-duplicada> --force

# Opção B: aceitar duplicates (raro)
runner add ... --force-duplicate
By Borlot.com.br on 06/05/2026