Guide

How to Self-Host a Next.js App With Docker

A no-fluff guide to packaging a Next.js app into a small, fast Docker image and running it on your own server: standalone output, a multi-stage build, environment variables, and the production details people skip.

Key takeaways

  • Set `output: 'standalone'` in next.config to shrink your image from ~1GB to roughly 150-250MB.
  • Use a three-stage Dockerfile (deps, builder, runner) on `node:24-alpine` and copy only standalone, static, and public into the final image.
  • `NEXT_PUBLIC_*` vars are baked in at build time; server secrets must be passed at runtime — never bake secrets into the image.
  • Production needs more than a container: a reverse proxy for TLS, a health check, restart policies, and a non-root user.
  • SSR is CPU- and latency-bound, so fast single-thread CPUs, NVMe, and proximity to users matter most when picking where to run.

Why self-host Next.js with Docker?

Vercel is the easy path, but plenty of teams want to self-host Next.js with Docker: predictable costs at scale, no per-seat or bandwidth surprises, data residency in a specific region, or simply running it next to a database and other services you already control. Docker makes that portable. The same image runs on your laptop, a CI runner, and a bare-metal box in a Stockholm datacenter, with no 'works on my machine' drift.

The catch is that a naive Dockerfile produces a 1GB+ image stuffed with dev dependencies and source code. Done right, the same app ships in roughly 150-250MB, boots in a second or two, and keeps your registry pulls and cold starts fast. The two ingredients that make that happen are Next.js standalone output and a multi-stage build.

One prerequisite: self-hosting a long-running Node server gives you full SSR, API routes, ISR, and middleware. If you only need a fully static export (no server), `output: 'export'` behind any static host is simpler. This guide targets the dynamic, server-rendered case, which is where Docker earns its keep.

Deploy on developer hostingOn the fastest servers in the North — free migration, 24/7 human support.Deploy on developer hosting

Step 1: Enable standalone output

Next.js can trace exactly which files your app needs at runtime and copy only those into a self-contained folder. Turn it on in your config:

  • In `next.config.js` (or `.ts`), set `output: 'standalone'` inside the config object.
  • Run `next build`. Next traces dependencies and writes a minimal server to `.next/standalone`, plus a `.next/static` folder for assets.
  • Start it locally with `node .next/standalone/server.js` to confirm it boots without your full `node_modules`.
  • Standalone bundles only the packages your code actually imports, not the hundreds in `node_modules`. That single flag is responsible for most of the image-size win.

Step 2: Write a lean multi-stage Dockerfile

The standard pattern uses three stages: `deps` installs dependencies, `builder` compiles the app, and `runner` is the tiny final image that ships. Each stage starts from `node:24-alpine` (Node 24 is the active LTS in 2026; Alpine keeps the base around 50MB instead of ~1GB).

A production-shaped Dockerfile looks like this:

  • `deps`: `FROM node:24-alpine AS deps`, copy `package.json` + lockfile, run `npm ci`. Caching this layer means dependencies only reinstall when the lockfile changes.
  • `builder`: copy `node_modules` from `deps` and the source, run `npm run build` (which produces `.next/standalone`).
  • `runner`: `FROM node:24-alpine AS runner`, set `NODE_ENV=production`, create a non-root user, then copy only `.next/standalone`, `.next/static`, and `public` from the builder.
  • Finish with `USER nextjs`, `EXPOSE 3000`, and `CMD ["node", "server.js"]`. Add a `.dockerignore` (node_modules, .next, .git, .env) so build context stays small.
  • Build and run: `docker build -t my-next-app .` then `docker run -p 3000:3000 my-next-app`.

Step 3: Handle environment variables correctly

This trips up almost everyone. Next.js has two kinds of env vars and they behave differently in Docker. `NEXT_PUBLIC_*` variables are inlined into the JavaScript bundle at build time, so they must be present during `docker build` (pass them with `--build-arg` and `ARG`/`ENV`). Server-only secrets like `DATABASE_URL` are read at runtime and should be passed at `docker run` (or via your orchestrator), never baked into the image.

