Cómo desplegar una app Next.js con SSR usando Docker y un backend

Una app de notas en Next.js 15, subida a GitHub, renderizando en un container con sus datos en un backend gestionado. Medida de punta a punta, incluidos el primer build de seis minutos y la página 500 que mostró nuestra primera versión.

Una ventana del navegador que muestra la página de notas de Next.js desplegada tal como se sirvió, renderizada en el servidor el 2026-09-16T12:43:01.063Z, junto a los tres tiempos medidos de build de la imagen para el mismo Dockerfile: 5 min 19 s, 3 min 04 s y 4 min 12 s

Traducción del original en inglés, probado el 16 de septiembre de 2026. El código, los números y las fechas son los del original. Read in English

Tu app Next.js se renderiza en el servidor, y esa es justamente la gracia: el HTML llega completo, con los datos dentro. También significa que la app necesita un sitio donde ejecutarse que no sea una CDN (un proceso que ejecute tus componentes en cada solicitud) y un sitio donde guardar los datos que no sea ese proceso.

Este post trata del renderizado en el servidor: un proceso que ejecuta tus componentes por solicitud, una fuente de datos que lee en el momento de la solicitud y claves que se quedan en el servidor. Un container ejecuta next start por ti, detrás de HTTPS. Un backend guarda las notas, las valida y responde a una llamada REST.

Las otras tres formas tienen su propio post: Node.js con Docker, FastAPI con Docker, un worker siempre activo.

Construimos una app de notas (un server component que lista las notas, una server action que las añade, 94 líneas en total con el Dockerfile incluido), la subimos a GitHub y la pusimos a renderizar en un container con los datos fuera de él.

El primer deploy tardó 6 minutos 4 segundos, la mayor parte en next build. Todo lo que sigue sale de esa ejecución, el 15 y el 16 de septiembre de 2026, incluida la página 500 que mostró nuestra primera versión.

Consigue el código funcionando: el repositorio companion está en github.com/templates-back4app/nextjs-notes — app/page.js, app/actions.js, lib/backend.js, Dockerfile, cloud/main.js y deploy-check.sh, que comprueba el health check, el renderizado en el servidor y los datos en cualquier URL de deploy.

¿Qué necesita una app renderizada en el servidor que una estática no?

Un proceso, y una fuente de datos a la que ese proceso pueda llegar. Un export estático son archivos en una CDN y no necesita ninguna de las dos cosas; en cuanto una página lee datos en el momento de la solicitud, algo tiene que ejecutar tu código y algo tiene que guardar los datos.

Qué necesitasPor qué no es opcionalDónde vive en este post
node server.js en ejecuciónLos server components se ejecutan por solicitudContainer, sección 03
HTTPS en una URL públicaLos navegadores rechazan HTTP plano para formulariosContainer, automático
Una base de datos que no administras túLos datos sobreviven al containerBackend, sección 04
Validación fuera del containerLa action es desechable; las reglas, noBackend, sección 05
Claves que nunca llegan al navegadorVariables de entorno solo de servidor, sin NEXT_PUBLIC_Entorno del container, sección 03

La mayoría de los hosts que ejecutan Next.js se quedan en las dos primeras filas, y los datos son una cadena de conexión a otra cosa que pagas y mantienes tú. Back4app es el que probamos porque la segunda mitad es un producto de la misma cuenta: una app de backend con base de datos gestionada y una REST API, llamada desde el container con dos headers.

El navegador recibe HTML. El container recibe dos variables de entorno. El backend recibe los datos. Nadie recibe un servidor.

¿De qué se compone la app de notas renderizada en el servidor?

Una página de notas. app/page.js es un server component: llama al backend, renderiza la lista y un formulario, y estampa la hora en que se renderizó. app/actions.js es una server action: el formulario le hace el POST, ella crea la nota en el backend y la página se vuelve a renderizar. lib/backend.js es el único archivo que conoce las claves.

Arquitectura: el navegador carga el HTML renderizado por el container de Next.js; el server component y la server action del container llaman al backend de Back4app por REST con el App ID y la REST key; un hook de Cloud Code valida cada Note

