Feature flags are the cheapest way to separate deploying code from releasing a behavior, and you do not need a SaaS to get them. I run my flags out of a single config file that ships with the app, plus one environment variable for emergencies. That is enough for a kill switch, a progressive rollout, and an A/B test — and it is small enough that I actually remove the flags when they are done. The hard part of feature flags was never the evaluate-a-boolean part. It is the lifecycle around them, and that is exactly where a heavyweight vendor will not save you.
What feature flags are for — and what they become
The reason to have a flag is deploy vs release. Deploying is putting bytes on the server; releasing is changing what users see. Coupling the two means every release is a deploy and every deploy is a risk. A flag breaks the link: ship the code dark, then flip it on when you are ready.
Four jobs a flag does well:
- Remove the deploy from the decision to ship. Merge the new editor, keep it off, turn it on Tuesday at 10am when you are awake to watch it.
- Kill switch. One boolean that turns a subsystem off under load, behind a config change instead of a rollback. This is the only genuinely long-lived flag type.
- Progressive rollout. Give it to 1% of users, then 10%, then 50%, then everyone — and stop early if the error rate moves.
- A/B test. Two variants, a metric, and a decision at the end.
Now the failure mode, which is more common than all four uses combined: flags that
are never deleted. A release flag that stays on for a year becomes a permanent if
in the code. Multiply it by a && for each flag and you get if spaghetti — code
whose real behavior is a matrix of states nobody can hold in their head. The zombie
flags are worse than absent ones: they are untested in the “off” path, and one day
someone flips a switch that no longer does anything. Every flag is born as a tool and
dies as technical debt unless you plan for its removal on day one.
Four kinds of feature toggles: release, kill switch, experiment, permission
The single most useful habit is to label each flag with its type, because the type tells you how long it should live.
| Type | Lives | Default when missing | Remove when |
|---|---|---|---|
| Release | days–weeks | off | the feature is the only path |
| Ops / kill switch | forever (by design) | on (fail open for ops) | the subsystem is gone |
| Experiment | until the result | off | the A/B test is decided |
| Permission | months | off | it becomes a real entitlement system |
The lifetime column is the point. A release toggle that is not deleted is a bug in waiting; a permission toggle that lives forever is fine if it is modeled properly. Mixing the two in one undifferentiated pile of booleans is how you end up unable to delete anything.
A minimal self-hosted design: config file, env override, per-request evaluation
The design I use has three rules:
- Flags are data, not code. They live in a file — committed to the repo for defaults, mounted on the server for live values. Changing a flag is changing data, which is why it needs no deploy.
- One source of truth, one override. The file is the truth; a single environment variable per flag exists only so I can force a value from the shell during an incident without editing anything.
- Evaluate per request, in memory. The file is parsed once and hot-reloaded on change. A flag lookup never makes a network call in the hot path.
Here is the entire system as a config file:
# flags.yaml — the whole "feature flag service"
version: 1
flags:
new_editor: # release: short-lived
type: release
enabled: true
rollout: 25 # percent of subjects
default: false # what callers get if it's missing
owner: deepseek
created: 2026-11-01
remove_by: 2026-12-15 # CI fails after this date
checkouts_paused: # ops / kill switch
type: ops
enabled: false # off = normal path
default: false
recommendations_v2: # experiment: needs a decision
type: experiment
enabled: true
rollout: 50
default: false
team_sso: # permission: allow-list
type: permission
enabled: true
allow: [team-alpha, team-beta]
default: false
The remove_by field is not decoration — a small CI check reads it and fails the
build once the date passes. That is the only thing I have found that reliably kills
a flag instead of letting it age into a load-bearing if.
Defaulting safely: fail closed and type your flags
Two rules keep a homemade flag system from becoming an outage source.
Fail closed. If the config file is missing, corrupt, or the flag name is unknown, the answer is “off” — never an exception in the request path, never an accidental “on”. Feature flags should be able to disappear and take the system back to its previous behavior. The one exception is ops flags, where you decide deliberately whether failing open is safer; for most kill switches, release the brakes is the right default only when the feature is genuinely optional.
Type the lookup. Give every call site an explicit default instead of relying on whatever the config happens to say. A missing flag with a typed default is a compile-time-ish promise; a missing flag that throws is a 3am page.
import hashlib, json, os
_config: dict = {}
def load(path: str = "flags.json") -> None:
global _config
try:
with open(path) as f:
_config = json.load(f).get("flags", {})
except (OSError, ValueError):
_config = {} # fail closed: no readable file => every flag off
def enabled(name: str, subject: str = "", default: bool = False) -> bool:
# explicit caller default, not whatever the file says
flag = _config.get(name)
if flag is None:
return default
# emergency override from the shell: FLAG_NEW_EDITOR=1
if os.environ.get(f"FLAG_{name.upper()}") in ("1", "true"):
return True
if not flag.get("enabled", False):
return False
if "allow" in flag: # permission flag
return subject in flag["allow"]
pct = flag.get("rollout")
if pct is None:
return True # fully on
# stable bucketing: the same subject always lands in the same bucket
h = hashlib.sha1(f"{name}:{subject}".encode()).hexdigest()
return int(h[:8], 16) % 100 < pct
Two details carry the weight. Stable bucketing — hashing name:subject — means a
user in the 25% group stays in it on the next request, so the rollout is a cohort and
not a coin flip. And the caller’s default means a flag that has been deleted from
the file degrades to the safe behavior instead of crashing.
The lifecycle: create → progressive rollout → remove
Every flag has a birth and a death. Skipping the death is the whole disease.
In practice the steps look like this:
- Create. Add the config row with a type, an owner, a safe
default, and aremove_bydate. Commit the code behindif enabled("new_editor", user.id). - Roll out. Flip
rolloutup over days, watching errors and latency. If something is wrong, set it to0— that is the kill switch, and it takes seconds because there is no deploy involved. If the whole feature is broken,enabled: falseis the rollback. - Remove. When the rollout hits 100% and the old path is dead code, delete the flag from the config and delete the branch it guarded, in one commit. Retire the code, not just the switch.
The reason to enforce step 3 mechanically is that humans are bad at it. A flag left in place has two behaviors, so both need tests; four flags need sixteen. The math is exponential and the only constant-time fix is deletion. This is why I treat an expired flag the same way I treat a failing test: the build stops until it is gone.
If you take nothing else from this post: schedule the removal when you create the flag, and let CI be the one that remembers.
When a flag is the wrong tool
Flags are seductive, so it is worth naming where they are the wrong answer:
- When it is really user config. If a human is meant to choose a theme or toggle a preference, that is a settings table, not a feature flag. Flags are for you to control, and they are temporary; settings are for them and are permanent.
- When it is a long-term entitlement. Per-plan limits, roles, and quotas belong in an entitlements or permissions model, not a growing allow-list of flag names. A flag system that has become your authorization layer cannot be deleted, ever.
- When it hides a broken deploy. If you cannot ship safely without a flag guarding the change, the deploy path is the bug. Fix that — atomic symlink and blue-green deploys solve the mechanics so a flag is not carrying them.
- For schema changes. Database migrations need a forward/backward-compatible plan, not a boolean. You cannot flag away a column that no longer exists.
- When you will never remove it and the branches are cheap anyway. One boolean in a five-line function is a reasonable constant. Fifty is not.
Feature flags are a small tool with a small contract: decouple deploy from release, give yourself a kill switch and a progressive rollout, and then clean up. The self-hosting part is almost an afterthought — one file, one hash, one env override — which is exactly what makes it survivable. It fits the same philosophy as the rest of what I self-host in 2026: the boring local version you fully understand beats the hosted one you rent and forget.
Write the flag, date its death, and delete it on time. That is the entire discipline, and it is the part no vendor can do for you.