Practical rule: bake nothing secret into the image. Public, build-time config can be a build arg; everything sensitive comes in at runtime through `--env-file`, `docker run -e`, or your platform's secret store. If you change a `NEXT_PUBLIC_*` value, you must rebuild — runtime injection won't change what's already compiled into the bundle.

  • Build time: `docker build --build-arg NEXT_PUBLIC_API_URL=https://api.example.com -t my-next-app .`
  • Run time: `docker run -p 3000:3000 --env-file .env.production my-next-app`
  • Set `HOSTNAME=0.0.0.0` in the runner so the standalone server binds to all interfaces inside the container, otherwise external traffic can't reach it.

Step 4: Run it in production

A container listening on port 3000 is not a production deployment by itself. Put a reverse proxy in front (Nginx, Caddy, or Traefik) to terminate TLS, handle HTTP/2, and serve as a stable entry point. Caddy is the quickest for automatic HTTPS; Nginx is the most battle-tested. Point it at `localhost:3000` and let it manage certificates.

Add a health check so your orchestrator knows the app is alive — a lightweight route like `/api/health` returning 200, wired into a Docker `HEALTHCHECK` or your platform's probe. Set restart policies (`--restart unless-stopped` or a Compose/Kubernetes equivalent) so the app recovers from crashes and reboots.

A few production details that matter: persist or externalize anything stateful (ISR cache, uploads) since containers are ephemeral; pin your base image to a specific Node version rather than a floating tag; and run as a non-root user, which the Dockerfile above already does. For zero-downtime deploys, build the new image, start it alongside the old one, flip the proxy, then drain the old container.

Where to run your container

Your image will run anywhere with a Docker runtime: a single VPS for a side project, a managed container service, or a Kubernetes cluster for larger fleets. The differentiator for a real-time, SSR-heavy Next.js app is latency and CPU. Server rendering is CPU-bound, and every request pays a round-trip, so raw single-thread speed, fast NVMe storage, and being physically close to your users move the needle more than headline core counts.

If you want the self-hosting upside without babysitting a stack, NordicVentures developer hosting runs your container on NVMe bare-metal and cloud in Stockholm, Frankfurt, and Ashburn, with free migration, transparent pricing (no renewal shock), and 24/7 human support when a deploy goes sideways at 2 a.m. You keep full control of your Dockerfile and environment; we keep the hardware fast and the network close to your users.

Once your Dockerfile builds clean and the container is healthy locally, deploying is the easy part. When you're ready to ship it on infrastructure built for SSR workloads, deploy on developer hosting at /developer-hosting.

FAQ

Can I self-host Next.js with Docker, or do I have to use Vercel?

You can fully self-host. Set `output: 'standalone'`, build a Docker image, and run it on any host with a Docker runtime. You get SSR, API routes, ISR, and middleware. The only Vercel-specific extras you'd reimplement yourself are things like their edge network and built-in analytics — the core framework runs anywhere.

How big should a Next.js Docker image be?

With standalone output and a multi-stage Alpine build, expect roughly 150-250MB. A naive build that copies all of node_modules and source often exceeds 1GB. If yours is that large, you've likely skipped standalone output or aren't using a separate runner stage.

Why aren't my NEXT_PUBLIC_ environment variables working in Docker?

`NEXT_PUBLIC_*` values are inlined into the client bundle at build time, not read at runtime. Pass them during `docker build` with `--build-arg` and a matching `ARG`/`ENV`. If you change one, rebuild the image — setting it at `docker run` won't update what's already compiled into the JavaScript.

Do I need Kubernetes to self-host Next.js?

No. A single container behind a reverse proxy on one server handles plenty of traffic. Reach for Kubernetes only when you need multi-node scaling, rolling deploys across a fleet, or complex service orchestration. For most apps, a well-provisioned VPS or bare-metal box with a restart policy is enough.

Ready to launch?Deploy on developer hosting on NordicVentures — the fastest servers in the North.Deploy on developer hosting