La página. Dos líneas la mantienen dinámica; sin ellas, next build la prerenderizaría una sola vez, con lo que el backend tuviera durante el 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>
  );
}

El cliente del backend se ejecuta solo en el servidor. cache: "no-store" es la otra mitad de “por solicitud”:

// 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;
}

El Dockerfile tiene dos etapas. La primera ejecuta next build; la segunda copia solo la salida standalone, así que la imagen no lleva más node_modules que los que necesita el servidor:

# 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 · septiembre de 2026
94líneas, Dockerfile incluido
→
0claves en el HTML servido
2variables de entorno que necesita el container

Necesitas una cuenta gratuita de Back4app, una cuenta de GitHub y curl. Docker en tu máquina, no: la plataforma construye la imagen, y este post se produjo en un Mac sin Docker instalado. Node solo hace falta para ejecutar la app en local antes.

¿De dónde salen los datos?

De una app de backend: Dashboard → New App → Build your Backend, ponle nombre, Create. La página Overview muestra el App ID y, en el desplegable Keys, la REST API key. Esos dos valores van al entorno del container y a ningún otro sitio.

Si tu frontend llamara al backend directamente, la clave en el navegador sería una clave de cliente y los permisos de la clase harían de guardia: ese caso tiene su propio post.

La clase Note la creó el primer POST, sin schema previo (Parse Server 7.5.2, septiembre de 2026). Esta es esa escritura de dos formas: la que hace la server action y la que puedes hacer desde tu terminal con las mismas claves. Las dos aparecieron en el siguiente render de la 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("/");
}

La misma escritura, el mismo backend. La página se vuelve a renderizar con cualquiera de las dos.

¿Por qué la regla de las notas es un hook del backend y no una server action?

Porque la regla define qué es una nota, no qué hace este formulario. Una server action se ejecuta solo en este container y solo para este formulario; el hook del backend se ejecuta para la action, para el curl de arriba y para la próxima app que escriba notas. El nuestro recorta el texto, rechaza una nota vacía y la 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);
});

Pégalo en Cloud Code → cloud/main.js en la dashboard del backend, Deploy, y demuéstralo con una solicitud. El nuestro respondió 400 {"code":142,"error":"a note needs some text."} a tres espacios, y 201, con el texto ya recortado, a una nota válida rellena de espacios; eso sí, después del segundo deploy.

¿Cómo se despliega desde GitHub?

Sube el repositorio y después New App → Deploy from GitHub en el lado de Containers. La primera vez, GitHub te pide instalar la app de Back4app Containers en los repositorios que elijas; la lista de la dashboard se actualiza en cuanto terminas.

Configurar. Selecciona el repositorio. El formulario detectó el Dockerfile y ofreció un Node.js buildpack como alternativa. Mantuvimos el Dockerfile, dejamos el puerto en automático (lee EXPOSE 8080), añadimos PARSE_APP_ID y PARSE_REST_KEY y pusimos el health check en /healthz, un route handler de cuatro líneas en el repositorio.

El formulario de deploy de nextjs-notes: Dockerfile seleccionado con Node.js Buildpack como alternativa, dos variables de entorno con los valores ocultos

Desplegar. Hicimos clic a las 15:03:41. El botón mostró “Deploying…” durante 37 segundos antes de que apareciera la página de la app; después PREPARING a las 15:04:18, BUILDING IMAGE durante 5 minutos 19 segundos (npm ci y luego next build dentro de kaniko), LAUNCHING CONTAINER a las 15:09:37 y DEPLOYMENT READY a las 15:09:45.

Medido · primer deploy · plan Free · 15 de septiembre de 2026
6 min 4 sclic en Deploy → DEPLOYMENT READY
→
5 min 19 sde ellos, npm ci + next build
47 MBde RAM en reposo, de 256

