Como fazer o deploy de um app Next.js SSR com Docker e um backend

Um app de notas em Next.js 15, enviado ao GitHub, renderizando em um container com os dados em um backend gerenciado. Medido de ponta a ponta, incluindo o primeiro build de seis minutos e a página 500 que a nossa primeira versão mostrou.

Uma janela de navegador mostrando a página de notas em Next.js exatamente como foi servida, renderizada no servidor em 2026-09-16T12:43:01.063Z, ao lado dos três tempos medidos de build da imagem para o mesmo Dockerfile: 5 min 19 s, 3 min 04 s e 4 min 12 s

Tradução do original em inglês, testado em 16 de setembro de 2026. Código, números e datas são os do original. Read in English

Seu app Next.js renderiza no servidor, e esse é o ponto: o HTML chega completo, com os dados dentro. Isso também significa que o app precisa de um lugar para rodar que não é uma CDN — um processo que executa seus componentes a cada requisição — e de um lugar para guardar os dados que não é esse processo.

Este post é sobre renderização no servidor: um processo que executa seus componentes por requisição, uma fonte de dados que ele lê no momento da requisição e chaves que ficam no servidor. Um container roda next start para você, atrás de HTTPS. Um backend guarda as notas, valida cada uma e responde a uma chamada REST.

As outras três formas têm posts próprios: Node.js com Docker, FastAPI com Docker, um worker sempre ligado.

Construímos um app de notas — um server component que lista as notas, uma server action que as adiciona, 94 linhas no total incluindo o Dockerfile —, enviamos para o GitHub e o colocamos renderizando em um container, com os dados fora dele.

O primeiro deploy levou 6 minutos e 4 segundos, a maior parte em next build. Tudo abaixo vem dessa execução, em 15 e 16 de setembro de 2026, incluindo a página 500 que a nossa primeira versão mostrou.

Pegue o código funcionando: o repositório companion está em github.com/templates-back4app/nextjs-notes — app/page.js, app/actions.js, lib/backend.js, Dockerfile, cloud/main.js e deploy-check.sh, que verifica health, renderização no servidor e dados em qualquer URL de deployment.

O que um app renderizado no servidor precisa que um app estático não precisa?

Um processo, e uma fonte de dados que esse processo alcance. Um export estático é um conjunto de arquivos em uma CDN e não precisa de nenhum dos dois; no instante em que uma página lê dados no momento da requisição, algo precisa executar o seu código e algo precisa guardar os dados.

O que você precisaPor que não é opcionalOnde fica neste post
node server.js rodandoServer components executam por requisiçãoContainer, seção 03
HTTPS em uma URL públicaNavegadores recusam HTTP puro para formuláriosContainer, automático
Um banco de dados que você não operaOs dados sobrevivem ao containerBackend, seção 04
Validação fora do containerA action é descartável; as regras nãoBackend, seção 05
Chaves que nunca chegam ao navegadorVariáveis de ambiente só no servidor, nada de NEXT_PUBLIC_Env do container, seção 03

A maioria dos hosts que rodam Next.js para nos dois primeiros, e os dados viram uma connection string para outra coisa que você paga e mantém. A Back4app é a que testamos porque a segunda metade é um produto da mesma conta: um app de backend com banco de dados gerenciado e uma REST API, chamada do container com dois headers.

O navegador recebe HTML. O container recebe duas variáveis de ambiente. O backend recebe os dados. Ninguém recebe um servidor.

Do que é feito o app de notas renderizado no servidor?

De uma página de notas. app/page.js é um server component: chama o backend, renderiza a lista e um formulário e carimba a hora em que renderizou. app/actions.js é uma server action: o formulário faz o post para ela, ela cria a nota no backend e a página renderiza de novo. lib/backend.js é o único arquivo que conhece as chaves.

Arquitetura: o navegador carrega o HTML renderizado pelo container Next.js; o server component e a server action do container chamam o backend Back4app via REST com o App ID e a REST key; um hook de Cloud Code valida cada Note

A página. Duas linhas a mantêm dinâmica — sem elas, next build a pré-renderizaria uma única vez, com o que quer que o backend tivesse durante o build:

// Stack: Next.js 15 App Router | File: app/page.js
import { listNotes } from "../lib/backend";
import { addNote } from "./actions";

export const dynamic = "force-dynamic";           // render per request, never at build time

