Standardizing Feature Flags with OpenFeature + Flagd — An architecture for managing gradual rollouts and A/B testing as code on Kubernetes, without vendor lock-in
When you use paid flag services like LaunchDarkly or Split, you eventually face an uncomfortable reality: flag logic is deeply embedded in the vendor SDK, so switching services means reworking your entire codebase. As microservices multiply and teams grow, this problem quietly accumulates as technical debt.
OpenFeature is a CNCF incubating project and a vendor-neutral feature flag standard that tackles this problem head-on. Flagd is an open-source flag evaluation daemon designed to that standard — it can run as a sidecar in Kubernetes or as a standalone process. Together, you declare flag definitions as YAML/JSON in Git, auto-sync with ArgoCD or Flux, and flag changes enter a PR → Review → Merge → Auto-apply workflow. Once the initial setup is done: no deployments, no pod restarts.
This article covers the architecture for connecting OpenFeature + Flagd in a Kubernetes environment. We'll walk through gradual rollout, A/B testing, and kill-switch scenarios with code, and explain how to build flag infrastructure without vendor lock-in — from automatic sidecar injection to GitOps integration.
Core Concepts
The Provider Pattern: Making Flag Backends Swappable
The heart of OpenFeature is the Provider pattern. Your application always calls only the standard OpenFeature SDK interface, while the backend that actually evaluates flags is separated into a Provider implementation. Switch from Flagd to LaunchDarkly, switch back, or run both simultaneously — your business code never needs to change.
Here's an example written in Go.
import (
"context"
"log"
"github.com/open-feature/go-sdk/openfeature"
flagd "github.com/open-feature/go-sdk-contrib/providers/flagd/pkg"
)
func main() {
openfeature.SetProvider(flagd.NewProvider())
client := openfeature.NewClient("my-service")
enabled, err := client.BooleanValue(
context.Background(),
"new-checkout-flow",
false, // fallback default value if flag evaluation fails
openfeature.EvaluationContext{},
)
if err != nil {
log.Printf("flag evaluation error: %v", err)
}
if enabled {
// run new feature
}
}Replace flagd.NewProvider() with a different implementation and the business code below it works unchanged. Because Python, Java, and .NET all share the same conceptual model (Provider, Hook, EvaluationContext), every team in a polyglot environment can work with flags using the same pattern.
Flagd: A Daemon Focused on Flag Evaluation
Flagd is a daemon that does one thing well: it reads flag definitions from a source file and returns evaluation results. It's a stateless, lightweight binary that supports files, HTTP endpoints, Kubernetes CRDs, and gRPC services as flag sources.
Flag definitions look like this in JSON:
{
"flags": {
"new-checkout-flow": {
"state": "ENABLED",
"variants": { "on": true, "off": false },
"defaultVariant": "off",
"targeting": {
"fractional": [["on", 20], ["off", 80]]
}
},
"checkout-button-color": {
"state": "ENABLED",
"variants": { "blue": "blue", "green": "green" },
"defaultVariant": "blue"
}
}
}Your app evaluates flags via localhost:8013 (gRPC) or localhost:8016 (HTTP). Flagd has provided gRPC-based remote evaluation since before OFREP (OpenFeature Remote Evaluation Protocol) was standardized. OFREP's contribution is standardizing this remote evaluation at the network level. Thanks to the REST API spec at POST /ofrep/v1/evaluate/flags/{key}, swapping in a different OFREP-compatible backend instead of Flagd guarantees network-level compatibility without any SDK changes.
OpenFeature Operator: Automatic Sidecar Injection via Annotations
This is the key convenience feature in a Kubernetes environment. Install the OpenFeature Operator in your cluster, and two lines of Deployment annotations are all it takes to automatically inject a Flagd sidecar.
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-service
spec:
template:
metadata:
annotations:
openfeature.dev/enabled: "true"
openfeature.dev/featureflagsource: "my-flag-source"The Operator can be installed via Helm chart:
helm repo add open-feature-operator https://open-feature.github.io/open-feature-operator/
helm install openfeature open-feature-operator/open-feature-operator \
--namespace open-feature-operator-system \
--create-namespaceDeclare your flag source as a CRD:
apiVersion: core.openfeature.dev/v1beta1
kind: FeatureFlagSource
metadata:
name: my-flag-source
spec:
sources:
- source: my-namespace/my-feature-flags
provider: kubernetesapiVersion: core.openfeature.dev/v1beta1
kind: FeatureFlag
metadata:
name: my-feature-flags
namespace: my-namespace
spec:
flagSpec:
flags:
new-checkout-flow:
state: ENABLED
variants:
"on": true
"off": false
defaultVariant: "off"
targeting:
fractional:
- ["on", 20]
- ["off", 80]Practical Application
Gradual Rollout — Starting at 5% with fractional
The biggest risk when deploying a new feature is pushing it to all traffic at once. Flagd's fractional targeting lets you control this with a single flag definition change.
Just update the flag definition at each step: 5% → 20% → 50% → 100%.
{
"flags": {
"new-checkout-flow": {
"state": "ENABLED",
"variants": { "on": true, "off": false },
"defaultVariant": "off",
"targeting": {
"fractional": [
["on", 5],
["off", 95]
]
}
}
}
}There's one important behavior to be aware of here. When the percentages in a fractional rule sum to 100%, every request is bucketed by that rule. In this state, defaultVariant is never consulted. That means with a configuration like [["on", 5], ["off", 95]] where the total is 100%, changing defaultVariant has no effect on behavior.
There are two ways to roll back.
First, set state to DISABLED. The targeting rule is not evaluated at all, and the default value you passed to BooleanValue() in your application code (false) is returned.
{
"flags": {
"new-checkout-flow": {
"state": "DISABLED",
"variants": { "on": true, "off": false },
"defaultVariant": "off"
}
}
}Second, explicitly update fractional to [["off", 100]].
{
"flags": {
"new-checkout-flow": {
"state": "ENABLED",
"variants": { "on": true, "off": false },
"defaultVariant": "off",
"targeting": {
"fractional": [["off", 100]]
}
}
}
}No pod redeployment is needed. Note, however, that propagation through ArgoCD happens within the default polling interval (3 minutes). If you need faster propagation, configure an ArgoCD webhook.
Your application code looks like this:
enabled, err := client.BooleanValue(
ctx,
"new-checkout-flow",
false, // default: returned when state is DISABLED or evaluation fails
openfeature.EvaluationContext{
TargetingKey: userID, // same user always gets the same result
},
)
if err != nil {
log.Printf("flag evaluation error: %v", err)
}Passing a user ID as TargetingKey ensures the same user always receives the same variant, maintaining UX consistency.
A/B Testing — Measuring Metrics with Variants
Gradual rollout and A/B testing both use fractional targeting, but for different purposes.
Gradual rollout is about risk distribution. You expose a new feature to a portion of traffic early in the deployment, monitor error rates and latency, and increase the percentage if all looks good. The goal is to eventually reach 100% and remove the flag once metrics are stable.
A/B testing is about measuring metrics. You statistically compare which variant produces higher values for specific business metrics like conversion rate or click-through rate. Therefore, the principle in A/B testing is to not change the percentages until you have a sufficient sample size. Changing the ratio mid-experiment contaminates the results.
Here's a scenario for A/B testing button color. Declare variants and percentages in the FeatureFlag CRD, then branch in your application based on the evaluation result.
apiVersion: core.openfeature.dev/v1beta1
kind: FeatureFlag
metadata:
name: checkout-ab-test
namespace: my-namespace
spec:
flagSpec:
flags:
checkout-button-color:
state: ENABLED
variants:
blue: "blue"
green: "green"
defaultVariant: "blue"
targeting:
fractional:
- ["blue", 50]
- ["green", 50]// TypeScript example
const buttonColor = await client.getStringValue(
"checkout-button-color",
"blue", // default value
{ targetingKey: userId }
);
renderButton({ color: buttonColor });To connect experiment results to OpenTelemetry traces, you need to explicitly register OpenTelemetryHook. Registering the hook automatically attaches flag evaluation results to spans, allowing you to compare performance metrics by variant. See the OpenTelemetry guide in the References section for integration details.
GitOps Integration — Auto-Syncing Flag Changes with ArgoCD
Managing flag changes through Git PRs gives you change history, code review, and automatic rollback all in one. A single git log line tells you who changed the traffic percentage, when, and why — and a single revert PR takes you back if something goes wrong. This is the fundamental difference from clicking values in a flag management UI.
In your ArgoCD Application config, set the path to the directory containing your FeatureFlag CRDs.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: feature-flags
spec:
source:
repoURL: https://github.com/your-org/your-repo
targetRevision: main
path: k8s/feature-flags
destination:
server: https://kubernetes.default.svc
namespace: my-namespace
syncPolicy:
automated:
prune: true
selfHeal: trueEdit a FeatureFlag YAML in the k8s/feature-flags/ directory, open a PR, and after it merges ArgoCD applies it to the cluster. When the CRD is updated, Flagd refreshes its in-memory flags without a pod restart. The time from merge to live propagation is within ArgoCD's polling interval (default 3 minutes). For faster propagation, connect GitHub webhooks to ArgoCD for immediate sync right after merge.
Kill Switch — Isolating Features During External Service Outages
One important property of Flagd is that even when the source is offline, it returns the last known value from its in-memory cache. This is valuable for preventing cascading failures.
The key to the kill-switch pattern is having no fractional targeting rule. Without a fractional rule, defaultVariant is applied directly to every request. This is why the structure differs from the gradual rollout flag we looked at earlier. In a rollout flag, the fractional rule buckets 100% of traffic so defaultVariant is never consulted — but a kill-switch flag has no targeting rule, so changing defaultVariant immediately affects all traffic.
# Normal state: payment gateway enabled
apiVersion: core.openfeature.dev/v1beta1
kind: FeatureFlag
metadata:
name: payment-gateway-flag
namespace: my-namespace
spec:
flagSpec:
flags:
external-payment-gateway:
state: ENABLED
variants:
"on": true
"off": false
defaultVariant: "on"# During outage: change only defaultVariant, then merge
apiVersion: core.openfeature.dev/v1beta1
kind: FeatureFlag
metadata:
name: payment-gateway-flag
namespace: my-namespace
spec:
flagSpec:
flags:
external-payment-gateway:
state: ENABLED
variants:
"on": true
"off": false
defaultVariant: "off"One PR, one merge — and that feature is disabled for all traffic. The application falls back to the false branch and stops calling the external API.
Note that the ArgoCD polling interval means there may be up to a 3-minute delay after merging. For situations requiring immediate isolation — like incident response — kubectl apply on the CRD directly is faster. If you want to maintain the GitOps workflow, set up ArgoCD webhooks in advance.
Pros and Cons
Advantages
| Item | Details |
|---|---|
| Vendor independence | The SDK interface is fixed, so switching LaunchDarkly → Flagd → ConfigCat requires no changes to application code |
| Polyglot standardization | Go, Java, JS, TS, Python, .NET, PHP, and Ruby SDKs share the same conceptual model, ensuring consistency in polyglot environments |
| Testability | The SDK's built-in InMemoryProvider lets you inject exact flag states in unit tests without any network dependency |
| Observability | Registering OpenTelemetryHook automatically attaches flag evaluation results to distributed traces |
| GitOps-friendly | Flag definitions are stored as YAML/JSON in Git, enabling change history, PR reviews, and automated deployment |
| CNCF ecosystem integration | Integrates naturally with the CNCF stack: Kubernetes Operator, Helm charts, ArgoCD, Flux, and more |
| Fault resilience | When the source is offline, returns the last known value from in-memory cache, preventing cascading failures |
InMemoryProvider is especially useful in unit tests. You can control flag behavior precisely without Flagd.
func TestNewCheckoutFlowEnabled(t *testing.T) {
provider := openfeature.NewInMemoryProvider(map[string]openfeature.InMemoryFlag{
"new-checkout-flow": {
DefaultVariant: "on",
Variants: map[string]interface{}{
"on": true,
"off": false,
},
},
})
openfeature.SetProvider(provider)
client := openfeature.NewClient("test")
enabled, err := client.BooleanValue(
context.Background(), "new-checkout-flow", false,
openfeature.EvaluationContext{TargetingKey: "user-1"},
)
if err != nil {
t.Fatal(err)
}
if !enabled {
t.Error("expected new-checkout-flow to be enabled")
}
}Disadvantages and Caveats
| Item | Details |
|---|---|
| No management UI | Flagd itself provides no admin UI. If you need visual management, you'll need a supplementary backend like Flipt or Unleash |
| Overhead for small projects | For a single service with five or fewer flags, the abstraction layer may add unnecessary complexity |
| Uneven Provider maturity | The completeness of vendor Providers varies. Some Providers don't fully support streaming updates or OFREP |
| Migration is not automated | Existing flag definitions, targeting rules, and environment configs must be manually reconstructed. Switching the SDK alone doesn't solve everything |
| CRD dependency | The Kubernetes Operator approach requires installing and managing CRDs and the operator in your cluster; watch for CRD schema changes on operator upgrades |
| Sidecar resource overhead | Injecting a Flagd sidecar into every pod adds CPU and memory per pod. For large clusters, consider Flagd-proxy shared mode — a setup where multiple pods share a single Flagd instance, routing requests to one central Flagd daemon instead of injecting individual sidecars |
Common Mistakes in Practice
Forgetting TargetingKey: When fractional targeting is called without a TargetingKey, it returns a random variant on every call. To ensure the same user always receives the same variant, you must pass a user ID as TargetingKey. If your A/B test results look off, check here first.
Connection errors in environments without a sidecar: Local development environments don't have the Operator, so adding annotations won't inject a sidecar. Locally, use InMemoryProvider or run the Flagd binary as a separate process.
Neglecting CRD version management: CRD schemas can change between Operator versions. Make it a habit to check the release notes for CRD changes before upgrading via Helm.
Closing Thoughts
The core value of the OpenFeature + Flagd combination is managing flags like code. When flag definitions live in Git, changes are tracked, reviewed via PR, and automatically applied by ArgoCD. When you want to switch vendors, just swap the Provider implementation. You can adjust traffic percentages without redeploying pods, and isolate a feature with a single flag during an outage.
Of course, it's not the right fit for every team. If you have few flags and a small team, a direct implementation may be simpler. But if you have multiple microservices, a polyglot environment, and a genuine need for gradual rollouts, OpenFeature is a choice worth seriously considering.
Three steps to get started:
-
Start with unit tests using OpenFeature SDK +
InMemoryProvider— Attach the OpenFeature SDK to your existing code and test flag behavior withInMemoryProvider, no production Provider needed. No network, no Kubernetes required. -
Run Flagd locally as a standalone process — Create a single JSON flag file and run the Flagd binary locally; flag evaluation works at
localhost:8016. You can verify the full flow without a sidecar. -
Set up a GitOps pipeline with Operator + ArgoCD — Install the OpenFeature Operator in a dev cluster, push your
FeatureFlagCRDs to Git, and configure ArgoCD auto-sync to experience a production-equivalent workflow.
References
- OpenFeature Official Documentation
- Flagd Official Site
- OpenFeature Operator GitHub
- CNCF OpenFeature Project Page
- OFREP Protocol Specification
- OpenFeature Quick Start — Kubernetes Operator
- Managing Feature Flags with GitOps — ArgoCD
- Correlating A/B Test Variants with OpenTelemetry
- OpenFeature Operator ArtifactHub Helm Chart
- Is it Observable? — Deep Dive into OpenFeature + Flagd
- SigNoz's OpenFeature Guide
- OpenFeature — Making Feature Flags a First-Class Citizen