# Build-time vs runtime config in Next.js on containers: the bug that took two deploys

We flipped a feature flag. The flag was an environment variable on the App Runner service, `NEXT_PUBLIC_NEW_CHECKOUT=true`, changed through CDK, deployed cleanly, service healthy. The old checkout stayed. We checked the environment on the running instance: the variable was there, set to `true`. We restarted the service. Still the old checkout. It took a second deploy, with a code change that touched nothing related, for the new checkout to appear.

The explanation is one sentence long, and every Next.js developer has read it: `NEXT_PUBLIC_*` variables are inlined into the JavaScript bundle at build time. We had read it too. What we hadn't internalised is what it means when your build and your runtime are two different places, which is exactly what containers give you. This article is the rulebook we wrote afterwards.

## Three kinds of configuration, not two

Next.js documentation talks about build-time and runtime. On a container platform there are really three kinds, and the third is the one that bites.

| Kind | Example | Where it lives | When it can change |
|---|---|---|---|
| Build-time, public | `NEXT_PUBLIC_API_URL` | Inlined into client chunks under `.next/static` | Only by rebuilding the image |
| Runtime, server-only | `DATABASE_URL`, API keys | `process.env` in server components, route handlers, middleware | On the next request after the env var changes |
| Runtime, public | A feature flag the browser needs | Nowhere, by default. You have to build the path yourself | Whenever you want, if you build it |

The first kind is what `NEXT_PUBLIC_` gives you: fast, static, and frozen at `next build`. The second kind works the way everyone expects; a server component that reads `process.env.DATABASE_URL` reads it at request time, and a change on the App Runner service takes effect at the next instance start. The third kind is the trap: a value the browser needs, that you'd like to change without a rebuild. Next.js has no built-in answer, so people reach for `NEXT_PUBLIC_` and get the first kind by accident.

<div class="article-figure">
<svg viewBox="0 0 900 250" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Timeline with two phases. Build phase in CodeBuild: next build inlines NEXT_PUBLIC variables into static chunks, the image is pushed to ECR. Run phase on App Runner: the container starts with the service environment, server code reads process.env at request time, but the chunks already contain the build-time values, so changing NEXT_PUBLIC on the service has no effect.">
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<rect x="20" y="30" width="400" height="180" rx="14" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/>
<text x="220" y="56" text-anchor="middle" fill="#7b8cff" font-size="14" font-weight="700">Build · CodeBuild · once per image</text>
<rect x="40" y="74" width="360" height="36" rx="8" fill="#0d1120" stroke="#2a3150"/><text x="220" y="97" text-anchor="middle" fill="#f1f3ff">next build</text>
<rect x="40" y="120" width="360" height="36" rx="8" fill="#0d1120" stroke="#ffd166"/><text x="220" y="143" text-anchor="middle" fill="#ffd166">NEXT_PUBLIC_* → inlined into .next/static chunks</text>
<rect x="40" y="166" width="360" height="30" rx="8" fill="#0d1120" stroke="#2a3150"/><text x="220" y="186" text-anchor="middle" fill="#9aa3c7">image pushed to ECR, tagged with the git SHA</text>
<rect x="480" y="30" width="400" height="180" rx="14" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/>
<text x="680" y="56" text-anchor="middle" fill="#4fffb0" font-size="14" font-weight="700">Run · App Runner · every instance start</text>
<rect x="500" y="74" width="360" height="36" rx="8" fill="#0d1120" stroke="#2a3150"/><text x="680" y="97" text-anchor="middle" fill="#f1f3ff">container starts with the service environment</text>
<rect x="500" y="120" width="360" height="36" rx="8" fill="#0d1120" stroke="#4fffb0"/><text x="680" y="143" text-anchor="middle" fill="#4fffb0">server code reads process.env at request time ✓</text>
<rect x="500" y="166" width="360" height="30" rx="8" fill="#0d1120" stroke="#ff6b8a"/><text x="680" y="186" text-anchor="middle" fill="#ff6b8a">chunks still hold the build-time NEXT_PUBLIC values ✗</text>
<path d="M420,120 L478,120" stroke="#9aa3c7" stroke-width="2" stroke-dasharray="5,4"/>
<text x="450" y="236" text-anchor="middle" fill="#9aa3c7">Changing NEXT_PUBLIC_* on the service changes nothing the browser sees until the image is rebuilt.</text>
</g>
</svg>
</div>

