Skip to main content
Web app skills · Lesson 10 of 11 · 14 min

Self-Host Next.js: Standalone Output, Docker and Env Vars

Learn to self-host Next.js: output standalone, the Docker runner image, build-time vs runtime env vars, NEXT_PUBLIC and a health check, from real files.

By Matthews WongReviewed on

Key takeaway

Self-hosting Next.js means running your own Node.js server instead of a managed platform. Set output to standalone and next build writes a minimal server.js with only the traced dependencies. You copy public and static yourself, freeze NEXT_PUBLIC values at build time, read server variables at runtime, and give the container a health check.

You will be able to

  • Turn on standalone output and list what the runner image needs to hold.
  • Tell build-time NEXT_PUBLIC values from runtime server variables, and place each correctly.
  • Add a health check that proves the Next.js server answers.
Watch it work

Step through the diagram

Press Play, or step with the arrows. Every step is written out below the diagram.

From next build to a running, checked server

Build time freezes NEXT_PUBLIC values and writes the standalone folder; you add static and public. Run time starts server.js, reads server variables live and answers a health check.

Step 1 of 7. Two moments matter. At build time Next.js compiles and decides which files the server needs. At run time a Node.js process serves requests inside a container.

Keys: ← → step · Home / End · K play or pause

  1. Two moments matter. At build time Next.js compiles and decides which files the server needs. At run time a Node.js process serves requests inside a container.

  2. next build replaces every NEXT_PUBLIC reference in browser JavaScript with a hard-coded value. Changing the variable later does not change a build that already exists.

    process.env.NEXT_PUBLIC_*
  3. With output standalone, the build writes a folder holding a minimal server.js and only the node_modules files it traced as needed.

    output: "standalone"Predict the next step Next, you start the standalone server. Which folders does it still expect you to copy? (Only node_modules · public and .next/static · None, it copies all)
  4. The standalone build leaves public and .next/static out by default, because a CDN should serve them. Copy them into the output and server.js serves them.

    COPY --from=builder /app/.next/static ./.next/static
  5. The runner holds only these outputs, and starts as a non-root user. PORT and HOSTNAME decide where the server listens.

    CMD ["node", "server.js"]Predict the next step Next, you change a NEXT_PUBLIC value only in the running container. What does the browser bundle use? (The new value · The old build-time value)
  6. Variables without the prefix stay on the server and are read while a request is handled, so one image can run with different values per environment.

    const apiKey = process.env.GROQ_API_KEY;
  7. A health check requests a cheap route from inside the container. If server.js stops answering, the container is marked unhealthy instead of silently running.

    --spider http://127.0.0.1:3000/robots.txt

What does output standalone give you?

During next build, Next.js traces every page and its dependencies with a static analysis of import, require and fs usage, to learn which files the production server could load. Set output to standalone in next.config and it copies just those files into .next/standalone, a folder you can deploy on its own without running npm install, along with a minimal server.js you start with node instead of next start.

Read more (2)

Tracing can be wrong in both directions. The Next.js docs describe outputFileTracingIncludes for a file the trace missed and outputFileTracingExcludes for one it kept needlessly, each keyed by route glob. In a monorepo the trace root defaults to the project directory, so files outside it need outputFileTracingRoot. When a standalone container crashes on a missing module, the trace is the first suspect.

The docs also say the minimal server does not copy public or .next/static by default, since they should ideally be handled by a CDN, but you can copy them into standalone/public and standalone/.next/static and server.js will serve them. Forgetting this is the classic symptom: the HTML loads, but every script, stylesheet and image answers 404.

Which environment variables are frozen at build time?

By default environment variables exist only on the server. A name starting with NEXT_PUBLIC_ is different: Next.js inlines its value into the JavaScript bundle during next build, replacing each process.env reference with a hard-coded value. The docs warn that after the build the app no longer responds to changes, and that a single Docker image deployed to several environments freezes those values as evaluated at build time.

Read more (1)

Two details trip people up. A dynamic lookup such as process.env[name], or copying process.env into a variable first, is not inlined. And values read on the server during dynamic rendering are evaluated at runtime, which the docs call out as what lets one image be promoted through several environments. So: public, non-secret settings are build arguments; secrets and per-environment values stay unprefixed and are supplied when the container starts. The DevOps track's lesson on Docker env vars and secrets covers the container side of that.

What else changes once you run the server yourself?

The self-hosting guide recommends a reverse proxy such as nginx in front of the Next.js server rather than exposing it directly, so malformed requests, slow connections and rate limits are handled before they reach the renderer. Streaming needs the proxy to stop buffering responses, and the guide shows an X-Accel-Buffering header for nginx. The reverse proxy lesson in the DevOps track covers that setup.

Read more (2)

Next.js also keeps its own cache. By default it stores generated pages and data in memory, 50 MB unless you change it, and on disk of each instance. That is fine for one container with a persistent disk, but each extra instance gets its own copy, so multiple instances need a shared cache handler. For a clean stop, send SIGINT or SIGTERM and let the server finish in-flight requests, with a drain period of 10 to 30 seconds recommended.

