Can you run Next with SSR wholly under DigitalOcean App Platform

How to deploy Next.js SSR apps on DigitalOcean App Platform

Run your Next.js app with full server-side rendering on DigitalOcean App Platform without juggling Droplets, reverse proxies, or manual scaling—this guide shows you exactly how to build, configure, and deploy for a seamless, production-ready experience.

Can you run Next.js with SSR wholly under DigitalOcean App Platform? The answer is yes! App Platform gives you a managed Node.js runtime, buildpacks or Docker builds, HTTPS by default, scaling, logs, and environment variable management. From Next.js’s point of view, it’s a regular self-hosted Node server, so next start (or a “standalone” server entry) works and you retain SSR, Route Handlers, middleware, API routes, and Incremental Static Regeneration (ISR).

In this in-depth explainer, you’ll wire it up end-to-end, see both buildpack and Docker flows, understand caching and the ephemeral filesystem, and pick up practical run command patterns that match modern Next.js (15.x at the time of writing).

If you want to follow along, make sure you have a DigitalOcean account ready and also a GitHub repo with a demo application, or a Docker container that you can pull.

What “wholly under App Platform” actually means

When you deploy a Next.js app as a “Web Service” in App Platform, DigitalOcean builds your code either with Cloud Native Buildpacks (zero-Docker) or from your Dockerfile and then runs your Node server behind their managed HTTPS load balancer. You don’t need a Droplet, Nginx, or PM2. The service process is just your Node runtime starting Next.js in production mode, and the platform handles TLS, routing, scaling, and rollouts.

DigitalOcean App Platform comes with built-in support for popular web frameworks and languages, while also giving you the freedom to deploy any custom framework using your own Dockerfile.

If your app relies on files written at runtime (for example, image uploads or SQLite databases), treat the container’s disk as temporary. Use Spaces (S3-compatible), Postgres, MySQL, Redis, or an external service for persistence. This is a standard PaaS constraint and it matters for ISR caches too; we’ll walk through it below.

The high-level deployment map

From a developer’s perspective, the minimal flow is straightforward. You push your Next.js repo, App Platform installs dependencies, runs your build command, and then executes your run command to boot the production server. You can set both commands in the UI or in an app spec file. The Node version is inferred from your package.json engines or pinned by buildpacks; keep it current for best performance and compatibility with Next.js 15.x.

The rest of this article expands that minimal flow with exact commands, variants for “standalone” output, how to pass environment variables, and what to expect from caching, ISR, middleware, and websockets.

A clean buildpack setup for SSR

If you want the simplest path with zero Docker, use buildpacks. Your package.json should have explicit build and start scripts that match what you run locally in production.

{
  "name": "my-next-app",
  "private": true,
  "engines": {
    "node": ">=20 <=22.x"
  },
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start -p ${PORT:-3000}"
  },
  "dependencies": {
    "next": "15.5.0",
    "react": "19.0.0-rc-2b0000000-20250801",
    "react-dom": "19.0.0-rc-2b0000000-20250801"
  }
}

On App Platform, set your Build Command to npm run build and your Run Command to npm run start. The platform injects PORT, so binding to process.env.PORT is crucial when you override the default port in Next.js.

This flow gives you the standard Next.js production server, which supports SSR, Route Handlers (app/api or pages/api), middleware, and ISR.

“Standalone” output and the alternative run command

Next.js can emit a compact, production-ready “standalone” server bundle that includes only the traced files your app needs. This reduces cold start times and avoids shipping a full node_modules to production.

Enable it in your Next config:

// next.config.js or next.config.mjs
const nextConfig = {
  output: 'standalone'
};

module.exports = nextConfig;

After next build, you’ll have .next/standalone/ with a server entry. In that mode, you typically run the server directly with Node:

node .next/standalone/server.js

If you adopt this, set the Run Command to node .next/standalone/server.js instead of next start. Your Build Command remains npm run build. Both modes deliver full SSR; the standalone mode just optimizes your server image and startup.

An app spec you can copy, understand, and evolve

If you prefer to keep infra as code, define your App Platform component in an app spec file and apply it with doctl or via the UI. Here’s a focused spec that deploys a single Next.js web service using buildpacks and the regular Next.js server. It maps the commands and environment clearly so you can extend it later.

