Static config in your artifacts: The comfort and the cost
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.
# 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 and Layer 2 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:
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?
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 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 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 that took down the cache subsystem and caused the outage. Their availability report 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:
# 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?
# 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. 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 and Layer 2 doing what they do best: 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.
Next: GitOps Deploys Your Code. What Deploys Your Decisions?