export default async function Home({ searchParams }) {
  const { error } = await searchParams;
  const notes = await listNotes();
  const renderedAt = new Date().toISOString();
  return (
    <main>
      <h1>Notes</h1>
      <form action={addNote}>
        <input name="text" placeholder="Write a note" required />
        <button>Add</button>
      </form>
      {error && <p role="alert">{error}</p>}
      <ul>{notes.map((n) => <li key={n.objectId}>{n.text}</li>)}</ul>
      <p>{notes.length} notes · rendered on the server at {renderedAt}</p>
    </main>
  );
}

O cliente do backend roda só no servidor. cache: "no-store" é a segunda metade do “por requisição”:

// Stack: Next.js 15 (server only) | File: lib/backend.js
const BASE = process.env.PARSE_SERVER_URL ?? "https://parseapi.back4app.com";
const headers = () => ({
  "X-Parse-Application-Id": process.env.PARSE_APP_ID,
  "X-Parse-REST-API-Key": process.env.PARSE_REST_KEY,
  "Content-Type": "application/json",
});

export async function listNotes() {
  const r = await fetch(`${BASE}/classes/Note?order=-createdAt&limit=50`, { headers: headers(), cache: "no-store" });
  if (!r.ok) throw new Error(`backend ${r.status}: ${await r.text()}`);
  return (await r.json()).results;
}

export async function createNote(text) {
  const r = await fetch(`${BASE}/classes/Note`, { method: "POST", headers: headers(), body: JSON.stringify({ text }) });
  const body = await r.json();
  if (!r.ok) throw new Error(body.error ?? `backend ${r.status}`);
  return body;
}

O Dockerfile tem dois estágios. O primeiro roda next build; o segundo copia só a saída standalone, então a imagem não tem node_modules além do que o servidor precisa:

# Stack: Docker | Node.js 22 (alpine), multi-stage | File: Dockerfile
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production PORT=8080 HOSTNAME=0.0.0.0
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
EXPOSE 8080
CMD ["node", "server.js"]
Medido · nextjs-notes · setembro de 2026
94linhas, Dockerfile incluído
→
0chaves no HTML servido
2variáveis de ambiente que o container precisa

Você precisa de uma conta gratuita na Back4app, de uma conta no GitHub e de curl. Nada de Docker na sua máquina: a plataforma faz o build da imagem, e este post foi produzido em um Mac sem Docker instalado. Node só para rodar o app localmente antes.

De onde vêm os dados?

De um app de backend — na dashboard, New App → Build your Backend, dê um nome, Create. A página Overview mostra o App ID e, no dropdown Keys, a REST API key. Esses dois valores vão para o ambiente do container e para nenhum outro lugar.

Se o seu frontend chamasse o backend diretamente, a chave no navegador seria uma client key e as permissões por classe (CLP) fariam a guarda: esse caso tem post próprio.

A classe Note foi criada pelo primeiro POST — sem schema prévio (Parse Server 7.5.2, setembro de 2026). Aqui está essa escrita de duas formas: a que a server action faz e a que você pode fazer do seu terminal com as mesmas chaves. As duas apareceram na renderização seguinte da página:

// Stack: Next.js 15 server action | File: app/actions.js
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { createNote } from "../lib/backend";

export async function addNote(formData) {
  const text = String(formData.get("text") ?? "");
  try {
    await createNote(text);      // the backend's beforeSave decides what a valid note is
  } catch (err) {
    redirect(`/?error=${encodeURIComponent(err.message)}`);
  }
  revalidatePath("/");
  redirect("/");
}

Mesma escrita, mesmo backend. A página renderiza de novo com qualquer uma das duas.

Por que a regra da nota é um hook no backend e não uma server action?

Porque a regra é sobre o que uma nota é. Uma server action roda só neste container e só para este formulário; o hook do backend roda para a action, para o curl acima e para o próximo app que escrever notas. O nosso faz trim no texto, rejeita nota vazia e limita a 280 caracteres:

// Stack: Node.js 22.x | Parse Server 8.x | File: cloud/main.js
Parse.Cloud.beforeSave("Note", (request) => {
  const n = request.object;
  const text = (n.get("text") ?? "").trim();
  if (!text) throw new Parse.Error(Parse.Error.VALIDATION_ERROR, "a note needs some text.");
  if (text.length > 280) throw new Parse.Error(Parse.Error.VALIDATION_ERROR, "keep it under 280 characters.");
  n.set("text", text);
});

Cole em Cloud Code → cloud/main.js na dashboard do backend, Deploy, e prove com uma requisição. O nosso respondeu 400 {"code":142,"error":"a note needs some text."} para três espaços e 201 com o texto já sem os espaços para uma nota válida cheia de padding — depois do segundo deploy.