## The rule: one image, all environments

The decision that fixed the class of bug, not just the instance: **the same image runs in dev, staging and production.** No environment-specific builds. If the image is the same, then by construction nothing that differs between environments can be build-time, and the question "is this build-time or runtime?" answers itself for every new variable.

What that leaves as build-time is very short: the git SHA, the build date, and third-party SDKs that insist on a static key at bundle time. Everything else, including everything public, is runtime. And because the image is the same, promoting a build from staging to production is a tag change on the App Runner service, not a rebuild. That's the feature that pays for the discipline.

## Runtime public config, done properly

The browser still needs some values. Instead of `NEXT_PUBLIC_`, the root layout, which is a server component, reads the environment at request time and hands the browser exactly the keys it should have:

```tsx
// app/layout.tsx (server component)
import { PublicConfigProvider } from '@/lib/public-config';

const publicConfig = () => ({
  apiUrl: process.env.API_URL!,
  newCheckout: process.env.FEATURE_NEW_CHECKOUT === 'true',
  release: process.env.RELEASE_SHA ?? 'dev',
});

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        <PublicConfigProvider value={publicConfig()}>{children}</PublicConfigProvider>
      </body>
    </html>
  );
}
```

```tsx
// lib/public-config.tsx
'use client';
import { createContext, useContext } from 'react';
const Ctx = createContext<ReturnType<typeof publicConfig> | null>(null);
export const PublicConfigProvider = Ctx.Provider;
export const usePublicConfig = () => {
  const c = useContext(Ctx);
  if (!c) throw new Error('PublicConfigProvider missing');
  return c;
};
```

Three properties of this pattern matter. The allow-list is explicit: `publicConfig()` names every key that reaches the browser, so a secret can't leak by being prefixed wrong. The values are read per request, so a change on the service is live at the next instance start with no rebuild. And the layout must not be statically prerendered for this to work, which in the App Router means the layout, or something in it, is dynamic; we read `headers()` in the layout anyway for the locale, which already makes it dynamic. If your layout is static, wrap the read in `unstable_noStore()` or `connection()` depending on your Next.js version.

Middleware gets the same treatment: it reads `process.env` at the edge of every request, which is runtime by nature, so the preview token and the maintenance flag live there without any of this ceremony.

## What stays build-time, and how we don't get burned again

Two things in our stack genuinely need a value at bundle time: the error-tracking SDK wants its DSN when the client bundle initialises, and the build SHA that we show in the footer and send with every API call. Both are the same in every environment (one error-tracking project with the environment set at runtime as a tag; the SHA is the SHA), so they don't break the one-image rule.

For those we pass Docker build arguments, not environment variables, and the Dockerfile makes the distinction visible:

```dockerfile
FROM node:22-alpine AS builder
ARG RELEASE_SHA                       # build-time only, baked into the bundle
ENV NEXT_PUBLIC_RELEASE_SHA=$RELEASE_SHA
COPY . .
RUN npm ci && npm run build

FROM node:22-alpine AS runner
ENV NODE_ENV=production               # runtime; App Runner overrides the rest
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
CMD ["node", "server.js"]
```

An `ARG` in the builder stage cannot be set on the App Runner service, so nobody can be tempted to "just change it in the console". If a value is an `ARG`, it's a rebuild. If it's read by `publicConfig()`, it's a service setting. There is no third place.

## The check that would have caught it

Our deploy script now verifies what the browser will actually get, after every deploy:

