UpuaiUpuai

Deploy de Docker no Brasil: do Dockerfile à produção

Guia prático de deploy de Docker no Brasil: escrever um Dockerfile de produção, escolher entre railpack e dockerfile, subir com git push e ler o erro explicado quando o build falha.

Equipe Upuai· Upuai CloudAtualizado em 14 min de leitura

O docker build passou, o docker run subiu e a aplicação responde em localhost:3000. E aí? Entre o Dockerfile que funciona na sua máquina e o app servindo usuário de verdade existe uma lista de tarefas chatas: registry, TLS, domínio, health check, variável de ambiente, migration e rollback quando quebra. Deploy de Docker no Brasil é resolver essa lista sem alugar servidor, sem escrever YAML de Kubernetes e sem virar DevOps no meio do caminho.

Este guia cobre o trajeto inteiro, na ordem em que ele acontece: quando você realmente precisa de um Dockerfile, como escrever um que não te sabote em produção, como testar antes de gastar um build, as quatro formas de subir (config no repo, CLI, diretório local e imagem já publicada), o que a plataforma faz entre o git push e o “no ar”, e o que aparece na tela quando o build falha. Todos os comandos, limites e mensagens citados aqui são os reais da plataforma nesta data, a fatura sai em Real e os 15 dias de teste não pedem cartão.

Três caminhos, e o seu talvez não seja o Dockerfile

Antes de escrever qualquer linha, vale escolher o caminho certo. São três, e eles resolvem problemas diferentes:

CaminhoQuando faz sentidoO que você escreve
railpack (padrão)Stack comum (Node, Vite, Python, Django, Go, Rails, estático) sem exigência especial de sistemaNada. O build detecta a stack e monta a imagem
Dockerfile do repoVocê precisa de controle: dependência de sistema (ffmpeg, imagemagick, driver), versão exata de runtime, passo de build próprio, imagem final enxutaO Dockerfile mais uma linha de configuração
Imagem já publicadaA imagem já é construída no seu CI e vive num registry (Docker Hub, GHCR, ECR)Só a referência registry/imagem:tag

Se o seu app é um Next.js ou uma API Node comum, o railpack resolve sem Dockerfile nenhum, e o guia de deploy de Next.js mostra esse fluxo do começo ao fim. O Dockerfile entra quando a sua build tem uma vontade própria que nenhum autodetect adivinha.

Uma coisa que economiza uma tarde de confusão: ter um Dockerfile no repositório não troca o builder sozinho. O padrão continua sendo o railpack até você declarar o contrário. É deliberado. Dockerfile de desenvolvimento (com pacote de teste, watcher, volume montado) é comum na raiz de projeto, e buildar isso em produção por acidente seria pior que ignorá-lo.

Um Dockerfile que sobrevive à produção

O Dockerfile abaixo é o esqueleto que serve para a maioria dos serviços Node. A ideia vale igual para Python, Go ou Java: separar quem compila de quem executa.

# syntax=docker/dockerfile:1

# 1) etapa de build: tem toolchain, devDependencies, compilador
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# 2) etapa final: só o que roda em produção
FROM node:22-alpine AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

Por que cada decisão está aí:

  • Duas etapas. A imagem que vai para produção não carrega compilador, cache de pacote nem devDependencies. Menos peso é menos tempo de download em cada rollout e menos superfície de ataque.
  • Manifesto antes do código. O COPY package.json package-lock.json antes do COPY . . existe por causa do cache de camadas: se o seu package-lock.json não mudou, a camada de dependências é reaproveitada e o npm ci nem roda. E aqui isso vale dinheiro de verdade, porque o cache de camadas persiste entre um deploy e o outro, por serviço e ambiente. Inverta essas duas linhas e todo push reinstala o mundo.
  • npm ci, não npm install. Instalação determinística a partir do lockfile. O build de terça é igual ao de quinta.
  • USER node. Container rodando como root é herança preguiçosa de tutorial. Trocar de usuário custa uma linha.
  • .dockerignore. Sem ele, o COPY . . empacota node_modules, .git e possivelmente o seu .env. O arquivo mínimo é este:
.git
node_modules
.env*
dist
coverage
**/*.log

A linha que mais derruba deploy: a porta

O container não escolhe a porta, a plataforma escolhe. A variável PORT é injetada no ambiente e a sua aplicação precisa escutar nela, em 0.0.0.0:

const port = Number(process.env.PORT) || 3000
app.listen(port, '0.0.0.0')

EXPOSE 3000 é documentação, não vínculo. Se o processo sobe escutando em localhost, ele fica visível só de dentro do próprio container: o health check bate de fora, leva “connection refused”, o rollout estoura o prazo e o deploy falha com a sua aplicação aparentemente de pé. É a falha número um de quem vem do docker run local, e a plataforma hoje a diagnostica pelo nome, dizendo que nada abriu a porta esperada em vez de mostrar um log genérico.

Testar local antes de gastar um build

Cinco minutos aqui poupam três deploys vermelhos:

docker build -t meu-app:local .
docker run --rm -e PORT=8080 -p 8080:8080 meu-app:local
curl -i localhost:8080/health
docker image ls meu-app:local

O que conferir antes de dar push:

  1. Sobe com a porta que vem de fora? Rode com PORT diferente do default. Se só funciona na 3000, o código está ignorando a variável.
  2. O endpoint de health responde 2xx? Se você não tem um, /health devolvendo ok é meia dúzia de linhas e salva o rollout.
  3. A imagem final tem tamanho de gente? Uma API Node em multi-stage vive na casa das centenas de MB. Se deu 2 GB, sobrou toolchain na imagem final.
  4. Roda sem .env do seu computador? Em produção as variáveis vêm da plataforma. Se o container só sobe com o seu arquivo local, falta configuração declarada.

Do Dockerfile ao ar: quatro caminhos

1. O canônico: declarar no upuai.toml

Configuração como código, versionada e revisada em PR. O arquivo vive na raiz do serviço:

#:schema https://upuai.com.br/schemas/upuai-toml-v1.json

[build]
builder = "dockerfile"
dockerfilePath = "Dockerfile"

[deploy]
healthCheckPath = "/health"
releaseCommand = "npx prisma migrate deploy"
releaseTimeoutSeconds = 300

Commit, push, e o próximo deploy já usa o seu Dockerfile. Em monorepo, cada serviço tem o seu arquivo dentro do próprio diretório raiz (por exemplo apps/api/upuai.toml), e o caminho do Dockerfile é relativo a ele. A referência completa das chaves está na documentação do upuai.toml.

2. Sem abrir PR: pela CLI

brew tap saiph-ti/upuai-cli && brew install upuai   # macOS e Linux; no Windows, via Scoop
upuai login
upuai init --name meu-app --repo owner/repo --framework Docker --yes
upuai config set --builder dockerfile --dockerfile-path Dockerfile
upuai deploy --wait --yes -o json

O --wait bloqueia até o deploy chegar a um status final e sai com código diferente de zero se falhar, o que é exatamente o que o seu pipeline precisa. Vale lembrar a precedência: o que está salvo no painel vence o upuai.toml. Escolha um lugar e fique nele, senão você vai passar meia hora procurando por que a mudança do arquivo não pegou.

3. Sem repositório conectado: upuai up

upuai up --wait --yes

Empacota o diretório atual, envia e faz o deploy dessa fonte, respeitando .gitignore e .upuaiignore (e sempre excluindo .git, node_modules e .env*). É a saída de emergência: prototipagem, código ainda não commitado, repo que não está conectado. Para o dia a dia, git continua sendo o caminho recomendado, porque cada deploy fica preso a um commit e cada ambiente passa a deployar sozinho a branch que rastreia.

4. Imagem já publicada

upuai init --name meu-app --image ghcr.io/org/app:v2 --framework Docker --yes
upuai deploy --wait --yes -o json

Aqui não existe build: a plataforma espelha a imagem do registry de origem e sobe. Esse caminho era o mais silencioso da casa, e desde a versão 1.34.0 a etapa de download da imagem também tem log próprio na aba de build, então dá para ver o que aconteceu quando o pull não vai.

O que acontece entre o push e o “no ar”

  1. Fonte. Clone do repositório na versão do commit, ou o tarball que você enviou com upuai up.
  2. Build. O BuildKit constrói a imagem num runner com CPU e memória dedicados por plano.
  3. Release phase. Se você declarou releaseCommand, ele roda num job efêmero com a mesma imagem e as mesmas variáveis, antes do rollout.
  4. Rollout. Os pods novos sobem e só recebem tráfego depois de passar no health check. Se não passarem, os antigos continuam servindo.

O poder de build muda por plano, e esse número aparece na página de preços justamente porque ele decide se o seu build passa ou morre:

PlanoBuild (CPU e RAM dedicados)Serviços por projetoRetenção de logsTamanho do repositório
Teste (15 dias, sem cartão)4 vCPU, 4 GB212 horasaté 100 MB
Starter (R$ 19)6 vCPU, 6 GB324 horasaté 250 MB
Plus (R$ 49)6 vCPU, 6 GB448 horasaté 350 MB
Pro (R$ 99)6 vCPU, 6 GB572 horasaté 500 MB
Business (R$ 199)8 vCPU, 10 GB107 diasaté 1,5 GB

Esse limite de memória é mais importante do que parece: quase toda ferramenta de build se dimensiona pelo tamanho da máquina, não pelo teto do build. O next build abre um worker por CPU que enxerga, o make abre um job por core, o Gradle idem. Num runner de 4 GB, isso vira morte por OOM em projeto que compila tranquilo no seu notebook de 32 GB.

Duas coisas que valem para todos os planos: esperar por um runner é estado normal e aparece como espera, não como erro; e um push novo substitui o anterior na fila em vez de enfileirar build velho. Um deploy em andamento também sobrevive a um reinício da plataforma e retoma sem refazer o build.

Quando o build falha, o erro chega explicado

Esta é a parte que mudou de verdade na versão 1.34.0, publicada em 29 de julho de 2026: a análise da falha é gerada antes de o status de falha aparecer. Na prática, o deploy vermelho já nasce com quatro campos preenchidos: a categoria do problema, o que aconteceu em português, a evidência (o trecho verbatim do log que sustenta o diagnóstico) e a ação recomendada. Você não vê “FAILED” e sai caçando log.

Alguns casos que a plataforma nomeia sozinha, com a ação que ela sugere:

  • Memória do build. “O build excedeu o limite de memória do seu plano e foi encerrado pelo kernel (OOM)”, com o limite exato do seu bucket na mensagem e a recomendação de limitar o paralelismo da ferramenta (experimental.cpus no Next.js, -j2 no make, org.gradle.workers.max no Gradle) antes de pensar em upgrade.
  • Porta que ninguém abriu. A aplicação iniciou, o health check levou “connection refused” até o prazo do rollout. A ação aponta o $PORT e o 0.0.0.0, que é quase sempre a causa.
  • Registry. Imagem não encontrada, credencial recusada, permissão negada e rate limit são classes distintas, com mensagens distintas. Vale saber o número: o Docker Hub permite 100 pulls a cada 6 horas por endereço IP sem autenticação, contra 200 numa conta pessoal autenticada e sem limite nos planos pagos, segundo a documentação oficial. Como qualquer nuvem sai por IPs compartilhados, puxar imagem pública sem credencial é uma das causas mais bobas de deploy vermelho no mundo inteiro.
  • upuai.toml inválido. O deploy é bloqueado antes do build, com o erro de sintaxe apontado.

Nada disso conserta o seu código. O que muda é o tempo entre a falha e o entendimento, que é onde o dia de trabalho costuma escoar. O log cru continua a um comando de distância quando você quiser ver tudo:

upuai logs -n 200 --build     # saída do build
upuai logs -n 200 --deploy    # release phase e rollout
upuai logs -n 200             # runtime

Migration, staging e rollback

Rodar migration na mão depois do deploy é a receita clássica de código novo contra schema velho. Com o releaseCommand declarado, a migration roda antes do rollout, com a mesma imagem e as mesmas variáveis; se falhar, o tráfego não muda e a versão anterior segue servindo. Se o seu app precisa de banco, o guia de PostgreSQL gerenciado cobre o provisionamento em um comando e a conexão privada entre serviços.

O resto do ciclo é curto:

upuai promote --yes                      # staging para produção, copiando o artefato
upuai rollback --list                    # histórico de deploys
upuai rollback --to <deploy-id> --yes    # volta para a versão anterior

O promote copia a imagem já construída em vez de rebuildar, então o que você testou em staging é bit a bit o que vai para produção. Para serviço que não é web, como worker e cron, o mesmo Dockerfile serve a vários processos declarados no upuai.toml, no estilo Procfile: é o que sustenta, por exemplo, manter um bot no ar 24 horas por dia.

O que isso custa em Real

O teste de 15 dias não pede cartão e comporta app mais banco (dois serviços). Pra manter no ar depois: Starter R$ 19, Plus R$ 49, Pro R$ 99, Business R$ 199, com o plano anual saindo por dez mensalidades. A fatura vem em Real, o que corta os 3,5% de IOF do cartão internacional e a variação cambial que fazem a mesma carga custar mais caro aqui do que lá fora.

Duas honestidades que preferimos dizer antes de você descobrir sozinho: o período de teste não tem backup automático de banco (a retenção começa em 1 dia no Starter e vai a 7 dias no Business), e o Redis é instância única em todos os planos (persistência em disco e backup diário, sem failover automático). Os 15 dias servem pra validar o caminho inteiro; produção com dado de usuário pede plano pago.

Perguntas frequentes

Preciso de Dockerfile para subir na Upuai? Não. O padrão é o railpack, que detecta a stack e monta a imagem sem você escrever nada. O Dockerfile é para quando você precisa de controle fino: dependência de sistema, versão específica de runtime ou um passo de build fora do comum.

Dá para usar docker compose? O modelo é outro: cada container vira um serviço, e a composição acontece no projeto, não num arquivo. Banco, cache e bucket você provisiona gerenciados (upuai add --type database --engine postgres, por exemplo), e os serviços se enxergam pela rede interna do ambiente. As variáveis compartilhadas são opt-in por serviço, então a credencial do banco não vaza para todo mundo só por existir no projeto.

Meu build passou, mas a URL não responde. O que olhar primeiro? A porta. Confirme que o processo escuta em process.env.PORT e em 0.0.0.0, não em localhost. Depois o healthCheckPath: se o endpoint configurado não existe ou demora mais que o prazo de inicialização, a probe nunca fica verde. O diagnóstico da falha já aponta os dois casos por nome.

Imagem privada do GHCR ou do Docker Hub funciona? Funciona, configurando as credenciais de registry no serviço. Vale como dica geral: mesmo para imagem pública, o pull autenticado tem limite de taxa muito maior, e isso resolve o erro de rate limit que aparece em horário de pico.

O build reaproveita cache entre um deploy e outro? Sim. O cache de camadas persiste entre builds, por serviço e ambiente. Por isso a ordem do Dockerfile importa tanto: manifesto de dependências primeiro, código depois. A exceção é quando você muda uma variável de build (NEXT_PUBLIC_, VITE_ e afins): aí o cache é descartado de propósito, para o valor novo entrar no bundle.

Feche o ciclo

Dockerizar bem é escrever menos linhas do que parece: duas etapas, o manifesto antes do código, um usuário sem privilégio e a porta vinda do ambiente. O resto do caminho, do build ao TLS, é o que a plataforma faz para você, em servidor brasileiro e com fatura em Real.

Se você já tem o Dockerfile pronto, o teste é rápido: comece seus 15 dias grátis, sem cartão, conecte o repositório, declare builder = "dockerfile" e dê um push. Se der ruim no primeiro build, pelo menos você vai ler a causa em vez de garimpar log.

Faça deploy no Brasil em 5 minutos

PaaS brasileira, preços em Real, infraestrutura LGPD-native.

Ver planos

Artigos relacionados