Cómo lo medimos: un cronómetro desde el clic en Deploy hasta DEPLOYMENT READY, plan Free, un deploy el 15 de septiembre de 2026; los tres tiempos de build son tres deploys del mismo commit, leídos del log de build. next build domina, así que una app más grande construye más despacio, y nada se cachea entre builds.

deploy-check.sh llamó después a /healthz, cargó / y buscó rendered on the server at: presente, con una marca de tiempo que cambiaba en cada solicitud. Una nota creada a través de la REST API estaba en la página en la siguiente carga; nada se cacheó entre las dos.

La página de notas desplegada: un campo de texto, un botón Add, dos notas con sus marcas de tiempo UTC y la línea 2 notes · rendered on the server at 2026-09-16T12:43:01.063Z

Después, el formulario. Un navegador headless escribió una nota e hizo clic en Add: el POST a la server action tardó 368 ms de ida y vuelta, y el backend tenía la fila nueva 300 ms después del clic. A continuación buscamos las dos claves en el HTML servido con grep: cero coincidencias.

La página de notas justo después de añadir una nota desde el formulario, con la nota nueva la primera de la lista

¿Dónde viven las notas? El backend detrás de la página

Tres pantallas de la dashboard del backend, y cada fila que aparece en ellas la escribió el container de arriba: sin datos de semilla, sin schema definido a mano.

Overview: de dónde salen las dos variables. Dashboard → New App → Build your Backend → Create. La página Overview muestra el App ID y, en el desplegable Keys, la REST API key. Esos dos valores van al entorno del container y solo los lee lib/backend.js.

El Overview del backend de nextjs-notes: App ID, el desplegable Keys en Rest Key (valor difuminado), Parse Server 7.5.2 y MongoDB 3.6 a la derecha

Cloud Code: donde vive la regla. cloud/main.js abierto en el editor con el hook beforeSave("Note"), y la insignia Files pending deploy (1) junto al botón Deploy. En un backend recién creado esa insignia es la pista: sigue ahí después del primer Deploy, y así sabes que no se envió nada.

El editor de Cloud Code con cloud/main.js abierto mostrando el hook beforeSave de Note, y la insignia Files pending deploy (1) junto al botón Deploy

Database Browser: la clase Note. Seis filas: cuatro añadidas desde el formulario (la server action), una escrita a través de la REST API desde una terminal y la primera, recortada por el hook, que llegó rellena de espacios. Son las mismas filas que renderiza la página: el navegador muestra HTML, y esto muestra de dónde salió ese HTML.

El Database Browser mostrando la clase Note con seis objetos y su columna text: cuatro notas añadidas desde el formulario, una escrita a través de la REST API y una recortada por el hook

¿Cuánto dura la URL y cuánto cuesta un redeploy?

En el plan Free, la URL y el container viven 60 minutos desde que empieza el deploy, y un build de seis minutos se come seis de ellos. Lo vimos ocurrir: el log a las 16:04:12 decía The Back4app custom domain has expired for free plan, después COOLING DOWN…, FINISHING CONTAINER… y DEPLOYMENT DESTROYED a las 16:05:14. El estado en la dashboard pasó a URL Expired.

A la mañana siguiente cambiamos el plan a Shared (0,5 vCPU, 512 MB, US$ 5 al mes a 16 de septiembre de 2026). Sin ningún clic en Deploy, el log mostró PREPARING DEPLOYMENT a las 12:35:42 y DEPLOYMENT READY a las 12:38:57 en la misma URL b4a.run: 3 minutos 14 segundos, con el build de la imagen bajando de 5 min 19 s a 3 min 4 s.

El Overview del container en el plan Shared: US$ 5,00 al mes, 0,5 vCPU y 512 MB, estado Available, origen GitHub main con auto, el deployment v1.1.0 Ready en 4m 35s

Medido · plan Shared · 16 de septiembre de 2026
3 min 14 scambio de plan → redesplegado, misma URL
→
4 min 34 sgit push → versión nueva en vivo (autodeploy)
US$ 5al mes, 0,5 vCPU · 512 MB

