JDM
← All posts
6 min read

What I changed in next.config.mjs when leaving Vercel

Three diffs that earn their place: output standalone, edge runtime switch, explicit compression. Plus the security headers you stop getting for free.

nextjsverceldockercoolifysolo-founder

I moved my portfolio off Vercel onto a Docker-on-Coolify setup running on the same homelab where I already operate the other Drafted By sites. The migration was mostly smooth, but next.config.mjs needed three specific changes I did not see coming. This post covers exactly what changed and why.

The full context is in my Vercel-to-Coolify migration post. Here I focus on the config file itself.

output: 'standalone' is the first thing to change

Vercel runs Next.js in its own runtime. It does not need the standalone output mode. Your local next build produces a .next folder with all the build artifacts, but the server code still lives inside node_modules. On Vercel, that is fine because their infrastructure handles the full dependency tree.

When you self-host, you want a minimal Docker image. Without output: 'standalone', the runner image needs the complete node_modules directory in the final stage, which dominates the image size. With standalone, the runner only needs .next/standalone, .next/static and public. For this site, .next/standalone totals about 41MB versus the multi-hundred-MB the full node_modules would have added.

Add this line to next.config.mjs:

const nextConfig = {
  output: 'standalone',
  // rest of config
};

After a build, Next.js produces .next/standalone. That folder contains a server.js file and a minimal node_modules with only the runtime dependencies.

The real win is in the Dockerfile pattern. You can do a multi-stage build:

FROM node:24-slim AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci

FROM node:24-slim AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:24-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
COPY --from=builder --chown=node:node /app/public ./public
USER node
EXPOSE 3000
CMD ["node", "server.js"]

The COPY --from=builder line copies only the standalone bundle. The runner image stays small. No npm install in the runner stage. No dev dependencies. No build tools.

One gotcha: the standalone folder does not include static files. You need to copy .next/static and public separately. The Dockerfile above handles that.

Edge runtime breaks on Node

Vercel scaffolds new projects with export const runtime = "edge" in certain route files. The app/opengraph-image.tsx file is a common example. The edge runtime expects Vercel's edge infrastructure, which runs on Cloudflare Workers. Inside a Node-standalone Docker image, there is no edge runtime to fall back on.

The build succeeds. But when you try to access the route, the server throws a runtime error. The edge runtime is simply not available.

The fix is straightforward. Change the runtime to nodejs:

// app/opengraph-image.tsx
export const runtime = "nodejs";

After this change, the build output changes. Next.js shows the route as (static) instead of ƒ (dynamic). That is actually better. The Open Graph image is deterministic for a given path. Static generation means it gets cached at build time. No runtime computation needed.

Do a grep through your app directory for any file that exports runtime = "edge". Common candidates are API routes, middleware, and image generation files. Swap them all to nodejs.

If you have middleware that uses the edge runtime, you might need to rewrite it. Middleware with export const runtime = "edge" also breaks. I had to move some logic into a server-side API route instead. Not ideal, but workable.

Enable compression and strip the powered-by header

Vercel handles compression at its edge proxy. The compress: true option in next.config.mjs is redundant on Vercel. Same for poweredByHeader: false. Vercel strips the header at the edge.

On a self-hosted setup, you need both. Here is why.

Traffic enters through Cloudflare Tunnel, then Traefik, then your Node process. Cloudflare compresses at its edge. But if you access the site directly via LAN, or run health checks, or debug locally, that traffic bypasses Cloudflare. Without Node-level compression, those requests serve uncompressed responses.

Add these lines:

const nextConfig = {
  output: 'standalone',
  poweredByHeader: false,
  compress: true,
  reactStrictMode: true,
  // rest of config
};

reactStrictMode: true is already the default in newer Next.js versions, but I include it explicitly for clarity. It catches double-rendering bugs during development.

The poweredByHeader: false line removes the X-Powered-By: Next.js header. It is a minor security win. No point advertising your stack.

A note on experimental flags

Vercel runs experimental features in production with battle-testing. They have the infrastructure to catch regressions before they hit users. On a self-hosted setup, you do not. My rule: no experimental.* flags in next.config.mjs unless I have a CI test that covers the exact behavior, or unless the flag is documented as production-stable in the latest Next.js release notes.

The full config file

Here is what my next.config.mjs looks like now:

/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'standalone',
  poweredByHeader: false,
  compress: true,
  reactStrictMode: true,
  images: {
    formats: ['image/avif', 'image/webp'],
  },
  async headers() {
    return [
      {
        source: '/:path*',
        headers: [
          { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
          { key: 'X-Frame-Options', value: 'DENY' },
          { key: 'X-Content-Type-Options', value: 'nosniff' },
          { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
          { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
          // Plus a Content-Security-Policy tuned to the actual third-party
          // dependencies (Umami host, form endpoint, fonts). Omitted here for
          // brevity, but it should be on this list too.
        ],
      },
    ];
  },
};

export default nextConfig;

The async headers() function sets security headers. Vercel's edge proxy adds these automatically. On self-hosted, you need to set them yourself. HSTS, Permissions-Policy, and a real Content-Security-Policy belong here too once you know the third parties you actually load (Umami host, form endpoint, font CDN).

Companion changes outside next.config.mjs

The config file is only part of the story. Two other changes matter.

First, the .dockerignore file. Without it, you copy node_modules and .next into the build context, which slows down the Docker build. Mine looks like this:

node_modules
.next
.git
.env
.env.local

Second, environment variables. Next.js inlines NEXT_PUBLIC_* variables at build time. If you set them only as runtime environment variables in Docker, they will not be available. You need to pass them as --build-arg in the Docker build command.

In Coolify, you set build-time variables in the Docker Compose file or the Coolify UI under "Build arguments". For local builds:

docker build \
  --build-arg NEXT_PUBLIC_SITE_URL=https://example.com \
  -t my-site .

Then in your Dockerfile, add this before the build step:

ARG NEXT_PUBLIC_SITE_URL
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL

Non-public environment variables (like API keys) can stay as runtime variables. Next.js does not inline them.

The tradeoff

Self-hosting gives you control over the config. You can tune compression, caching, and security headers exactly how you want. No vendor lock-in. No surprise bills. Direct access to logs.

The cost is maintenance. You handle Node, Docker, and Coolify updates yourself. You debug edge runtime quirks that Vercel would have abstracted. You write your own CI checks for any flag Next.js still calls "experimental".

For a small fleet of Next.js apps already sharing infrastructure with the rest of your stack, the trade is fair. For a single side project, Vercel's free tier is the better default.

If you are moving off Vercel, start with these three config changes. They are the minimum to get a working self-hosted Next.js setup. The rest is debugging whatever edge cases Vercel was abstracting away for you.