```bash
url=$(aws apprunner describe-service --service-arn "$ARN" --query 'Service.ServiceUrl' --output text)
sha=$(curl -sf "https://$url/api/health" | jq -r .release)
[ "$sha" = "$GIT_SHA" ] || { echo "running $sha, expected $GIT_SHA"; exit 1; }

# the bundle the browser downloads must carry the same release
chunk=$(curl -sf "https://$url/" | grep -o '/_next/static/chunks/main-app-[a-z0-9]*\.js' | head -1)
curl -sf "https://$url$chunk" | grep -q "$GIT_SHA" || { echo "client bundle is stale"; exit 1; }
```

Two lines of `grep` against the served JavaScript. It fails the deploy when the server says one release and the client bundle says another, which is precisely the state we were in for a full day without knowing.

<div class="article-figure">
<svg viewBox="0 0 900 200" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Decision flow for a new configuration value. Does the browser need it? If no, read process.env on the server: runtime. If yes, does it differ between environments? If yes, expose it through publicConfig in the root layout: runtime public. If no, and an SDK needs it at bundle time, use a Docker build ARG: build-time, same in every environment.">
<defs><marker id="arrCf" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#7b8cff"/></marker></defs>
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<rect x="20" y="70" width="180" height="60" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="110" y="96" text-anchor="middle" fill="#f1f3ff" font-weight="700">new config value</text><text x="110" y="114" text-anchor="middle" fill="#9aa3c7">does the browser need it?</text>
<line x1="202" y1="85" x2="288" y2="45" stroke="#7b8cff" stroke-width="1.5" marker-end="url(#arrCf)"/><text x="240" y="56" fill="#9aa3c7">no</text>
<line x1="202" y1="115" x2="288" y2="155" stroke="#7b8cff" stroke-width="1.5" marker-end="url(#arrCf)"/><text x="240" y="150" fill="#9aa3c7">yes</text>
<rect x="290" y="20" width="250" height="50" rx="10" fill="#0d1120" stroke="#4fffb0" stroke-width="1.5"/><text x="415" y="41" text-anchor="middle" fill="#4fffb0" font-weight="700">runtime, server-only</text><text x="415" y="58" text-anchor="middle" fill="#9aa3c7">process.env in server code</text>
<rect x="290" y="130" width="250" height="60" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="415" y="152" text-anchor="middle" fill="#f1f3ff" font-weight="700">differs per environment?</text><text x="415" y="172" text-anchor="middle" fill="#9aa3c7">or needed by an SDK at bundle time?</text>
<line x1="542" y1="145" x2="628" y2="105" stroke="#7b8cff" stroke-width="1.5" marker-end="url(#arrCf)"/><text x="580" y="116" fill="#9aa3c7">differs</text>
<line x1="542" y1="175" x2="628" y2="175" stroke="#7b8cff" stroke-width="1.5" marker-end="url(#arrCf)"/><text x="565" y="192" fill="#9aa3c7">same everywhere</text>
<rect x="630" y="70" width="250" height="60" rx="10" fill="#0d1120" stroke="#4fffb0" stroke-width="1.5"/><text x="755" y="92" text-anchor="middle" fill="#4fffb0" font-weight="700">runtime, public</text><text x="755" y="112" text-anchor="middle" fill="#9aa3c7">publicConfig() in the root layout</text>
<rect x="630" y="140" width="250" height="50" rx="10" fill="#0d1120" stroke="#ffd166" stroke-width="1.5"/><text x="755" y="161" text-anchor="middle" fill="#ffd166" font-weight="700">build-time · Docker ARG</text><text x="755" y="178" text-anchor="middle" fill="#9aa3c7">one image, verified by grep after deploy</text>
</g>
</svg>
</div>

## The wider lesson

The bug wasn't a Next.js bug and it wasn't an App Runner bug. It was a boundary we hadn't drawn: which values are properties of the artifact and which are properties of the environment. Vercel hides that boundary from you by rebuilding on every environment change, and it's a fine trade if you're on Vercel. On containers you own the boundary, so you have to draw it on purpose. One image for all environments is the simplest line to draw, and the deploy-time grep is the cheapest way to prove you stayed on the right side of it.

If you're moving a Next.js app from Vercel to containers and want to skip the day we lost, [talk to us](/contact).
