JDM Casanova
← All posts
3 min read

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

Build output, route runtimes and response headers: the migration choices, and how the current pnpm-based runtime differs from the original standalone example.

nextjsverceldockercoolifysolo-founder

I moved my portfolio from Vercel to a Docker-on-Coolify setup alongside my other Drafted By products. This article originally recorded the configuration changes around that move. This September revision separates the original standalone approach from the runtime image the repository now uses.

The migration account covers the operational context. Here, the useful questions are what the build produces, what the container actually runs, and what has to be included for the blog to work.

Build output and runtime packaging are separate choices

The configuration still declares:

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

output: 'standalone' produces a traced runtime bundle. In the original migration, that bundle was about 41MB. That was a measurement of the earlier build, not the size of the current production image.

The current Dockerfile uses a full Next.js runtime: it copies node_modules, .next, public, the required configuration files and content/. It starts the Next.js CLI. Declaring standalone output does not mean the Dockerfile is using that output.

For this file-backed blog, content/ is especially important. The application reads the MDX files from disk. A container that serves the homepage can still be incomplete if the article files are missing.

Use the package manager declared by the repository

This repository declares [email protected] and keeps a pnpm lockfile. The relevant build sequence is:

corepack enable
pnpm install --frozen-lockfile
pnpm run lint
pnpm run build

The Docker build copies package.json, pnpm-lock.yaml and pnpm-workspace.yaml before dependency installation, then copies the source and builds it. The runtime command in the current Dockerfile is:

CMD ["node", "node_modules/next/dist/bin/next", "start", "-H", "0.0.0.0", "-p", "3000"]

A standalone runtime is a different packaging choice: it runs the generated server.js and needs the traced dependencies plus the static and application-owned assets it reads. Do not mix the two layouts by copying a standalone bundle and expecting the full CLI installation, or by leaving out content/ because the homepage looks fine.

Review route runtimes individually

The portfolio's Open Graph image route explicitly uses Node.js:

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

That records the choice for this route. My earlier advice to change every Edge route to Node.js was too broad. Review the installed Next.js version, the APIs used by each route and the deployment target before changing its runtime. A Node.js runtime declaration alone does not guarantee static generation; check the actual build output and the response.

The Next.js self-hosting guide documents supported server features and deployment considerations. Use it alongside the application code, rather than assuming every issue comes from leaving Vercel.

Keep response behavior explicit

I keep compression and the powered-by-header setting explicit in the configuration. They express the behavior I want when traffic reaches the Node server as well as through the proxy.

The current configuration also sets response headers, including a Content Security Policy tailored to the scripts, images and connections this site uses. A copied header list is only a starting point: a policy that blocks the analytics script or an image request is a functional change.

Verify the response through the public hostname and the local server when diagnosing a mismatch. The proxy and the application can both affect what the browser receives.

Document the exceptions in the real configuration

The small configuration example above is an excerpt, not the whole file. The repository also includes image settings, an Umami proxy rewrite and experimental.optimizeCss.

An experimental setting deserves a specific reason and a check of the behavior it changes. For CSS optimization, I inspect the resulting pages and their styling after the production build. A successful compilation is only one part of that check.

Supply public variables at build time

Next.js inlines NEXT_PUBLIC_* values into the browser bundle during the build. The repository passes the public PostHog and Umami identifiers into the build before running pnpm.

Server-side settings have a different lifecycle. Check where a variable is read: during the build, on server startup or while handling a request. The distinction matters when promoting an existing image between environments. See the official environment-variable guidance.

Check the delivered application

My useful completion checks are concrete:

  • The homepage and a real blog article return successfully.
  • The article's MDX content and images render.
  • Navigation, the Open Graph image and required response headers work.
  • The running release matches the intended commit.

The maintenance trade-off remains: I own the runtime, dependency updates, proxy configuration and delivery checks. Keeping the configuration close to the repository makes that work easier to explain and repeat.