> Markdown version of https://www.getunleash.io//blog/static-config-vs-runtime-control
> For clean Markdown of any blog post, append .md to its URL.
> For a site index, see https://www.getunleash.io//llms.txt.

# Static config in your artifacts: The comfort and the cost

_Published 2026-07-15 by Wojtek Gawronski in Industry Insights._

There’s a reason why infrastructure engineers love static configuration.

A _Helm_ values file committed to source control, a **config.json** embedded in a _Docker_ image, a **.properties** file bundled with a _JAR_ – these are _knowable_. You can reason about the behavior at runtime, trace exactly what configuration was running at any point in time by looking at the artifact SHA and the Git history. No ambiguity; no drift; no _someone changed it in production and didn’t tell anyone_ moment.

That’s powerful. And for a lot of decisions, it’s the right call.

```yml
# values.yaml — committed to Git, versioned, reviewed in a PR
replicaCount: 3
image:
  repository: registry.internal/checkout-service
  tag: "v2.14.7"
resources:
  requests:
    memory: "256Mi"
    cpu: "200m"
  limits:
    memory: "512Mi"
    cpu: "500m"
config:
  jvmHeapSize: "384m"
  threadPoolSize: 16
  gracefulShutdownTimeout: 30
```
Every value here is reviewed in a pull request, tested in _CI_, deployed through a pipeline, and reconciled by _ArgoCD_ or _Flux_. If something goes wrong, **git log** tells you who changed what, when, and why (_if the commit message is decent_). If you need to revert, **git revert** and the reconciliation loop handles the rest.