El plan de pago también desbloquea Autodeploy (Settings → Build & deploy). Lo activamos y subimos la versión con la corrección del error de más abajo a las 13:02:55. PREPARING DEPLOYMENT quedó registrado a las 13:02:58, antes de que el comando push hubiera terminado, y DEPLOYMENT READY a las 13:07:34.

Nuestra sonda vio la versión nueva 4 minutos 34 segundos después del push; el container antiguo siguió respondiendo hasta el cambio.

¿Por qué Next.js mostró “Application error” y qué más se rompió?

Rompimos la app a propósito, y la plataforma la rompió una vez por nosotros. Cada entrada empieza por lo que vas a ver de verdad.

Application error: a server-side exception has occurred while loading … (see the server logs for more information). Digest: 3998820666. Nuestra primera versión. El backend rechazó una nota en blanco, createNote lanzó el error, la server action lo relanzó y Next.js mostró su página de error genérica; el digest corresponde al stack trace en Runtime Logs. Captura el error en la action y haz redirect("/?error=…"); la página mostrará entonces el mensaje del backend junto al formulario.

La página de error genérica de Next.js: Application error: a server-side exception has occurred while loading nextjsnotes-sz7qfr45.b4a.run, con un digest

Con la redirección en su sitio (v1.1.0, desplegada mediante autodeploy), el mismo envío en blanco responde 303 a /?error=a%20note%20needs%20some%20text. y la página muestra la frase del backend bajo el formulario:

La página de notas tras un envío en blanco en la v1.1.0: el mensaje a note needs some text. en rojo bajo el formulario, con la lista de notas intacta

La página muestra notas antiguas después de una escritura. La ruta se está prerenderizando o el fetch está cacheado. Hacen falta las dos cosas: export const dynamic = "force-dynamic" en la página y cache: "no-store" en el fetch; revalidatePath("/") en la action es lo que hace que la redirección aterrice en datos frescos.

Cada carga de página es un 500 y Runtime Logs dice ⨯ Error: backend 403: {"error":"unauthorized"}. Una PARSE_REST_KEY incorrecta: listNotes() lanza el error dentro del server component, así que el visitante recibe la página de error genérica.

/healthz sigue en verde porque no toca el backend; haz que llame a listNotes() para que tu monitorización vea la clave incorrecta antes que nadie.

El build tarda minutos y la hora gratuita corre. Los 60 minutos empiezan en PREPARING DEPLOYMENT, antes del build. Nuestro primer build de seis minutos dejó 54 minutos de ejecución. Prueba en esa ventana; ejecuta en un plan de pago.

Success on deploying your changes! pero el hook no hace nada. El primer deploy de Cloud Code en un backend recién creado no envía ningún archivo. Busca main.js not found en Logs → System, edita el archivo en el editor y despliega otra vez.

¿Cuándo es un container la respuesta equivocada para Next.js?

Según nuestro propio manifiesto, un post en el que solo gana la plataforma no se publica, así que aquí está la otra columna.

  • Tus páginas son estáticas. Un blog, un sitio de documentación, una página de marketing sin datos por solicitud: output: "export" y una CDN son más baratos, más rápidos y no necesitan ningún proceso.
  • Necesitas renderizado en el edge u optimización de imágenes en la CDN. Esto es un container en una región; no hay runtime de edge y la optimización de next/image corre en la CPU del container.
  • Necesitas funciones de Postgres. Joins, funciones de ventana, un schema que migras: el backend es Parse Server sobre MongoDB (Postgres en los planes de pago), consultado con JSON en where, no con SQL.
  • Los builds de seis minutos duelen. Cada push reconstruye la imagen desde npm ci; si despliegas veinte veces al día, un host que cachee node_modules entre builds se sentirá más rápido.

Si ninguno de esos casos es el tuyo, un container Next.js renderizado en el servidor delante de un backend gestionado es la forma que elegiríamos otra vez, y los dos headers son toda la integración.

¿Qué construirías a continuación sobre la app de notas?

