> Markdown version of https://www.getunleash.io//blog/everything-you-cannot-do-with-environment-variables
> For clean Markdown of any blog post, append .md to its URL.
> For a site index, see https://www.getunleash.io//llms.txt.

# Everything you can’t do with environment variables (and what you actually should)

_Published 2026-06-23 by Wojtek Gawronski in How to._

Let me be clear upfront: **environment variables are great and they have their place**. This is not a hit piece.

[The Twelve-Factor App](https://12factor.net/) got this right in 2011. Environment variables are the universal interface for process-level configuration. Every language reads them. Every container runtime injects them. Every CI/CD pipeline sets them. Every orchestrator manages them. Every operating system has them. They’re the closest thing we have to a truly cross-platform, cross-language, cross-framework configuration primitive.

You have used environment variables for so many things:

-   **Database connection strings**: Different per environment, sensitive, known at deploy time.
-   **API keys and secrets**: Hopefully, through a secrets manager, and not hardcoded in a **.env** file committed to source control.
-   **Service URLs and ports**: Where is the auth service? What port does the cache listen on?
-   **Environment detection**: think **NODE\_ENV=production** or similar. Simple. Effective. Universal.
-   **Resource allocation hints**: Worker count, pool size, memory limits, execution environment-specific settings (e.g., JVM settings) – values that are set once per deployment and don’t change during the process lifetime.

These are process-lifetime, environment-specific, context-free values. They’re [Layer 2](https://www.getunleash.io/blog/where-configuration-decisions-actually-belong) decisions (set at deploy time, same for every request) and some are even [Layer 1](https://www.getunleash.io/blog/where-configuration-decisions-actually-belong) (baked into the build). Environment variables handle them perfectly.

The problem starts when we ask env vars to do something they were never designed for. And some of you already saw that (or experienced already) in the examples listed above.

## Where environment variables break down

### No per-request context

This is the fundamental limitation. An environment variable is the same value for every request hitting that process. It can’t know who the user is, which tenant they belong to, what region they’re in, or what plan they’re on.

```shell
# This is the same for every request, every user, forever
FEATURE_NEW_CHECKOUT=true
```
Want to enable the new checkout for 5% of users? Want to roll it out to enterprise customers first? Or show it to internal testers but not production users? You can’t. The variable is **true** or **false**, and it’s the same **true** or **false** for every request.

The workaround teams build: a proxy layer that reads a database or config service, resolves targeting rules, and… congratulations, you’ve built a feature flag system.

### Changes require restart

Modifying an environment variable on a running process requires at minimum a restart, and with container orchestrators like Kubernetes, typically a rolling update or pod recreation. That means:

```yaml
# Changing this value in production:
# 1. Update the ConfigMap
# 2. Trigger a rolling restart (or wait for reconciliation)
# 3. Wait for new pods to be ready
# 4. Old pods drain connections
# Total: minutes, at best
env:
  - name: ENABLE_CACHE_V2
    valueFrom:
      configMapKeyRef:
        name: feature-config
        key: ENABLE_CACHE_V2
```
During an incident, _minutes at best_ are an eternity. If the value you need to change is a [kill switch](https://www.getunleash.io/blog/kill-switches-best-practice) for a failing dependency, and the fastest you can change it is a rolling restart across your fleet – that’s not a kill switch. That’s a slow, polite request for the problem to eventually go away.

Reversibility is a first-class property of runtime control. An environment variable that requires a rolling restart to change doesn’t provide it.

Compared to a runtime flag evaluation: where the SDK polls or receives a push update, the flag changes in (milli)seconds, the next request sees the new value. No restart, no rolling update, no draining required.

### No coordination across processes

Each process reads its own copy of the environment at startup. There’s no mechanism for coordinating a change across all instances simultaneously. If you update a _ConfigMap_, pods pick up the change at different times depending on restart order, readiness probe timing, and rolling update strategy.

Again, for something like a _kill switch_ – where you need _every_ instance to stop calling a failing dependency _now_ – this uncoordinated rollout means you have stragglers making bad calls for the duration of the update window.

### No audit trail

Who changed **ENABLE\_DARK\_MODE** from **false** to **true** in production at 3 AM? If it’s in a _ConfigMap_ managed by _GitOps_, you might have a commit hash. If someone ran **kubectl edit configmap** during an incident, you might have nothing. The bottomline is: it depends on your tooling.

Additionally, environment variables have no native concept of change tracking, approval workflows, or audit logs. They were not designed to do that in the first place. The value is just there. It was changed. By someone. _Probably_.

You can argue that GitOps is the answer, but I would counterargue that it only covers half the story. A commit hash tells you what was _deployed_ and when. It doesn’t tell you what was _activated_, for whom, or why. That gap – between deployment audit and runtime audit – is where incidents go untraced and compliance auditors start asking uncomfortable questions.

### No gradual rollout

It’s all or nothing. When you flip **FEATURE\_NEW\_PRICING=true**, every user on every instance of your application sees it simultaneously. There’s no feedback loop like _enable for 1%, watch the metrics, ramp to 10%, then 50%, then 100%_. The only way to approximate gradual rollout with environment variables is to deploy different values to different applications – which means maintaining parallel deployments, separate routing, and a coordination overhead that grows linearly with the number of variants you want to test.

### No targeting

You can’t say: _enable this for tenants in the EU_ or _show this to users on the enterprise plan_ with an environment variable. The variable doesn’t know about tenants, plans, or regions at the process level. Those are contextual concepts, bound at a request-level.

## The Layer 1.5 trap

If you look at this list of gaps, you’ll notice they all point in the same direction: **environment variables are a deploy-time mechanism being asked to make request-time decisions.**

They sit between _Layer 1_ (_baked into the artifact_) and _Layer 2_ (_configured at deployment_). They’re more flexible than a hardcoded constant, but they’re still fundamentally static from the perspective of a running process. I would call this _Layer 1.5_ and it’s the exact spot where most teams stuck.

The progression usually looks like this:

1.  **Start with environment variables.** Simple, works, no dependencies.
2.  **Need to change something without restarting.** Build a config service that reads from a database. Application polls it.
3.  **Need to know who the user is.** Add context passing. Application resolves the value based on user attributes.
4.  **Need an audit trail.** Add logging around config changes.
5.  **Need gradual rollout.** Add percentage-based logic.
6.  **Need targeting.** Add rule evaluation.

At step 2, you stopped using environment variables and you’ve started building a feature flag system. But you might not realize it yet, because it still _feels_ like configuration.

This is how most homegrown feature flag systems are born. A database table replaces the **.env** file. A REST endpoint replaces the config reload. An if-statement reads the value. It’s usually _Layer 1.5_ dressed up as [_Layer 3_](https://www.getunleash.io/blog/where-configuration-decisions-actually-belong). It works, until you need the things that actually make _Layer 3_ valuable: per-request context, consistent targeting, lifecycle management, edge evaluation, and governance.

At that point you’ve built a distributed system with caching, consistency requirements, and a multi-language client to call that service – competing with feature delivery for the same engineers’ time.

I think the problems and pains are visceral enough, so I want to pause here. We’ll explore this homegrown maturity trap in depth later in the series.

## The Boundary

Here’s the rule I like to use:

> **Environment variables are for process-lifetime,  
> environment-specific, context-free values.**

If the value:

-   Is the same for every request → _environment variable is fine_
-   Changes less often than you deploy → _environment variable is fine_
-   Doesn’t need to know about request-level attributes → _environment variable is fine_
-   No operational urgency for change (e.g., kill switch) → _environment variable is fine_

If the value:

-   Needs to change without a deployment → **_you need runtime control_**
-   Needs to differ contextually per-request → **_you need runtime control_**
-   Steers gradual rollout or any other flexible exposure → **_you need runtime control_**
-   Needs an audit trail of who changed what and when → **_you need runtime control_**
-   Is a kill switch for an incident response → **_you need runtime control_**

The second list is _Layer 3_ territory. That’s where runtime control belongs, with mechanisms such as feature flags, contextual evaluation, and externalized decision-making.

## What this means practically

I’m not suggesting you rip out your environment variables. That would be absurd. They do exactly what they’re supposed to do, and they do it well.

What I’m suggesting is that the next time you reach for an environment variable to control something that’s really a _runtime decision_ – a rollout, an experiment, a kill switch, a per-tenant behavior – pause and ask: _am I using a Layer 1.5 tool for a Layer 3 problem?_

Because the cost of that mismatch isn’t the cost of the environment variable itself. It’s the restart during the incident; the “_all users at once”_ rollout that you can’t roll back without another deployment; the config change at 3 AM with no audit trail; and the workaround code you build around a mechanism that was never designed for what you’re asking it to do.

Environment variables got you here. They’re solid. They’re staying.

But like any other tool, they are not a silver bullet. And pretending they are is how teams end up with a dirt road where a paved path for _Layer 3_ should be.

Environment variables aren’t the only _Layer 1-2_ mechanism being stretched beyond its design. Next, we look at what happens when static configuration baked into your artifacts hits the same ceiling. And why comparing the speed of light with your CI/CD pipeline’s speed will show the same problem – an upper bound, but that one is on your reaction time.

_This is the 2nd post of “The Runtime Control Layer” – a series on FeatureOps for infrastructure engineers._

Previously: [Build, Deploy, or Request: Where your configuration decisions actually belong](https://www.getunleash.io/blog/where-configuration-decisions-actually-belong).  
Next: Static Config in Your Artifacts: The Comfort and the Cost.
