Why Your Payload CMS Docker Build Fails on MongoDB (and Why It Should Never Need It)
If you deploy a Next.js + Payload CMS site as a Docker image, sooner or later you will meet this error in your build logs:
Error: missing secret key. A secret key is needed to secure Payload.
payloadInitError: true
> Build error occurred
Error: Failed to collect page data for /posts/[slug]
And if you "fix" that with a dummy secret, its sibling shows up right behind it:
ERROR: cannot connect to MongoDB. Details: Invalid scheme, expected connection
string to start with "mongodb://" or "mongodb+srv://"
Error occurred prerendering page "/"
The confusing part: your app works perfectly in development, and the database is up and healthy in production. So why does building an image want to talk to MongoDB at all?
To answer that, we need to look at what Next.js and Payload each do — and at exactly when and where your code runs.
Part 1: The cast of characters
Next.js is not just a compiler
When you run next build, two very different things happen:
- Compilation — TypeScript and React are bundled into optimized server and client code. This is what most people picture when they hear "build".
- Static generation (prerendering) — Next.js then executes parts of your application. Any page that can be rendered ahead of time (SSG/ISR) is rendered right there in the build, and the resulting HTML is baked into the output. To do that, Next.js runs your
generateStaticParamsfunctions, your layouts, and the page components themselves.
Step 2 is the one that surprises people. Building a Next.js app is not a passive transformation of source code — your application code runs during the build, including any data fetching those pages do.
Payload CMS lives inside your Next.js app
Payload 3 is a "Next-native" CMS: it isn't a separate service you call over HTTP, it's a library mounted inside your Next.js project. The admin panel is a route in your app, and your pages fetch content through Payload's Local API:
const payload = await getPayload({ config: configPromise })
const posts = await payload.find({ collection: 'posts', ... })
This is fantastic for DX — no network hop, full type safety, one deployment unit. But getPayload() initializes the CMS: it validates that PAYLOAD_SECRET is set (it signs auth tokens with it) and connects Mongoose to DATABASE_URL. No secret → it throws. No reachable MongoDB → it throws.
Put the two together
Now connect the dots:
next buildexecutes pages andgenerateStaticParams.- Those call
payload.find(...). payload.find(...)initializes Payload, which demands a secret and a live MongoDB.
So a completely standard Payload project has an implicit, easy-to-miss requirement: next build needs a database connection. In development you never notice, because your .env and local MongoDB are always there. The build "just works" — for the wrong reason.
And it's rarely just one page. In our project the build-time database touchpoints were:
Code that runs during next build |
What it queries |
|---|---|
generateStaticParams (3 dynamic routes) |
page and post slugs |
Prerendering / and /posts |
page content |
| The root layout + its metadata | header, footer, site-settings globals |
That last row is the killer: the root layout wraps every page. Even if all your content routes were dynamic, prerendering something as innocent as the 404 page still renders the layout — which fetches globals — which initializes Payload.
Part 2: The crime scene — building in CI, running in ECS
Here's the question I asked when I first hit this: "My container runs on ECS in AWS, right next to the database. Why is the build failing on MongoDB?"
Because the build doesn't happen on ECS. The image is built in a completely different world than the one it runs in:
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ BUILD TIME │ │ RUN TIME │
│ GitHub Actions runner │ push │ AWS ECS (Fargate/EC2) │
│ │ ─────► │ │
│ • ephemeral VM, outside │ ECR │ • inside your VPC │
│ your VPC │ │ • security groups allow │
│ • no route to MongoDB / │ │ MongoDB / DocumentDB │
│ DocumentDB / Atlas │ │ • secrets injected from │
│ • no secrets (and it must │ │ Secrets Manager / SSM │
│ stay that way) │ │ as env vars │
└──────────────────────────────┘ └──────────────────────────────┘
This split isn't an accident you should work around — it's the whole point of containers:
A Docker image is an immutable artifact. You build it once, in CI, and the same bytes run in staging and production. If the build needed the production database, "build once, run anywhere" would be a lie — the artifact would be entangled with one environment.
The CI runner can't reach your database — by design. A GitHub Actions runner is an ephemeral VM on GitHub's infrastructure, outside your VPC. Your DocumentDB/Atlas instance (hopefully!) doesn't accept connections from arbitrary internet IPs. Opening your database's security group to CI runners so that a compile step can pass would be a security hole with a build log for a receipt.
Secrets don't belong in builds. Anything present at build time can leak into image layers or build logs, and images get pushed to registries and cached on machines. That's why the runtime pattern exists: ECS injects
PAYLOAD_SECRETandDATABASE_URLfrom Secrets Manager into the container's environment when it starts — never before.
So the real diagnosis isn't "CI is misconfigured, it can't see MongoDB". It's the opposite: CI is correctly isolated, and the build process was wrong to want a database in the first place.
Part 3: The fix — a build that needs nothing
The main move: compile-only builds
Next.js can split its two build phases. The flag:
RUN PAYLOAD_SECRET=build-placeholder \
pnpm exec next build --experimental-build-mode compile
--experimental-build-mode compile performs compilation and skips static generation entirely. No page code executes, so getPayload() is never called, so no secret and no MongoDB are needed. The build becomes hermetic: it depends on your source code and your lockfile, nothing else.
The trade-off is honest and small: the image ships no prerendered HTML. Every route renders on its first request — where the real database is available — and is then cached according to its own caching/revalidation rules. For a CMS-driven site this is barely a trade-off at all: your content changes at runtime anyway, and your prerendered pages were only as fresh as the last deploy.
Two details worth copying:
- The placeholder
PAYLOAD_SECRETis scoped to the singleRUNcommand, so it's never stored in an image layer (Docker's linter will rightly complain aboutENV PAYLOAD_SECRET=...). - If you use
next-sitemapvia apostbuildscript, call it explicitly — npm'spostbuildhook only fires afternpm run build, not after a directnext buildinvocation.
Defense in depth: make the code tolerate a missing database
The compile flag fixes Docker, but I also hardened the app so that even a full next build survives without a database. Two patterns:
1. generateStaticParams returns nothing instead of throwing:
export async function generateStaticParams() {
try {
const payload = await getPayload({ config: configPromise })
const posts = await payload.find({ collection: 'posts', select: { slug: true } })
return posts.docs.map(({ slug }) => ({ slug }))
} catch (error) {
// No database at build time — skip prerendering; these routes
// render on demand at runtime (dynamicParams is on by default).
console.warn('skipping prerender:', error)
return []
}
}
2. Shared data fetchers defer the route to request time. Next 15+ has connection(): awaiting it during prerendering tells Next.js "this route can't be static — render it per request", while at runtime it's a no-op. Wrapped around the layout's globals fetcher:
try {
return await getCachedGlobal()
} catch (error) {
await connection() // during build: defer route to request time
throw error // at runtime: real errors still surface
}
One gotcha: connection() is neutered under export const dynamic = 'force-static', so a route that must be able to defer can't force staticness. (Another: dynamic APIs like connection() aren't allowed inside an unstable_cache scope — call it outside the cached function.)
With both layers, no environment — laptop, CI, Docker — depends on MongoDB being reachable at build time. When a database is present, everything prerenders exactly as before.
The takeaways
next buildruns your code. Any data fetching in prerenderable pages,generateStaticParams, or the root layout becomes a build-time dependency. Know your touchpoints — the layout is the sneaky one.- Build and runtime are different environments on purpose. CI has no VPC access and no secrets, and that's a feature. Don't punch holes; make the build hermetic instead.
next build --experimental-build-mode compileis the one-line fix for Payload-in-Docker: no static generation, no Payload init, no MongoDB.- Never bake secrets into images. A command-scoped placeholder for the build, real values injected by ECS at runtime.
- The error message says "cannot connect to MongoDB", but the bug was never the connection — it was asking for one at the wrong time.
Comments
Post a Comment