How I Handle Environment Variables and Secrets on a Self-Hosted Server
Cloud

How I Handle Environment Variables and Secrets on a Self-Hosted Server

NEXT_PUBLIC_ variables get inlined into the JavaScript bundle at next build, not read at runtime — which means the moment you rebuild a Next.js image per environment instead of injecting config at runtime, you've built something that can leak.

Published September 25, 202610 min readUpdated Sep 6, 2026

Written by · Full-Stack Agentic AI Software Engineer — AI Agents, Automation & Revenue Systems for GTM/RevOps teams

In brief

What's the actual difference between build-time and run-time environment variables in a self-hosted Next.js app, and why does mixing them up leak secrets?

A NEXT_PUBLIC_-prefixed variable is inlined directly into the JavaScript bundle when `next build` runs — it becomes a string literal in shipped code, readable by anyone who opens the browser's dev tools, by design. A variable without that prefix stays server-only and can be read at request time. The mistake that causes real leaks is treating a secret like a build-time value: baking it into an image (via a Dockerfile ARG that becomes an ENV, or a build-stage .env file) rather than injecting it into the running container at start, the way Coolify's environment store does. An image with a baked-in secret is a secret sitting in a registry; one that only becomes meaningful once secrets are injected at runtime is safe sitting there doing nothing.

  • NEXT_PUBLIC_ variables are compiled into the JavaScript bundle at next build — they are not a runtime read, and prefixing an actual secret with NEXT_PUBLIC_ ships it to every visitor's browser
  • Server-only variables (no NEXT_PUBLIC_ prefix) can be read at request time via `await connection()` in a Server Component, which is what makes one Docker image promotable across environments with different runtime values
  • A .env file copied into an image, or a secret passed as a Dockerfile build ARG, becomes part of that image's layers permanently — deleting the file afterward doesn't remove it from the layer history
  • Rotating a secret injected at runtime (Coolify's env store) needs only a restart; rotating one baked into an image needs a full rebuild and redeploy, which is the practical cost of getting this wrong

Evidence notes

Next.js environment variables and connection()

By default env vars are server-only; NEXT_PUBLIC_-prefixed ones are inlined into the JS bundle at next build. Server-side vars can be safely read at runtime during dynamic rendering via `await connection()`, which is what allows one Docker image to be promoted through multiple environments with different values — verified against Next.js 16.3.3's self-hosting docs, September 2026.

Docker build ARG vs ENV persistence

A value passed as a Dockerfile ARG at build time, if assigned to an ENV, is baked into the resulting image's layer history and can be recovered from the image even after the container starts, unless deliberately excluded from the final stage of a multi-stage build.

Continue with purpose

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.

  1. Update the value in Coolify's environment store for the affected application

  2. Restart the container — no rebuild, no new image, no registry push required

  3. 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

  4. 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

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.

Build time or run time — pick correctly

Five questions on where a value actually gets read, and what that means for rotating it. Answers and reasoning at the end.

1 / 5
You change a NEXT_PUBLIC_ variable in Coolify's dashboard and restart the container. Does the browser see the new value?

Apply this article

How to turn insights into execution

A practical sequence for teams turning concepts into production outcomes.

environment variablessecrets managementCoolifyNext.jsDockercloud

Audit your current state

Map the bottlenecks and constraints connected to the article’s core problem.

Choose one bounded change

Test the most useful recommendation on one workflow before widening the scope.

Measure what changed

Keep the parts that improve the work, document what failed, and make the next decision from evidence.

Next step

Need help applying this in your stack?

I can translate these patterns into a concrete implementation plan for your team.

Discuss implementationBack to blog

Replies usually within 24 hours.

Next Steps

Continue reading

My Free Monitoring Stack for Self-Hosted Apps

Vercel gives you monitoring whether you ask for it or not. A VPS gives you a blank terminal and the assumption you'll figure it out. Here's what to actually watch on a self-hosted box, with tools that cost nothing, and why watching from the box itself is the one setup that will lie to you.

Navigate

The Security Mistakes I See New Self-Hosters Make

These aren't rare. They're the same seven patterns, documented in breach reports, CVE databases, and botnet postmortems, showing up on new self-hosted boxes on a loop — because the defaults that make setup fast are the same defaults that make a box exploitable.

Navigate

Zero-Downtime Deploys and Instant Rollbacks on a Cheap VPS

docker compose up -d looks like a deploy and behaves like an outage — there's a gap between the old container stopping and the new one answering requests, and on a small box that gap is exactly where a real user lands.

Navigate

How to Self-Host Next.js With Coolify: A Complete 2026 Walkthrough

Every Coolify tutorial stops at "push to deploy." This one covers the parts that actually break a real app: standalone output, a build that OOMs on a small box, env vars baked in at the wrong time, and a health check that would have caught all of it.

Navigate

How I Self-Host PostgreSQL for My SaaS (and When I Wouldn't)

Running Postgres in a container is easy. Running it in a way that survives a redeploy, a full disk, and an eventual major-version upgrade is the actual job. Here's the setup, tuned against Postgres's own defaults, and the honest list of where managed wins outright.

Navigate

10 Mistakes That Break a Self-Hosted SaaS

None of these ten show up as a single dramatic outage. They show up as a disk that quietly fills, a rollback that turns out to be impossible, a backup nobody ever restored. Here's the mechanism behind each one, and the fix.

Navigate

My Complete Self-Hosted Stack for SaaS in 2026

Contabo, Coolify, Docker, Cloudflare, Postgres, R2, Resend, Uptime Kuma, Turborepo and Docker Hub. Here's every piece of the stack I actually run, what each one replaced, and why I picked it over the alternatives.

Navigate

The Simplest Production Architecture for a Bootstrapped SaaS

One box, a handful of managed pieces around the edges, and a very short list of things you're not allowed to build yet. Here's the architecture, priced out to $20 a month, and the exact signal that tells you when to add each thing you skipped.

Navigate

The Docker Concepts You Need Before Coolify Hides Them From You

Coolify's whole pitch is that you shouldn't have to think about Docker, and most days that's true. The days it isn't are the days a container is 'running' but unhealthy, a redeploy quietly ate a volume, or an env var vanished — and the dashboard doesn't explain any of it.

Navigate

How I Secure a Fresh VPS Before Deploying Anything

A brand-new VPS gets scanned within minutes of getting an IP address. Here's the exact order I run through before a single container touches the box — and which of these steps are real protection versus which ones are just theatre.

Navigate