The docs describe the configuration but do not prescribe a health endpoint. Any cheap route that proves the server answers will do: a dedicated route you add, or an existing one. Pick one that does not touch a database, or a slow dependency will make a healthy server look broken.

How this site does it

The real config behind this lesson

Excerpts from the repository that builds and deploys this website, quoted as they are.

next.config.ts:14
  output: "standalone" as const, // self-hosted Docker deploy — trims node_modules to only what server.js needs

One line turns the feature on. Everything else in this lesson follows from it: the build now writes a standalone folder with its own server.js, and the Dockerfile copies that folder instead of the whole project.

Dockerfile:60-66
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]

The runner stage copies three things: public, the standalone folder, and .next/static into the path where server.js expects it. The chown flags give the non-root user ownership of what it runs. The process then runs as a non-root user and starts with node server.js, not next start.

Dockerfile:38-41
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME=0.0.0.0

PORT and HOSTNAME are the two variables the Next.js docs name for the standalone server. The docs show HOSTNAME set to 0.0.0.0 in their own example. Compose then publishes the container port on the loopback address only, so the reverse proxy is the way in.

app/api/chat/route.ts:163-172
function getGroqClient(): Groq {
  if (!groq) {
    const apiKey = process.env.GROQ_API_KEY;
    if (!apiKey) {
      throw new Error("GROQ_API_KEY environment variable is missing");
    }
    groq = new Groq({ apiKey });
  }
  return groq;

A server-only secret, read with no NEXT_PUBLIC prefix inside a function that runs while a request is handled, and failing loudly if it is missing. It never enters the browser bundle, and the container receives the value when it starts, not when the image is built.

docker-compose.yml:29-42
    healthcheck:
      test:
        [
          "CMD",
          "wget",
          "--no-verbose",
          "--tries=1",
          "--spider",
          "http://127.0.0.1:3000/robots.txt",
        ]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 20s

The check runs inside the container, so it targets the internal port. It asks for robots.txt, an existing cheap route that touches no database, instead of a new endpoint. The start period gives server.js time to boot before failures count, and three misses in a row mark the container unhealthy.

next.config.ts:21
  cacheMaxMemorySize: 16 * 1024 * 1024,

The default in-memory cache is 50 MB, as the self-hosting guide states. This site lowers it, because nearly every route is prerendered at build time and read from disk, and the server shares a small machine. Tune it only after you have measured memory.

What breaks

Mistakes that cost real time

  • Pages load but every asset is a 404

    The standalone folder does not include .next/static or public by default. Copy .next/static to .next/static inside the output and public beside server.js, then rebuild the image and check a script URL directly.

  • A NEXT_PUBLIC value ignores the new env file

    The value was inlined during next build, so restarting the container cannot change it. Rebuild with the new value, or serve the setting from your own API route so the client asks for it at runtime.

  • A health check that always fails

    Typical causes are a wrong port, a wrong path, or a start period shorter than the boot time. Run the same command by hand inside the container and read the exit code.

  • A missing module only in the container

    File tracing missed something, often a file loaded in a way static analysis cannot see. Add it with outputFileTracingIncludes for the affected route, rebuild, and confirm it exists in the standalone folder.

Try it yourself

Do it on your own machine

Run the standalone server on your own machine before you put it in a container. You will see exactly what the Dockerfile has to reproduce.

  1. Set output to standalone in next.config, then run the production build.

    npm run build
  2. Start the server without copying anything and open a page. Notice the missing styles and scripts, then check the 404s in the browser's network panel.

    node .next/standalone/server.js
  3. Stop it, copy public and .next/static into the standalone folder as the Next.js docs show, and start it again. The assets now load.

    cp -r public .next/standalone/ && cp -r .next/static .next/standalone/.next/
  4. Start it on another port to see the environment variables at work, then request a cheap route the way a health check would.

    PORT=8080 HOSTNAME=0.0.0.0 node .next/standalone/server.js
Quick quiz

Three questions before you move on

Pick an answer to see why it is right or wrong. Nothing is saved or graded, so guess freely.

  1. 1.What does output standalone create?
  2. 2.You change NEXT_PUBLIC_SITE_NAME in the running container's env and restart. The browser still shows the old value. Why?
  3. 3.Which health check target is the safest choice for a Next.js container?
Think it through

Answer in your head, then check

  1. 1.Why does the Dockerfile copy .next/static separately from the standalone folder?Show the answer

    Answer:The standalone output leaves .next/static and public out by default, since a CDN would ideally serve them. Copying them into the output lets server.js serve them itself.

  2. 2.Where should an API key live, and how does the chat route read it?Show the answer

    Answer:In an unprefixed server variable supplied to the container at start. The route reads process.env.GROQ_API_KEY inside a function at request time, so it never reaches the browser bundle.

  3. 3.What do PORT and HOSTNAME control for server.js?Show the answer

    Answer:The port and the address the server listens on. This site sets 3000 and 0.0.0.0 in the Dockerfile so the container can be reached through its published port.

Go deeper

Related deep dives on this site