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

URL: https://www.back4app.com/blog/pt/como-fazer-o-deploy-de-um-app-nextjs-ssr-com-docker-e-backend
Publicado: 2026-10-17 · Testado: 2026-09-16
Testado em: 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
Publisher: Back4app Engineering

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](https://www.back4app.com/blog/deploy-node-app-dockerfile-back4app), [FastAPI com Docker](https://www.back4app.com/blog/deploy-python-fastapi-dockerfile-database), [um worker sempre ligado](https://www.back4app.com/blog/run-a-bot-webhook-or-cron-24-7-without-a-vps).

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](https://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ê precisa | Por que não é opcional | Onde fica neste post |
|---|---|---|
| `node server.js` rodando | Server components executam por requisição | Container, seção 03 |
| HTTPS em uma URL pública | Navegadores recusam HTTP puro para formulários | Container, automático |
| Um banco de dados que você não opera | Os dados sobrevivem ao container | Backend, seção 04 |
| Validação fora do container | A action é descartável; as regras não | Backend, seção 05 |
| Chaves que nunca chegam ao navegador | Variá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.

## 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](/blog/blog-assets/deploy-nextjs-app-github-backend/architecture-diagram.svg)

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:

```javascript
// Stack: Next.js 15 App Router | File: app/page.js

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":

```javascript
// 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:

```dockerfile
# 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"]
```

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](https://www.back4app.com/blog/lock-down-a-backend-your-frontend-talks-to).

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:

## 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:

```javascript
// 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](/blog/blog-assets/deploy-nextjs-app-github-backend/02-deploy-form-v2.jpg)

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

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](/blog/blog-assets/deploy-nextjs-app-github-backend/05-live-app-v2.jpg)

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](/blog/blog-assets/deploy-nextjs-app-github-backend/06-note-added-v2.jpg)

## 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](/blog/blog-assets/deploy-nextjs-app-github-backend/09-backend-overview-v2.jpg)

**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](/blog/blog-assets/deploy-nextjs-app-github-backend/11-cloud-code-pending.jpg)

**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](/blog/blog-assets/deploy-nextjs-app-github-backend/10-database-note.jpg)

## 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](/blog/blog-assets/deploy-nextjs-app-github-backend/04-shared-plan-ready.jpg)

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](/blog/blog-assets/deploy-nextjs-app-github-backend/07-application-error-v2.jpg)

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](/blog/blog-assets/deploy-nextjs-app-github-backend/08-backend-message-v2.jpg)

**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](https://www.back4app.com/blog/add-signup-login-password-reset-without-an-auth-server)); 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](https://github.com/templates-back4app/nextjs-notes) 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.