This is [_Layer 1_](https://www.getunleash.io/blog/where-configuration-decisions-actually-belong) [and](https://www.getunleash.io/blog/where-configuration-decisions-actually-belong) [_Layer 2_](https://www.getunleash.io/blog/where-configuration-decisions-actually-belong) at their best. Static configuration baked into artifacts (_Layer 1_) and environment-specific values managed by GitOps (_Layer 2_). Deterministic, auditable, reproducible.

So what’s the problem?

## The reaction time ceiling
The problem is the clock. Plus, conceptually it has more in common with the _speed of light_ constant than you may think.

Let’s walk through what happens when you need to change a static config value in production:

```markdown
1.  Engineer identifies the needed change            0:00
2.  Opens a PR modifying values.yaml                 0:02
3.  CI runs linting, validation                      0:05
4.  Peer review (if you're lucky, someone's free)    0:15
5.  Merge to main                                    0:17
6.  CI pipeline: build, test, push artifact          0:30
7.  GitOps reconciliation detects change             0:33
8.  Rolling update begins                            0:35
9.  New pods ready, old pods drained                 0:40
10. Change is live                                   0:42
```
Forty-two minutes, and I am not even being _mean_ here. That’s the _happy path_ – assuming a reviewer is available, _CI_ passes the first time, and no approval gates block the merge.

For a _JVM_ heap size or a thread pool count update, forty-two minutes is fine. You don’t change those under pressure (_until you do_). They’re tuning parameters you adjust deliberately, test in staging, and roll out during a maintenance window.

### But what about this value, also in your _Helm_ chart?

```yml
config:
  # "Feature flag", kinda.
  enableNewPaymentProvider: true
  # Another one.
  enableCacheV2: true                
  # What if this needs to. change NOW?
  rateLimitPerSecond: 1000           
  # Kill switch — but is it fast enough?
  maintenanceMode: false  
```
When the new payment provider starts timing out at 14:30 on a Friday, and you need to disable it _now_, your forty-two-minute pipeline run isn’t a safety net. **_It’s a constraint_**. It’s your upper bound, like the speed of light in physics. Except here, it does not guard the rules of nature, but provides a ceiling for your reaction time.

When your rate limit needs to double because of unexpected traffic, you’re waiting for CI to build an artifact that differs from the current one by exactly one integer.

When you need to put a tenant into maintenance mode while you investigate a data issue, you’re modifying a _Helm_ chart, running **helm upgrade**, and hoping the rollout finishes before the customer notices.

This isn’t theoretical. In June 2025, a Google Cloud backend change deployed without a feature flag triggered a cascading failure across Gmail, BigQuery, and Cloud Run – global impact. [Google’s own postmortem](https://status.cloud.google.com/incidents/ow5i3PPK96RduMcb1SsW#:~:text=Feature%20flags%20are%20used%20to%20gradually%20enable%20the%20feature%20region%20by%20region%20per%20project%2C%20starting%20with%20internal%20projects%2C%20to%20enable%20us%20to%20catch%20issues.%20If%20this%20had%20been%20flag%20protected%2C%20the%20issue%20would%20have%20been%20caught%20in%20staging.) noted: _if the change had been flag-protected, it would have been caught in staging_.

In November 2025, a Cloudflare configuration update propagated globally without a kill switch causing five and a half hours of downtime. [Their outage report](https://blog.cloudflare.com/18-november-2025-outage/#:~:text=Enabling%20more%20global%20kill%20switches%20for%20features) points out clearly: _enabling more global kill switches for features_.

In March 2026, GitHub a bug caused every user’s cache to expire, get recalculated, and get rewritten – causing a [thundering herd effect](https://en.wikipedia.org/wiki/Thundering_herd_problem) that took down the cache subsystem and caused the outage. [Their availability report](https://github.blog/news-insights/company-news/github-availability-report-march-2026/#:~:text=We%20have%20added%20a%20killswitch%20and%20improved%20monitoring%20to%20the%20caching%20mechanism%20to%20ensure%20we%20are%20notified%20before%20there%20is%20user%20impact%20and%20can%20respond%20swiftly.) from that month acknowledged the downtime and mentions: _we have added a killswitch and improved monitoring to the caching mechanism_.

Three of the most mature engineering organizations on the planet. Same root cause: a static config change with no runtime escape route.

## GitOps optimizes deployment, not reaction
GitOps is often treated as the answer to everything. And for deployment, it is. Its reconciliation loop is a brilliant mechanism: declare the desired state in source control, and the cluster/infrastructure converges toward it automatically. Drift gets detected and corrected. Every change has an audit trail. The deployment pipeline is version controlled.

But GitOps is _Layer 2_ infrastructure. It answers the question: _is the right artifact running with the right configuration?_ It doesn’t answer: _should this feature be active for this user on this request?_

Its reconciliation loop optimizes the _deployment of static configuration_. It makes static config faster and more reliable to deploy. It doesn’t eliminate the fundamental limitation of static config: **it can’t adapt to what’s happening right now, for this user, on this request.**

There’s an interesting tell in GitOps tooling itself. ArgoCD has an **ignoreDifferences** configuration:

```yml
# ArgoCD Application spec
spec:
  ignoreDifferences:
    - group: apps
      kind: Deployment
      jsonPointers:
        - /spec/replicas    # Don't fight the HPA
```
### Why does this exist?

Because some values _shouldn’t_ be reconciled from source control. In the example above, the _Horizontal Pod Autoscaler_ (_HPA_) sets replicas based on real-time metrics. If _GitOps_ tried to enforce the static value from the manifest, it would fight the autoscaler in a loop.

This is _GitOps_ acknowledging that some state belongs at _Layer 3_ because it’s runtime-determined, context-dependent, and dynamic. The **ignoreDifferences** pattern is an admission that the _Layer 2_ reconciliation model doesn’t cover everything. Feature flag state is in the same category: it’s runtime state that should never be in a manifest committed to source control, because the whole point is that it changes faster and/or in a contextual way that is disconnected from the commit-merge-deploy cycle.

This creates a governance gap. GitOps gives you an audit trail for everything in Git – what was deployed, when, and who approved the PR. But runtime decisions that bypass Git leave no trace in the Git log, such as auto scaling, flag toggles, rollout adjustments, and kill switch activations.

The deployment is audited; the runtime behavior isn’t. We’ll explore how GitOps and FeatureOps close this gap together in the next post.

## The reproducibility argument – and its limits
The strongest case for static config is reproducibility. _I know exactly what’s running because the config is in the artifact_. That’s true, and it matters for compliance, debugging, and incident forensics.

But let’s stress-test this thesis. Does static config actually give you full reproducibility?

```yml
# These are in your Helm chart:
config:
  databaseUrl: "postgres://prod-primary:5432/app"
  redisUrl: "redis://cache-cluster:6379"
  paymentProviderUrl: "https://api.stripe.com/v1"
```
The _values_ are reproducible. The _behavior_ those values produce is not. The database might be under replication lag. Redis might be experiencing eviction pressure. Stripe might be having a partial outage. Values coming from your static config are deterministic, the systems that are referenced aren’t.

This means the guarantee static config provides is narrower than it seems: you know what config was _intended_, not what behavior _occurred_. During troubleshooting you are correlating source control audit logs from GitOps with your monitoring, to pinpoint a root cause, like an external provider’s outage or the database replication lag causing performance issues. That’s runtime behavior and the real user experience, so you need runtime observability and runtime control.

Static config gives you reproducible _intent_. Runtime control gives you adaptive _behavior_.

## Static config as foundation, not ceiling
None of this means you should stop using static configuration. That would be ridiculous, like in the case with [environment variables](https://www.getunleash.io/blog/everything-you-cannot-do-with-environment-variables). Your JVM heap, your resource requests and limits, your base image tags, your TLS certificate paths, your service discovery endpoints – these belong in version-controlled manifests, deployed through pipelines, reconciled by _GitOps_.

Static config is the _bedrock_. It’s [_Layer 1_](https://www.getunleash.io/blog/where-configuration-decisions-actually-belong) [and](https://www.getunleash.io/blog/where-configuration-decisions-actually-belong) [_Layer 2_](https://www.getunleash.io/blog/where-configuration-decisions-actually-belong) [doing what they do best](https://www.getunleash.io/blog/where-configuration-decisions-actually-belong): providing a stable, auditable, reproducible foundation. The mistake is treating it as the _ceiling_ or approaching it from a GitOps purist’s perspective – as if every configuration decision in your system should go through the same mechanism.

When your _Helm_ chart contains both **jvmHeapSize** and **enableNewPaymentProvider**, those two values have radically different binding requirements.

**jvmHeapSize,** a Layer 1 decision:

-   Changes quarterly
-   No per-user targeting
-   No kill switch urgency
-   Hours of reversal time are fine
-   Needs environment context only

**enableNewPaymentProvider**, a Layer 3 decision:

-   Changes multiple times a day during rollout
-   Needs per-tenant targeting for staged rollout
-   Kill switch is critical (an outage means failed checkouts)
-   Reversal must happen in seconds
-   Needs user, tenant, region, and plan-tier context

Same _Helm_ chart, same deployment pipeline, same reversal cost. But wildly different binding requirements. The JVM heap is a _Layer 1_ decision. The payment provider toggle is a _Layer 3_ decision. Treating them identically just because they both live in **values.yaml** is an example of misplaced tool usage. Using a DIY analogy: you can imagine what happens when you try to put a screw in – and the hammer is closer to your hand than a screwdriver.

## The Reversal Test
When you put a decision in static config, you’re making an implicit choice about how fast you can undo it. Ask yourself:

> When this decision is wrong, what happens?

If the answer is: _we adjust it in the next maintenance window_, _static config is fine_. You chose _Layer 1/2_ deliberately, and the reversal cost is acceptable.

If the answer is: _customers see errors until we can get a fix through the pipeline_, the decision doesn’t belong in the static config. The reversal cost is too high for the risk profile of that particular value.

The cost of a wrong static config isn’t the wrong value itself. It’s the _time required to correct it_, multiplied by the _impact per second_ of it being wrong. A wrong heap size wastes some memory for a few hours (_which may cause an outage anyway, but that’s a different story_). A wrong kill switch value means a broken dependency stays connected for as long as your pipeline takes to push a fix (_assuming that CI/CD wasn’t affected by the potential outage so bad that you can’t even deploy until the whole system recovers_).

Reversibility isn’t a feature you bolt on. It’s a property of where you put the decision in the first place. Static config gives you source control-level reversibility: revert a commit, wait for reconciliation. Runtime control gives you flag-level reversibility: toggle off, effect in seconds.

Both are legitimate. The question is which one matches the decision you’re making.

## What belongs where
Here’s the boundary I draw:

-   **Static config (Layer 1/2):** Process-lifetime values. Resource allocation. Infrastructure topology. Base images. TLS configuration. Anything where “_change it next deploy”_ is an acceptable reaction time and per-request context is irrelevant.
-   **Runtime control (Layer 3):** Anything that needs to change without a deployment. Anything that needs to differ per-user, per-tenant, or per-request. Anything that serves as a kill switch or operational lever. Anything where the reversal cost of _waiting for the pipeline_ is unacceptable for the business.

Static config is the comfort of knowing exactly what’s running. Runtime control is the escape route when what’s running isn’t what you need.

You need both. When the platform provides runtime control as shared infrastructure – reversibility as a service – every team gets that escape route without building their own.

The series continues by exploring how static config and runtime control work together – starting with the relationship between _GitOps_ and feature flags, where the deployment layer meets the runtime layer.

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

_Previously:_ [_Everything You Can’t Do with Environment Variables._](https://www.getunleash.io/blog/everything-you-cannot-do-with-environment-variables)_  
Next: GitOps Deploys Your Code. What Deploys Your Decisions?_
