How to Deploy a Next.js SSR App With Docker and a Backend [Full Repo]

A Next.js 15 notes app, pushed to GitHub, rendering on a container with its data in a managed backend. Measured end to end, including the six-minute first build and the 500 page our first version showed.

A browser window showing the deployed Next.js notes page exactly as served, rendered on the server at 2026-09-16T12:43:01.063Z, next to the three measured image build times for the same Dockerfile: 5 min 19 s, 3 min 04 s and 4 min 12 s

Your Next.js app renders on the server, and that is the whole point: the HTML arrives complete, with data in it. It also means the app needs somewhere to run that is not a CDN — a process that executes your components per request — and somewhere to keep the data that is not that process.

This post is about server-side rendering: a process that runs your components per request, a data source it reads at request time, and keys that stay on the server. A container runs next start for you, behind HTTPS. A backend holds the notes, validates them, and answers a REST call.

The other three shapes have their own posts: Node.js with Docker, FastAPI with Docker, an always-on worker.

We built a notes app — one server component that lists notes, one server action that adds them, 94 lines in total including the Dockerfile — pushed it to GitHub, and had it rendering on a container with the data off it.

The first deploy took 6 minutes 4 seconds, most of it next build. Everything below is from that run, on September 15–16, 2026, including the 500 page our first version showed.

Get the working code: the companion repo is at github.com/templates-back4app/nextjs-notes — app/page.js, app/actions.js, lib/backend.js, Dockerfile, cloud/main.js, and deploy-check.sh, which checks health, server rendering and data on any deployment URL.

What does a server-rendered app need that a static one does not?

A process, and a data source that process can reach. A static export is files on a CDN and needs neither; the moment a page reads data at request time, something has to run your code and something has to hold the data.

What you needWhy it is not optionalWhere it lives in this post
node server.js runningServer components execute per requestContainer, section 03
HTTPS on a public URLBrowsers refuse plain HTTP for formsContainer, automatic
A database you don’t runData outlives the containerBackend, section 04
Validation outside the containerThe action is disposable; the rules are notBackend, section 05
Keys that never reach the browserServer-only env vars, no NEXT_PUBLIC_Container env, section 03

Most hosts that run Next.js stop at the first two, and the data is a connection string to something else you pay for and maintain. Back4app is the one we tested because the second half is a product from the same account: a backend app with a managed database and a REST API, called from the container with two headers.

The browser gets HTML. The container gets two environment variables. The backend gets the data. Nobody gets a server.

What does the server-rendered notes app consist of?

A notes page. app/page.js is a server component: it calls the backend, renders the list and a form, and stamps the time it rendered. app/actions.js is a server action: the form posts to it, it creates the note in the backend, and the page re-renders. lib/backend.js is the only file that knows the keys.

Architecture: the browser loads HTML rendered by the Next.js container; the container's server component and server action call the Back4app backend over REST with the App ID and REST key; a Cloud Code hook validates every Note

The page. Two lines keep it dynamic — without them next build would prerender it once, with whatever the backend held during the 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>
  );
}

The backend client runs only on the server. cache: "no-store" is the second half of “per request”:

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

The Dockerfile is two stages. The first runs next build; the second copies only the standalone output, so the image has no node_modules beyond what the server needs:

# 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"]
Measured · nextjs-notes · September 2026
94lines, Dockerfile included
→
0keys in the served HTML
2env vars the container needs

You need a free Back4app account, a GitHub account and curl. No Docker on your machine: the platform builds the image, and this post was produced on a Mac without Docker installed. Node only for running the app locally first.

Where does the data come from?

From a backend app — Dashboard → New App → Build your Backend, name it, Create. The Overview page shows the App ID and, under the Keys dropdown, the REST API key. Those two values go into the container’s environment and nowhere else.

If your frontend called the backend directly instead, the key in the browser would be a client key and the class permissions would do the guarding: that case is its own post.

The Note class was created by the first POST — no schema beforehand (Parse Server 7.5.2, September 2026). Here is that write two ways: the one the server action makes, and the one you can make from your terminal with the same keys. Both showed up on the next page render:

// 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("/");
}

Same write, same backend. The page re-renders with either.

Why is the note rule a backend hook and not a server action?

The rule about what a note is. A server action runs only in this container and only for this form; the backend hook runs for the action, for the curl above, and for the next app that writes notes. Ours trims the text, rejects an empty note and caps it at 280 characters:

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

Paste it into Cloud Code → cloud/main.js in the backend dashboard, Deploy, and prove it with a request. Ours answered 400 {"code":142,"error":"a note needs some text."} to three spaces and 201 with the text already trimmed to a padded valid one — after the second deploy.

How do you deploy from GitHub?

Push the repo, then New App → Deploy from GitHub on the Containers side. The first time, GitHub asks you to install the Back4app Containers app on the repositories you choose; the list in the dashboard updates the moment you finish.