Como fazer o deploy a partir do GitHub?

Envie o repositório, depois New App → Deploy from GitHub no lado de Containers. Na primeira vez, o GitHub pede para instalar o app Back4app Containers nos repositórios que você escolher; a lista na dashboard atualiza no momento em que você termina.

Configure. Selecione o repositório. O formulário detectou o Dockerfile e ofereceu um Node.js buildpack como alternativa. Mantivemos o Dockerfile, deixamos a porta em auto (ele lê EXPOSE 8080), adicionamos PARSE_APP_ID e PARSE_REST_KEY e apontamos o health check para /healthz, um route handler de quatro linhas no repositório.

O formulário de deploy do nextjs-notes: Dockerfile selecionado com a alternativa Node.js Buildpack, duas variáveis de ambiente com os valores mascarados

Deploy. Clicamos às 15:03:41. O botão ficou em “Deploying…” por 37 segundos até a página do app aparecer; depois PREPARING às 15:04:18, BUILDING IMAGE por 5 minutos e 19 segundos (npm ci, depois next build dentro do kaniko), LAUNCHING CONTAINER às 15:09:37, DEPLOYMENT READY às 15:09:45.

Medido · primeiro deploy · plano Free · 15 de setembro de 2026
6 min 4 sclique em Deploy → DEPLOYMENT READY
→
5 min 19 sdisso, npm ci + next build
47 MBde RAM ocioso, de 256

Como medimos: um cronômetro do clique em Deploy até DEPLOYMENT READY, plano Free, um deploy em 15 de setembro de 2026; os três tempos de build são três deploys do mesmo commit, lidos do log de build. next build domina, então um app maior faz o build mais devagar, e nada é cacheado entre builds.

deploy-check.sh então bateu em /healthz, carregou / e procurou rendered on the server at — presente, com um timestamp que mudava a cada requisição. Uma nota criada pela REST API estava na página no carregamento seguinte; nada foi cacheado entre os dois.

A página de notas no ar: um campo de texto, um botão Add, duas notas com seus timestamps em UTC e a linha 2 notes · rendered on the server at 2026-09-16T12:43:01.063Z

Depois, o formulário. Um navegador headless digitou uma nota e clicou em Add: o POST para a server action levou 368 ms de ida e volta, e o backend tinha a nova linha 300 ms depois do clique. Em seguida fizemos grep no HTML servido pelas duas chaves: zero ocorrências.

A página de notas logo depois de uma nota ser adicionada pelo formulário, com a nova nota no topo da lista

Onde as notas ficam? O backend por trás da página

Em três telas da dashboard do backend, e cada linha nelas foi escrita pelo container acima — sem dados de seed, sem schema definido à mão.

Overview — de onde vêm as duas variáveis. Dashboard → New App → Build your Backend → Create. A página Overview mostra o App ID e, no dropdown Keys, a REST API key. Esses dois valores vão para o ambiente do container e são lidos só por lib/backend.js.

O Overview do backend do nextjs-notes: App ID, o dropdown Keys em Rest Key (valor borrado), Parse Server 7.5.2 e MongoDB 3.6 à direita

Cloud Code — onde a regra mora. cloud/main.js aberto no editor com o hook beforeSave("Note"), e o selo Files pending deploy (1) ao lado do botão Deploy. Em um backend novo, esse selo é o sinal: ele continua lá depois do primeiro Deploy, e é assim que você sabe que nada foi enviado.

O editor de Cloud Code com cloud/main.js aberto mostrando o hook beforeSave de Note, e o selo Files pending deploy (1) ao lado do botão Deploy

Database Browser — a classe Note. Seis linhas: quatro adicionadas pelo formulário (a server action), uma escrita pela REST API a partir de um terminal e a primeira, trimmed by the hook, que chegou cheia de espaços. São as mesmas linhas que a página renderiza — o navegador mostra o HTML; isto mostra de onde o HTML veio.

O Database Browser mostrando a classe Note com seis objetos e a coluna text: quatro notas adicionadas pelo formulário, uma escrita pela REST API, uma com o trim feito pelo hook

Quanto tempo a URL dura, e quanto custa um redeploy?

No plano Free, a URL e o container vivem 60 minutos a partir do momento em que o deploy começa — e um build de seis minutos consome seis deles. Vimos acontecer: o log às 16:04:12 dizia The Back4app custom domain has expired for free plan, depois COOLING DOWN…, FINISHING CONTAINER…, DEPLOYMENT DESTROYED às 16:05:14. O status na dashboard virou URL Expired.