# .do/app.yaml
name: my-next-app
services:
  - name: web
    http_port: 3000
    instance_count: 1
    instance_size_slug: basic-xxs
    source_dir: /
    git:
      repo_clone_url: https://github.com/your-org/your-repo.git
      branch: main
      deploy_on_push: true
    build_command: "npm ci && npm run build"
    run_command: "npm run start"
    envs:
      - key: NODE_ENV
        value: production
        scope: RUN_TIME
      - key: NEXT_PUBLIC_API_BASE_URL
        value: https://api.example.com
        scope: RUN_TIME
      - key: NEXT_RUNTIME_STATIC
        value: "false"
        scope: BUILD_TIME

You can switch to the standalone server by changing run_command to node .next/standalone/server.js and keeping the rest the same.

A Dockerfile that’s production-grade and small

When you need deterministic builds, private OS packages, or multi-stage caching, use Docker. This example emits a small final image and supports both classic next start and output: 'standalone'. It installs only production dependencies and uses a non-root user.

# Stage 1: deps
FROM node:lts-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --include=dev

# Stage 2: build
FROM node:lts-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# For standalone builds, ensure next.config output: 'standalone'
RUN npm run build

# Stage 3: runtime
FROM node:lts-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
# If using standalone, copy the traced output; otherwise copy .next and node_modules
# Standalone:
COPY --from=build /app/.next/standalone ./ 
COPY --from=build /app/public ./public
# Classic server (uncomment this block and comment the standalone block if you prefer):
# COPY --from=build /app/.next ./.next
# COPY --from=build /app/node_modules ./node_modules
# COPY --from=build /app/public ./public
# COPY --from=build /app/package.json ./package.json

# Non-root user for security
RUN addgroup -S nextjs && adduser -S nextjs -G nextjs
USER nextjs

# Standalone server entry:
CMD ["node", "server.js"]
# For classic server instead, use:
# CMD ["npm", "run", "start"]

In your app spec, set dockerfile_path: .do/Dockerfile (or wherever it lives) and omit the build/run commands; App Platform will use your container’s CMD.

Confirming SSR with an explicit Route Handler

To ensure you’re using server rendering, create a server-only Route Handler and a server component that fetches it on each request. This is intentionally simple and self-contained.

// app/api/time/route.ts
import { NextResponse } from 'next/server';

export const dynamic = 'force-dynamic'; // always render on the server

export async function GET() {
  return NextResponse.json({ now: new Date().toISOString() });
}
// app/page.tsx
export const dynamic = 'force-dynamic';

export default async function Home() {
  const res = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/time`, { cache: 'no-store' });
  const { now } = await res.json();

  return (
    <main style={{ padding: 24 }}>
      <h1>SSR is live</h1>
      <p>Server time: {now}</p>
    </main>
  );
}

On App Platform, set NEXT_PUBLIC_BASE_URL to your live URL. If you refresh the page repeatedly, the timestamp changes and you know SSR and server handlers are working behind the platform’s proxy.

ISR and the ephemeral filesystem

By default, Next.js stores the shared cache for generated static pages, responses, and other build outputs on the server’s filesystem when you self-host. App Platform containers are ephemeral: they can be replaced during deploys or scaling. That means any on-disk cache may be lost when the container is recreated.

You still get ISR benefits while a container is running, but don’t rely on that cache surviving a redeploy. If you need cross-instance or cross-deploy persistence, plug in a remote cache or push revalidation through an API that can recompute quickly. A pragmatic pattern is to use on-demand revalidation endpoints and a backing database or store for the canonical data.

Here’s a minimal revalidation handler that you can call after publishing content:

// app/api/revalidate/route.ts
import { NextResponse } from 'next/server';
import { revalidatePath } from 'next/cache';

export async function POST(req: Request) {
  const { path, secret } = await req.json();
  if (secret !== process.env.REVALIDATE_SECRET) {
    return NextResponse.json({ ok: false }, { status: 401 });
  }
  revalidatePath(path);
  return NextResponse.json({ ok: true, revalidated: path });
}

Set REVALIDATE_SECRET in App Platform. When your CMS webhook fires, it can POST to this endpoint to refresh paths on demand, even across new containers.

Middleware, headers, and edge-like logic

Next.js middleware runs within the Node server in self-hosting scenarios. App Platform routes all requests over HTTPS, so you can rely on wss:// for websockets and read X-Forwarded-Proto/X-Forwarded-For for original scheme/IP if you need them. A concise middleware.ts that enforces HTTPS and sets headers looks like this:

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(req: NextRequest) {
  const res = NextResponse.next();

  // Example: security headers
  res.headers.set('X-Frame-Options', 'SAMEORIGIN');
  res.headers.set('X-Content-Type-Options', 'nosniff');

  return res;
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)']
};

Deploy and you can verify headers on any page. Because this runs in your Node process, there’s nothing special to enable in App Platform.

WebSockets on App Platform with Next.js

If you expose a websocket server from your Next.js Node process, connect with wss:// from the browser because App Platform terminates TLS at the edge. A small websocket server can live alongside Next.js:

// server.ts (only if you run a custom server entry)
import http from 'http';
import next from 'next';
import { WebSocketServer } from 'ws';

const dev = process.env.NODE_ENV !== 'production';
const app = next({ dev });
const handle = app.getRequestHandler();

const port = Number(process.env.PORT || 3000);

app.prepare().then(() => {
  const server = http.createServer((req, res) => handle(req, res));
  const wss = new WebSocketServer({ server });

  wss.on('connection', (ws) => {
    ws.send(JSON.stringify({ hello: 'from server' }));
  });

  server.listen(port, () => {
    console.log(`Ready on http://localhost:${port}`);
  });
});