Configure. Select the repo. The form detected the Dockerfile and offered a Node.js buildpack as the alternative. We kept the Dockerfile, left the port on auto (it reads EXPOSE 8080), added PARSE_APP_ID and PARSE_REST_KEY, and set the health check to /healthz, a four-line route handler in the repo.

The deploy form for nextjs-notes: Dockerfile selected with a Node.js Buildpack alternative, two environment variables with masked values

Deploy. We clicked at 15:03:41. The button read “Deploying…” for 37 seconds before the app page appeared; then PREPARING at 15:04:18, BUILDING IMAGE for 5 minutes 19 seconds (npm ci, then next build inside kaniko), LAUNCHING CONTAINER at 15:09:37, DEPLOYMENT READY at 15:09:45.

Measured · first deploy · free plan · September 15, 2026
6 min 4 sDeploy click → DEPLOYMENT READY
→
5 min 19 sof that, npm ci + next build
47 MBRAM at idle, of 256

How we measured: a stopwatch from the Deploy click to DEPLOYMENT READY, free plan, one deploy on September 15, 2026; the three build times are three deploys of the same commit, read from the build log. next build dominates, so a larger app builds slower, and nothing is cached between builds.

deploy-check.sh then hit /healthz, loaded / and looked for rendered on the server at — present, with a timestamp that changed on every request. A note created through the REST API was on the page at the next load; nothing was cached between the two.

The deployed notes page: a text field, an Add button, two notes with their UTC timestamps, and the line 2 notes · rendered on the server at 2026-09-16T12:43:01.063Z

Then the form. A headless browser typed a note and clicked Add: the POST to the server action took 368 ms round trip, and the backend held the new row 300 ms after the click. We grepped the served HTML for both keys afterwards: zero matches.

The notes page right after a note was added through the form, the new note first in the list

Where do the notes live? The backend behind the page

Three screens in the backend dashboard, and every row on them was written by the container above — no seed data, no schema defined by hand.

Overview — where the two variables come from. Dashboard → New App → Build your Backend → Create. The Overview page shows the App ID and, under the Keys dropdown, the REST API key. Those two values go into the container’s environment and are read only by lib/backend.js.

The backend Overview for nextjs-notes: App ID, the Keys dropdown set to Rest Key (value blurred), Parse Server 7.5.2 and MongoDB 3.6 on the right

Cloud Code — where the rule lives. cloud/main.js open in the editor with the beforeSave("Note") hook, and the badge Files pending deploy (1) next to the Deploy button. On a fresh backend that badge is the tell: it stays after the first Deploy, which is how you know nothing shipped.

The Cloud Code editor with cloud/main.js open showing the beforeSave Note hook, and the badge Files pending deploy (1) next to the Deploy button

Database Browser — the Note class. Six rows: four added through the form (the server action), one written through the REST API from a terminal, and the first one, trimmed by the hook, which arrived padded with spaces. They are the same rows the page renders — the browser shows HTML, this shows where the HTML came from.

The Database Browser showing the Note class with six objects and their text column: four notes added through the form, one written through the REST API, one trimmed by the hook

How long does the URL last, and what does a redeploy cost?

On the free plan the URL and the container live 60 minutes from the moment the deploy starts — and a six-minute build eats six of them. We watched it happen: the log at 16:04:12 read The Back4app custom domain has expired for free plan, then COOLING DOWN…, FINISHING CONTAINER…, DEPLOYMENT DESTROYED at 16:05:14. The dashboard status turned to URL Expired.

The next morning we changed the plan to Shared (0.5 vCPU, 512 MB, $5/month as of September 16, 2026). Without a click on Deploy, the log showed PREPARING DEPLOYMENT at 12:35:42 and DEPLOYMENT READY at 12:38:57 on the same b4a.run URL — 3 minutes 14 seconds, the image build down from 5 min 19 s to 3 min 4 s.

The container Overview on the Shared plan: $5.00 per month, 0.5 vCPU and 512 MB, status Available, source GitHub main with auto, the v1.1.0 deployment Ready in 4m 35s

Measured · Shared plan · September 16, 2026
3 min 14 splan change → redeployed, same URL
→
4 min 34 sgit push → new version live (autodeploy)
$5per month, 0.5 vCPU · 512 MB

The paid plan also unlocks Autodeploy (Settings → Build & deploy). We turned it on and pushed the version with the error fix below at 13:02:55. PREPARING DEPLOYMENT was logged at 13:02:58 — before the push command had returned — and DEPLOYMENT READY at 13:07:34. The poller saw the new version 4 minutes 34 seconds after the push; the old container answered until the swap.

Why did Next.js show “Application error”, and what else broke?

We broke the app on purpose, and the platform broke it once for us. Each entry starts with what you will actually see.