Na manhã seguinte, mudamos o plano para Shared (0,5 vCPU, 512 MB, US$ 5/mês em 16 de setembro de 2026). Sem nenhum clique em Deploy, o log mostrou PREPARING DEPLOYMENT às 12:35:42 e DEPLOYMENT READY às 12:38:57 na mesma URL b4a.run — 3 minutos e 14 segundos, com o build da imagem caindo de 5 min 19 s para 3 min 4 s.

O Overview do container no plano Shared: US$ 5,00 por mês, 0,5 vCPU e 512 MB, status Available, origem GitHub main com auto, o deployment v1.1.0 Ready em 4m 35s

Medido · plano Shared · 16 de setembro de 2026
3 min 14 stroca de plano → redeploy, mesma URL
→
4 min 34 sgit push → nova versão no ar (autodeploy)
US$ 5por mês, 0,5 vCPU · 512 MB

O plano pago também libera o Autodeploy (Settings → Build & deploy). Ligamos e enviamos a versão com a correção de erro abaixo às 13:02:55. PREPARING DEPLOYMENT foi registrado às 13:02:58 — antes de o comando de push retornar — e DEPLOYMENT READY às 13:07:34. O poller viu a nova versão 4 minutos e 34 segundos depois do push; o container antigo respondeu até a troca.

Por que o Next.js mostrou “Application error”, e o que mais quebrou?

Quebramos o app de propósito, e a plataforma o quebrou uma vez para nós. Cada item começa com o que você vai ver de fato.

Application error: a server-side exception has occurred while loading … (see the server logs for more information). Digest: 3998820666. A nossa primeira versão. O backend rejeitou uma nota em branco, createNote lançou o erro, a server action o relançou, e o Next.js mostrou sua página de erro genérica — o digest aponta para o stack trace em Runtime Logs. Capture o erro na action e faça redirect("/?error=…"); a página passa a renderizar a mensagem do backend ao lado do formulário.

A página de erro genérica do Next.js: Application error: a server-side exception has occurred while loading nextjsnotes-sz7qfr45.b4a.run, com um digest

Com o redirect no lugar (v1.1.0, deploy feito pelo autodeploy), o mesmo envio em branco responde 303 para /?error=a%20note%20needs%20some%20text. e a página mostra a frase do backend abaixo do formulário:

A página de notas depois de um envio em branco na v1.1.0: a mensagem a note needs some text. em vermelho abaixo do formulário, a lista de notas intacta

A página mostra notas antigas depois de uma escrita. A rota está sendo pré-renderizada ou o fetch está cacheado. Os dois são necessários: export const dynamic = "force-dynamic" na página e cache: "no-store" no fetch; revalidatePath("/") na action é o que faz o redirect cair em dados frescos.

Todo carregamento de página é um 500, e Runtime Logs diz ⨯ Error: backend 403: {"error":"unauthorized"}. Uma PARSE_REST_KEY errada — listNotes() lança o erro dentro do server component, e o visitante recebe a página de erro genérica. /healthz continua verde porque não toca o backend; faça-o chamar listNotes() para que o seu monitoramento veja a chave errada primeiro.

O build leva minutos e a hora gratuita está correndo. Os 60 minutos começam em PREPARING DEPLOYMENT, antes do build. O nosso primeiro build de seis minutos deixou 54 minutos de execução. Teste nessa janela; rode em um plano pago.

Success on deploying your changes!, mas o hook não faz nada. O primeiro deploy de Cloud Code em um backend novo não envia arquivo nenhum. Procure main.js not found em Logs → System, edite o arquivo no editor e faça o deploy de novo.

Quando um container é a resposta errada para Next.js?

Pelo nosso próprio manifesto, um post em que só a plataforma ganha não é publicado — então aqui está a outra coluna.

  • Suas páginas são estáticas. Um blog, um site de documentação, uma página de marketing sem dados por requisição: output: "export" e uma CDN são mais baratos, mais rápidos e não precisam de processo nenhum.
  • Você precisa de renderização no edge ou de otimização de imagens na CDN. Isto é um container em uma região; não há edge runtime, e a otimização do next/image roda na CPU do container.
  • Você precisa de recursos do Postgres. Joins, window functions, um schema que você migra — o backend é Parse Server sobre MongoDB (Postgres nos planos pagos), consultado com JSON no where, não com SQL.
  • Builds de seis minutos doem. Cada push refaz a imagem a partir do npm ci; se você faz deploy vinte vezes por dia, um host que cacheia node_modules entre builds vai parecer mais rápido.