In Docker or standalone mode you’d CMD ["node","server.js"] and your client connects to wss://YOUR_APP_DOMAIN/ws (or wherever you attach it). For simple apps, the built-in Next.js server is enough; switch to a custom entry like this only when you need fine control.

Environment variables, build-time vs run-time

App Platform distinguishes variables available during the build and those present at runtime. Next.js reads .env* files locally, but in production you should define variables in the platform UI or in the app spec and mark sensitive values as encrypted. If a value must be available to the bundler (for example, feature flags that change what’s compiled), mark it as build-time; otherwise scope it to runtime. Public variables that the browser must see should be prefixed with NEXT_PUBLIC_ and scoped to runtime.

In the app spec example above, NEXT_PUBLIC_API_BASE_URL is a runtime variable. A build-time variable might flip an experiment or include a static asset path during bundling.

Node versions and Next.js 15.x

Next.js 15.x supports modern Node LTS lines. App Platform’s Node buildpack tracks current LTS by default and you can pin an exact range using the engines.node field in package.json. In production, aim for Node 20 or 22 LTS for best alignment with Next.js releases, React 19 support, and contemporary middleware features. If you see mismatches, the fix is usually to declare engines and rebuild.

Scaling, health, and zero-downtime deploys

App Platform can horizontally scale your web service to multiple instances. Because instances are stateless, never store session state on disk or memory that you expect to survive restarts or scale-out. Use cookies with signed JWTs, Redis, or your database. Deploys are rolling and you can watch the Deployments tab to see the build and live cutover; failed builds won’t replace the current healthy version.

Troubleshooting the “runs locally, 404 in prod” class of errors

If your app reaches the platform but something is off in production, check these first. Ensure that your Run Command actually binds the injected PORT, otherwise the health check fails and the app restarts. Confirm environment variables are in the right scope for build or runtime. If you switched to output: 'standalone', make sure you’re using node .next/standalone/server.js and that any files outside the project root that you need were included via experimental.outputFileTracingIncludes or by copying them in your Dockerfile.

For ISR surprises after redeploy, remember that the shared cache on disk is not persistent. Warm critical pages on deploy, or trigger on-demand revalidation from your CMS, to smooth over cold starts.

A minimal “doctl” workflow you can repeat

If you prefer command-line deployments with a spec, this small script shows how to create and then update the app consistently. You can paste this into your CI with environment variables for the app ID and paths.

# First-time: create the app from a spec file
doctl apps create --spec .do/app.yaml --format ID,DefaultIngress,Created

# Subsequent updates: apply changes to the spec
APP_ID="your-app-id"
doctl apps update "$APP_ID" --spec .do/app.yaml --format ID,DefaultIngress,Updated

Because your run/build commands and env are in the spec, your deploys are reproducible across environments.

Putting it all together

If you use buildpacks, set npm run build and npm run start, keep Node pinned to a current LTS, and rely on the platform to pass PORT and provide TLS. If you want a smaller server footprint, flip output: 'standalone' and run node .next/standalone/server.js instead. Treat the filesystem as temporary, especially for ISR cache and uploads. Route Handlers, middleware, websockets, and API routes all work because you’re running a full Next.js Node server. With that foundation, your app is “wholly under App Platform,” no separate Droplet or reverse proxy required.

Was this helpful?

Thanks for your feedback!

Leave a comment

Your email address will not be published. Required fields are marked *