The single most common mistake in self-hosted Next.js deployments isn't a Docker misconfiguration or a Coolify setting — it's a variable named `NEXT_PUBLIC_API_KEY`. The prefix is doing exactly what it says: making that value public. Not 'available on the server, if you ask nicely' — compiled directly into the JavaScript bundle every visitor's browser downloads, readable by anyone who opens dev tools and looks.
It's an easy mistake to make precisely because the two categories of variable — one meant for the browser, one that must never reach it — look identical in a `.env` file. Nothing about the syntax warns you. The only signal is a seven-character prefix, and it's trivially easy to add it out of habit, or because some other value in the same file legitimately needed it, without registering that this particular line is different. Readers who reach environment variables through a growth or RevOps role will want the XenGrowth practice alongside this.
What does NEXT_PUBLIC_ actually do, mechanically?
By default, an environment variable in a Next.js app is server-only — the browser never sees it. Prefixing it with `NEXT_PUBLIC_` is how you deliberately opt a value into being available client-side, and the mechanism is inlining: at `next build`, every reference to a `NEXT_PUBLIC_`-prefixed variable in the code is replaced with the literal string value of that variable at build time, baked into the compiled JavaScript. It isn't read at runtime the way a server-only variable can be — it's a find-and-replace that happens once, during the build, and the result is permanent for that build's output.
That's the correct behavior for what it's designed for — a public analytics ID, a publishable Stripe key, a feature-flag toggle meant to be visible client-side. It is exactly the wrong behavior for anything that needs to stay secret, because there is no version of 'inlined into shipped JavaScript' that isn't public. Grep the built bundle for the variable name and the value is sitting right there in plaintext.
There's no runtime override for this either, which is the part that trips people up on Coolify specifically. Someone sets a new value for a `NEXT_PUBLIC_` variable in Coolify's environment panel, restarts the container, and the app still shows the old value — not because Coolify failed to inject it, but because the container is running a bundle that was compiled against whatever value existed at build time, and a container restart doesn't rerun `next build`. Changing a `NEXT_PUBLIC_` value always means rebuilding the image, full stop; there's no shortcut through the runtime store for anything that was inlined. The XenGrowth resource library goes further into the operations side of this.
So how does a server-only value get read, if not at build time?
A variable without the `NEXT_PUBLIC_` prefix stays server-only, and the App Router can read it during dynamic rendering — via `await connection()` inside a Server Component — at the moment a request actually comes in, not baked in ahead of time. That distinction is what makes a single Docker image usable across multiple environments with different config: the same built image can run in staging with one database URL and in production with another, because the value is read from the environment the container is actually running in, not compiled into the artifact that's identical in both places.
NEXT_PUBLIC_ variable | Server-only variable | |
|---|---|---|
When it's resolved | next build — compiled into the JS bundle once | Request time, read from the running container's environment |
Visible to the browser | Yes, by design — anyone can read it from shipped code | No — never sent to the client |
Same image, different environments | Not possible — a different value needs a different build | Works correctly — one image, config injected at container start |
Safe to put a secret in | Never | Yes, as long as it's injected at runtime, not baked into the image |
Why is a .env file copied into the image a leak, even if nobody prints it?
Because a Docker image is a stack of layers, and each layer is a permanent, addressable record of what that build step did — copying a file into the image writes it into a layer, and deleting the file in a later step doesn't remove it from the layer it was written into, only from the final visible filesystem. Anyone with pull access to the image can inspect its layer history and recover a file that was 'deleted' three steps later, because the earlier layer is still part of the image. The same applies to a secret passed as a Dockerfile `ARG` and then assigned to an `ENV` — it's readable in the resulting image's metadata unless the build is deliberately structured as a multi-stage build where the final stage never inherits that layer at all.
COPY .env . in a Dockerfile bakes that file's contents into a layer permanently, regardless of whether a later RUN rm .env appears to remove it
A build ARG assigned to an ENV persists in the image's inspectable history, not just during the build step that used it
docker history <image> and docker inspect <image> can both surface these values to anyone who can pull the image
A public registry makes this everyone's problem; even a private registry only limits who can pull, not what's recoverable once they do
The fix isn't a cleverer Dockerfile — it's not putting the secret in the build context at all. The image should be buildable, and safely public, with zero secrets present anywhere in its layers; everything it needs at runtime arrives after the container starts, injected by whatever's running it. A useful test before shipping any image: could this exact tag sit on a fully public registry, readable by a stranger, without anything bad happening? If the honest answer is no, something in that image doesn't belong there. On AI agents and marketing automation specifically, XenGrowth on governed AI marketing workflows is worth reading.
Secret location | Persists across rebuilds? | Rotation cost | Recoverable from a pulled image? |
|---|---|---|---|
Baked in via COPY .env or a build ARG | Yes — permanently, in every image tag it was ever built into | Full rebuild, push, redeploy | Yes — via docker history or a layer inspection |
Coolify's runtime environment store | No — lives outside the image entirely | A restart, nothing else | No — never part of the image |
Passed as a docker run -e flag manually | No, but easy to lose track of across servers if not centrally managed | Depends on whatever remembers to update it | No, though visible via docker inspect on that host |
Where do secrets actually live in this setup, if not in the image?
In Coolify's own environment variable store, scoped per application, injected into the container at start and never written into the image itself. GitHub Actions secrets hold what the build stage needs — registry credentials, mostly, since a correctly built app shouldn't need any secret at build time at all. Runtime secrets — the database URL, API keys, session secrets — live entirely in Coolify, set once through its dashboard or API, and handed to the container as environment variables the moment it starts, exactly the same mechanism a server-only Next.js variable reads from.
This is also the reason a single built image can be genuinely identical between staging and production, which matters more than it sounds like it should. If secrets had to be baked in, staging and production would need separate builds just to point at separate databases — two artifacts that are supposed to be 'the same code' but technically aren't, because a different value got compiled into each one. With everything server-only injected at runtime, the same image, byte-for-byte, gets promoted from staging to production by changing nothing except which Coolify environment it's deployed into. That's not just tidier — it's the only way to be certain the code that passed staging is exactly the code running in production, instead of a second build that merely resembles it.
What does rotating a secret actually cost under this setup?
A restart, nothing more. Because the secret was never part of the image, changing it in Coolify's environment store and restarting the container is the entire rotation procedure — the same image, unchanged, picks up the new value on its next start. Compare that to a secret that was baked into the image: rotating it means editing the Dockerfile or build args, rebuilding the whole image, pushing it to the registry, and redeploying — a full pipeline run to change one string, and every previously-pushed image tag still contains the old, now-compromised value sitting in a registry somewhere, which is the part people forget to worry about. Rotating the value doesn't retroactively scrub it from the tags built before the fix — those need deleting from the registry outright, not just superseding, if the exposure is taken seriously. XenGrowth on building one SEO and GEO content system goes further into AI search, GEO and discovery.
Update the value in Coolify's environment store for the affected application
Restart the container — no rebuild, no new image, no registry push required
The new value is read on next start; the previous value never lived in any image layer, so there's nothing else to clean up
If a secret was ever baked into an image by mistake, rotating it also means treating every tag built before the fix as permanently compromised, not just the current one
What does a docker inspect on a running container actually expose?
`docker inspect <container>` returns, among other things, the full list of environment variables the container is currently running with, in plaintext, to anyone with Docker access on that host. That's expected and fine for the server itself — whoever can run `docker inspect` on the box already has the access level where seeing runtime config isn't a new exposure, since they could just as easily read the app's own config files or database directly. It's a different story if that same command, or its output, ends up somewhere less trusted — a support ticket, a shared debugging session, a log aggregator that captures container metadata by default. Treat `docker inspect` output the same as any other place a secret can end up: fine on a trusted machine, not something to paste into a bug report without redacting it first.
The same caution extends to `docker exec` into a running container to check something — printenv, at that point, is running with the exact same visibility as inspect, just from inside instead of outside. Neither is a vulnerability by itself; both are reminders that 'runtime secret' means 'not in the image,' not 'invisible to anyone with legitimate access to the server.' The threat model this whole setup defends against is a leaked image or a compromised registry account, not a person who already has shell access to the box — at that point, plenty of other things are readable too, and env vars are the least of it.
An image with a secret baked in is a secret sitting in a registry. An image that only becomes meaningful once the runtime injects its config is safe sitting there doing nothing — which is the entire point of keeping build-time and run-time separate.
Where this fits in the rest of the pipeline
This split between build-time and run-time is one piece of a larger setup — how Next.js gets self-hosted on Coolify from scratch covers where the environment variable store fits into the full application setup, and the complete git-push-to-production pipeline shows how build secrets (registry credentials) and runtime secrets (database URLs, API keys) end up flowing through two entirely different paths. And if the difference between what an image contains and what a running container knows still feels blurry, the Docker concepts underneath Coolify's dashboard is the place that makes it concrete.
Further reading from XenGrowth
The XenGrowth resource library — what you'll learn: how the commercial side of this work is run, across search, automation and revenue operations.
XenGrowth on governed AI marketing workflows — what you'll learn: how the teams running AI marketing agents keep them governed and measurable.
XenGrowth on building one SEO and GEO content system — what you'll learn: how search and AI-answer visibility get run as a single content system.
Where this work meets go-to-market
If environment variables is part of a growth programme rather than a standalone build, XenGrowth's operator guides is the companion reading.
Five questions on where a value actually gets read, and what that means for rotating it. Answers and reasoning at the end.