Tienes una página que se renderiza por solicitud, un formulario que escribe a través del servidor, una base de datos que nunca aprovisionaste y claves que el navegador nunca ve. A partir de aquí:

  • Dale propietarios a las notas. La clase User de Parse añade endpoints de registro e inicio de sesión (medido, con restablecimiento de contraseña); guarda el session token en una cookie httpOnly y pásalo como X-Parse-Session-Token desde lib/backend.js.
  • Muestra los errores sin redirección. useActionState en un client component mantiene en su sitio el estado del formulario y el mensaje; la versión con redirección de arriba no necesita JavaScript en el cliente.
  • Haz honesto el health check. Haz que /healthz llame a listNotes() para que una clave incorrecta aparezca en tu monitorización y no ante el primer visitante; la comprobación de la plataforma solo necesita que el puerto responda.

Todo el código de arriba está completo y probado: el repositorio companion incluye deploy-check.sh, que ejecuta las comprobaciones de health, renderizado en el servidor y datos contra cualquier URL de deploy que le pases.

Etiquetasnextjsreactssrserver-actionsdockercontainersparse-server

Preguntas frecuentes

¿Puedo desplegar una app Next.js con renderizado en el servidor desde GitHub sin administrar un servidor?

Sí. En Back4app Containers conectas el repositorio de GitHub; la plataforma construye el Dockerfile (un build en dos etapas que ejecuta next build y copia la salida standalone), ejecuta node server.js, termina el HTTPS y te da una URL. Nuestro primer deploy tardó 6 minutos 4 segundos desde el clic en Deploy hasta DEPLOYMENT READY en el plan Free, y 3 minutos 14 segundos en el plan Shared (septiembre de 2026).

¿Dónde viven las claves de la API en una app Next.js?

En las variables de entorno del container, y solo las lee el código del servidor: el server component que lista las notas y la server action que las crea. Después del deploy buscamos las dos claves en el HTML servido: cero coincidencias. No se usa nada con NEXT_PUBLIC_ en el nombre, así que nada llega al navegador.

¿Por qué la página dice 'rendered on the server at' con una hora distinta en cada carga?

Porque la ruta está marcada como dinámica (export const dynamic = 'force-dynamic') y el fetch al backend usa cache: 'no-store'. Cada solicitud renderiza HTML nuevo en el container con las notas actuales. Sin esas dos líneas, Next.js prerenderizaría la página una sola vez durante el build, con lo que el backend tuviera en ese momento.

¿Qué pasa cuando el backend rechaza una nota?

El hook beforeSave del backend lanza un error de validación (código 142). En nuestra primera versión la server action lo relanzaba y Next.js mostraba su página genérica 'Application error: a server-side exception has occurred' con un digest. La solución es una redirección: captura el error en la action y redirige a /?error=… para que la página muestre el mensaje del backend junto al formulario.

¿Qué plataforma de backend se integra fácilmente con los frameworks de frontend más populares?

Cualquier backend con una API REST funciona con cualquier framework; lo que define el esfuerzo es dónde viven las claves y quién renderiza la página. Aquí, una app Next.js 15 con renderizado en el servidor lee y escribe en un backend de Back4app desde server components, así que ninguna clave llega al navegador, y el deploy desde GitHub a Back4app Containers tardó 6 min 4 s en el plan Free. El blog cubre el mismo patrón para Flutter, FastAPI y JavaScript puro.

Compara con

Cómo desplegar una app Node.js con Docker y una base de datos, sin administrar un servidor →

Lecturas complementarias

Revisado y verificado por Back4app Engineering, Editorial review en Back4app

Probado el 16 de septiembre de 2026 — Containers: Back4app, plan Shared, 0,5 vCPU, 512 MB (plan Free durante la primera hora) · Backend: Back4app, USA East · Local: Node 25, Next.js 15, React 19.

Más de Back4app Engineering

Containers · tutorial

Cómo desplegar una app Node.js con Docker y una base de datos, sin administrar un servidor

Containers · tutorial

Cómo ejecutar un bot, un webhook o un cron job de Node.js 24/7 sin VPS

Backend · tutorial

Tu frontend habla directamente con la base de datos. Así proteges el backend