Se nada disso descreve você, um container Next.js renderizado no servidor na frente de um backend gerenciado é a forma que escolheríamos de novo — e os dois headers são toda a integração.

O que construir em seguida sobre o app de notas?

Você tem uma página que renderiza por requisição, um formulário que escreve pelo servidor, um banco de dados que você nunca provisionou e chaves que o navegador nunca vê. A partir daqui:

  • Dê donos às notas. A classe User do Parse adiciona endpoints para criar conta e entrar (medido, com redefinição de senha); guarde o session token em um cookie httpOnly e passe-o como X-Parse-Session-Token a partir de lib/backend.js.
  • Mostre erros sem redirect. useActionState em um client component mantém o estado do formulário e a mensagem no lugar; a versão com redirect acima não precisa de JavaScript no cliente.
  • Torne o health check honesto. Faça /healthz chamar listNotes() para que uma chave errada apareça no seu monitoramento, não para o primeiro visitante — a verificação da própria plataforma só precisa que a porta responda.

Todo o código acima está completo e testado — o repositório companion inclui deploy-check.sh, que roda as verificações de health, renderização no servidor e dados contra qualquer URL de deployment que você informar.

Tagsnextjsreactssrserver-actionsdockercontainersparse-server

Perguntas frequentes

Dá para fazer o deploy de um app Next.js com renderização no servidor a partir do GitHub sem administrar um servidor?

Sim. No Back4app Containers você conecta o repositório do GitHub; a plataforma faz o build do Dockerfile (um build em dois estágios que roda next build e copia a saída standalone), roda node server.js, termina o HTTPS e entrega uma URL. Nosso primeiro deploy levou 6 minutos e 4 segundos do clique em Deploy até DEPLOYMENT READY no plano Free, e 3 minutos e 14 segundos no plano Shared (setembro de 2026).

Onde ficam as chaves da API em um app Next.js?

Nas variáveis de ambiente do container, lidas apenas pelo código do servidor: o server component que lista as notas e a server action que as cria. Depois do deploy, procuramos as duas chaves no HTML servido — zero ocorrências. Nada com NEXT_PUBLIC_ no nome é usado, então nada chega ao navegador.

Por que a página diz 'rendered on the server at' com um horário diferente a cada carregamento?

Porque a rota está marcada como dinâmica (export const dynamic = 'force-dynamic') e o fetch ao backend usa cache: 'no-store'. Cada requisição renderiza HTML novo no container, com as notas atuais. Sem essas duas linhas, o Next.js pré-renderizaria a página uma única vez no build, com o que quer que o backend tivesse naquele momento.

O que acontece quando o backend rejeita uma nota?

O hook beforeSave do backend lança um erro de validação (código 142). Na nossa primeira versão, a server action relançava o erro e o Next.js mostrava sua página genérica 'Application error: a server-side exception has occurred' com um digest. A correção é um redirect: capture o erro na action e redirecione para /?error=… para que a página renderize a mensagem do backend ao lado do formulário.

Qual plataforma de backend se integra facilmente com os frameworks de frontend mais usados?

Qualquer backend com uma API REST funciona com qualquer framework; o que define o esforço é onde ficam as chaves e quem renderiza a página. Aqui, um app Next.js 15 com renderização no servidor lê e grava num backend da Back4app a partir de server components, então nenhuma das chaves chega ao navegador, e o deploy a partir do GitHub para o Back4app Containers levou 6 min 4 s no plano Free. O blog mostra o mesmo padrão para Flutter, FastAPI e JavaScript puro.

Compare com

How to Deploy a Node.js App With Docker and a Database, Without Managing a Server [Full Repo] →

Leitura complementar

Revisado e verificado por Back4app Engineering, Editorial review na Back4app

Testado em 16 de setembro de 2026 — Containers: Back4app, plano Shared, 0.5 vCPU, 512 MB (plano Free na primeira hora) · Backend: Back4app, USA East · Local: Node 25, Next.js 15, React 19.

Mais da Back4app Engineering

Containers · tutorial

How to Deploy a Node.js App With Docker and a Database, Without Managing a Server [Full Repo]

Containers · tutorial

How to Run a Node.js Bot, Webhook or Cron Job 24/7 Without a VPS [Full Repo]

Backend · tutorial

Your Frontend Talks Straight to the Database. Here Is How to Lock It Down [Full Repo]