Application error: a server-side exception has occurred while loading … (see the server logs for more information). Digest: 3998820666. Our first version. The backend rejected a blank note, createNote threw, the server action re-threw, and Next.js showed its generic error page — the digest maps to the stack trace in Runtime Logs. Catch the error in the action and redirect("/?error=…"); the page renders the backend’s message next to the form instead.

Next.js's generic error page: Application error: a server-side exception has occurred while loading nextjsnotes-sz7qfr45.b4a.run, with a digest

With the redirect in place (v1.1.0, deployed through autodeploy), the same blank submit answers 303 to /?error=a%20note%20needs%20some%20text. and the page shows the backend’s sentence under the form:

The notes page after a blank submit on v1.1.0: the message a note needs some text. in red under the form, the notes list intact

The page shows old notes after a write. The route is being prerendered or the fetch is cached. Both export const dynamic = "force-dynamic" on the page and cache: "no-store" on the fetch are needed; revalidatePath("/") in the action is what makes the redirect land on fresh data.

Every page load is a 500, Runtime Logs say ⨯ Error: backend 403: {"error":"unauthorized"}. A wrong PARSE_REST_KEY — listNotes() throws inside the server component, so the visitor gets the generic error page. /healthz stays green because it does not touch the backend; make it call listNotes() so your monitoring sees the bad key first.

Build takes minutes and the free hour is ticking. The 60 minutes start at PREPARING DEPLOYMENT, before the build. Our six-minute first build left 54 minutes of runtime. Test in that window; run on a paid plan.

Success on deploying your changes! but the hook does nothing. The first Cloud Code deploy on a fresh backend ships no files. Look at Logs → System for main.js not found, edit the file in the editor, deploy again.

When is a container the wrong answer for Next.js?

Per our own manifesto, a post where only the platform wins does not get published — so here is the other column.

  • Your pages are static. A blog, a docs site, a marketing page with no per-request data: output: "export" and a CDN are cheaper, faster and need no process at all.
  • You need edge rendering or image optimization at the CDN. This is one container in one region; there is no edge runtime and next/image optimization runs on the container’s CPU.
  • You need Postgres features. Joins, window functions, a schema you migrate — the backend is Parse Server on MongoDB (Postgres on paid plans), queried with where JSON, not SQL.
  • Six-minute builds hurt. Every push rebuilds the image from npm ci on; if you deploy twenty times a day, a host that caches node_modules between builds will feel faster.

If none of those describe you, a server-rendered Next.js container in front of a managed backend is the shape we would choose again — and the two headers are the whole integration.

What would you build on the notes app next?

You have a page that renders per request, a form that writes through the server, a database you never provisioned, and keys the browser never sees. From here:

  • Give notes owners. Parse’s User class adds signup and login endpoints (measured, with password reset); store the session token in an httpOnly cookie and pass it as X-Parse-Session-Token from lib/backend.js.
  • Show errors without a redirect. useActionState in a client component keeps the form state and the message in place; the redirect version above needs no client JavaScript at all.
  • Make the health check honest. Have /healthz call listNotes() so a bad key shows up in your monitoring, not at the first visitor — the platform’s own check only needs the port to answer.

All code above is complete and tested — the companion repo includes deploy-check.sh, which runs health, server-render and data checks against any deployment URL you give it.

Tagsnextjsreactssrserver-actionsdockercontainersparse-server

Frequently asked questions

Can I deploy a Next.js app with server-side rendering from GitHub without managing a server?

Yes. On Back4app Containers you connect the GitHub repository; the platform builds the Dockerfile (a two-stage build that runs next build and copies the standalone output), runs node server.js, terminates HTTPS and gives you a URL. Our first deploy took 6 minutes 4 seconds from the Deploy click to DEPLOYMENT READY on the free plan, 3 minutes 14 seconds on the Shared plan (September 2026).

Where do the API keys live in a Next.js app?

In the container's environment variables, read only by server code: the server component that lists notes and the server action that creates them. We checked the served HTML for both keys after deploying — zero occurrences. Nothing with NEXT_PUBLIC_ in its name is used, so nothing reaches the browser.

Why does the page say 'rendered on the server at' a different time on every load?

Because the route is marked dynamic (export const dynamic = 'force-dynamic') and the fetch to the backend uses cache: 'no-store'. Every request renders fresh HTML on the container with the current notes. Without those two lines Next.js would prerender the page once at build time, with whatever the backend held during the build.

What happens when the backend rejects a note?

The backend's beforeSave hook throws a validation error (code 142). In our first version the server action re-threw it and Next.js showed its generic 'Application error: a server-side exception has occurred' page with a digest. The fix is one redirect: catch the error in the action and redirect to /?error=… so the page renders the backend's message next to the form.

Compare with

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

Further reading

Reviewed & verified by Back4app Engineering, Editorial review at Back4app

Tested Sep 16, 2026 — Containers: Back4app, Shared plan, 0.5 vCPU, 512 MB (free plan for the first hour) · Backend: Back4app, USA East · Local: Node 25, Next.js 15, React 19.

More from 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]