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

URL: https://www.back4app.com/blog/deploy-nextjs-app-github-backend
Published: 2026-09-28 · Tested: 2026-09-16
Tested on: 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
Publisher: Back4app Engineering

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

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](https://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 need | Why it is not optional | Where it lives in this post |
|---|---|---|
| `node server.js` running | Server components execute per request | Container, section 03 |
| HTTPS on a public URL | Browsers refuse plain HTTP for forms | Container, automatic |
| A database you don't run | Data outlives the container | Backend, section 04 |
| Validation outside the container | The action is disposable; the rules are not | Backend, section 05 |
| Keys that never reach the browser | Server-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.

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

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

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

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

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:

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

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

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:

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

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

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

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

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

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

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

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

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

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

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

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

**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](https://www.back4app.com/blog/add-signup-login-password-reset-without-an-auth-server)); 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](https://github.com/templates-back4app/nextjs-notes) includes `deploy-check.sh`, which runs health, server-render and data checks against any deployment URL you give it.
