Why Did My Next.js Build Need My API Key? Containerising Senquel
Senquel runs on Vercel and Supabase. Neither needs Docker — Vercel builds and deploys the Next.js app itself, and Supabase is managed Postgres. So containerising it had no production justification. I did it to learn Kubernetes, expecting a boring afternoon writing a Dockerfile.
Instead the exercise acted as an audit. Forcing the app to build in an environment with nothing pre-installed and no secrets available exposed two assumptions the Vercel build had been quietly absorbing for months.
1. The build required a runtime secret
Two API routes opened with this:
import Anthropic from '@anthropic-ai/sdk';
const client = new Anthropic(); // module scope
export async function POST(req: Request) { /* ... */ }
That looks harmless. The client is created once and reused, which is the usual advice for SDK clients. The problem is where it runs.
Next.js evaluates every route module during next build — it has to, in order to discover exports like dynamic and revalidate and decide how each route should be rendered. Evaluating the module runs the top-level code. And the Anthropic SDK constructor throws immediately when ANTHROPIC_API_KEY is unset.
So the build did not merely prefer the API key. It failed without it. A production build — an operation that should be a pure function of source code — had become dependent on a live credential.
This had been invisible because every environment that built the app already had the key set. It was in Vercel's environment variables, and it was in the CI workflow's env block. Someone had put it there to make the build pass, which fixed the symptom and hid the cause.
The fix is to move the construction inside the handler:
export async function POST(req: Request) {
const client = new Anthropic(); // per request
// ...
}
The cost is negligible: the constructor just reads an env var and stores config. The benefit is that the build no longer touches secrets at all.
What made this worth writing up is that it was the second instance of the same bug. A few days earlier, a broken CI build turned out to be an API route constructing a Supabase client at module scope — same shape, same consequence. One occurrence is a mistake; two is a pattern worth naming:
Anything constructed at module scope in a Next.js route runs at build time, not just at request time. If it reads a secret, validates config, or opens a connection, it has become a build-time dependency.
2. The image would have been over a gigabyte
The naive Dockerfile — copy everything, install, build — produces an image containing the full node_modules (862MB), the git history (115MB), and public/ (116MB).
That last number was the surprise. public/ held twelve .mov and .mp4 demo videos, the largest 23MB. Meanwhile the actual application source — every route, component and library file — is 1.3MB. The media outweighed the code by roughly ninety to one.
On Vercel this had never mattered. Static assets are served from a CDN and the deployment size is invisible in day-to-day work. Docker made it a number I had to look at.
Two changes did most of the work:
A .dockerignore file. Same idea as .gitignore, for the build context. Excluding node_modules, .git, .next and the video files removes them before Docker sees them.
output: 'standalone' in next.config.ts. This makes Next emit a self-contained server bundle with only the dependencies actually reachable at runtime — 73MB rather than 862MB, because dev tooling, test runners and build-time compilers are all excluded.
Final image: 442MB, of which roughly half is the node:20-slim base. Switching to Alpine would take it under 300MB; I stayed on Debian because glibc avoids a category of native-binary problems that are not worth debugging on a first attempt.
3. What the environment surface actually was
Writing the container's configuration meant enumerating every environment variable the app reads. The documented list had eight entries. The real one had fourteen.
The missing six were not obscure. They included the Meta WhatsApp Cloud API credentials — an entire second messaging provider — and NEXT_PUBLIC_SITE_URL, which has a hardcoded fallback to a personal domain. In any deployment where that variable is unset, the fallback silently produces wrong callback URLs and Twilio signature validation fails. Not with an error; just with rejected webhooks.
There was also no .env.example in the repository at all. Onboarding anyone would have meant reading the source to find out what to configure.
The build-time / runtime split
One genuine constraint fell out of this that is worth stating plainly, because it is a property of Next.js rather than of Docker.
Variables prefixed NEXT_PUBLIC_ are inlined into the client bundle at build time. They are not read at runtime — they are substituted into the JavaScript that ships to browsers. That means a built image is tied to one environment. You cannot point the same image at a different Supabase project by passing a new environment variable; you have to rebuild.
Everything else — service-role keys, Twilio tokens, Xero credentials — is read lazily inside request handlers and can be injected at run time, which is what makes them suitable for a Kubernetes Secret.
Once that distinction is clear, the Dockerfile writes itself: NEXT_PUBLIC_* become build arguments, everything else becomes runtime environment, and the build needs no credentials whatsoever.
What I would tell someone starting this
The Dockerfile was the easy part. Multi-stage builds are well documented and the Next.js standalone output is designed for exactly this. What took the time was the three discoveries above — none of which are Docker problems, and all of which were latent in a codebase that had been deploying successfully for months.
Containerisation is often described as a portability tool. In practice, its first useful effect is diagnostic. A container has nothing installed, no ambient credentials, and no state left over from previous runs. Building one forces every implicit dependency to become explicit, because anything you forgot simply is not there.
That is worth doing once even if you never deploy the image.