How to Use Feature Flags With Simple Shell and Config Tools
Use simple feature flags with environment variables, a config file and a small script, roll out gradually, remove old flags, and know when to use a service.

A feature flag lets you deploy code that is not yet switched on, or turn a risky behaviour off without redeploying. You do not need a flag platform to get the benefit. A few lines of configuration cover the common cases of kill switches and gradual rollouts. The examples use Bash 5.2 and a small JSON file; the config handling is described in configuration files and defaults.
Environment variables
#!/usr/bin/env bash
set -euo pipefail
flag() { # flag NAME [default]
local name="FLAG_${1^^}" default=${2:-off}
[[ ${!name:-$default} == @(on|1|true|yes) ]]
}
if flag new_report off; then
./report-v2.sh
else
./report.sh
fiThe helper reads FLAG_NEW_REPORT with a default and accepts a few true spellings, so every call site is one readable line. ${!name} is indirect expansion, covered in indirect variables in Bash. Setting FLAG_NEW_REPORT=on in a systemd unit or .env file flips it on the next start. Environment flags are easy and need a restart to change.
A file read at run time
{
"new_report": { "enabled": true, "percent": 25, "owner": "alice", "remove_by": "2027-01-31" },
"fast_export": { "enabled": false, "percent": 0, "owner": "bob", "remove_by": "2026-12-15" }
}flag_on() {
jq -e --arg f "$1" --arg id "$2" '
.[$f] as $x
| $x.enabled and ($x.percent >= 100 or (($id | length) % 100) < $x.percent)
' /etc/myapp/flags.json >/dev/null
}A JSON file next to the service, read on each decision, changes without a restart, and carries who owns each flag and when it should go. The percentage test in the example uses the length of the ID, which is a poor hash; use a real hash such as the first characters of sha256sum, turned into a number, so a given user lands in the same bucket every time and the buckets are evenly sized.
Stable percentage rollouts
bucket() { # stable 0-99 bucket for an ID
local h; h=$(printf '%s:%s' "$1" "$2" | sha256sum | cut -c1-8)
echo $(( 0x$h % 100 ))
}
(( $(bucket new_report "$user_id") < 25 )) && echo "in the 25 percent"Hashing the flag name together with the ID gives each flag an independent split, so the same users are not always the first to get every experiment. Start at 1 percent, watch errors and latency, and increase in steps, as in a canary release described in canary releases.
Discipline matters more than tooling
Keep every flag in one place, name each by what it enables, record an owner and a removal date, and review the list monthly. A flag that stays after the rollout is dead code with a hidden branch, and a combination of old flags is a configuration nobody has tested. Test with the flag on and off, and make the default the safe behaviour so a missing config does not enable an unfinished feature. Log the decision, so an incident review can tell which path a request took.
Designing a flag so it can be removed
A flag is a temporary piece of code, and the way to keep it cheap is to plan its removal when it is created. Give each flag an owner, a purpose and an expiry date, as in the JSON example, and have a check in CI that fails when a flag is past its date and still present. Name flags for the feature and not the mechanism, so that new_report is clear and flag_17 is not. Keep the number of live flags small, since the combinations multiply: ten independent flags allow 1,024 states and no test suite covers them all. When a feature has been fully on for a release or two, delete the flag and the old code path in one commit.
Distinguish the kinds of flags, because they have different lifetimes. A release flag hides unfinished work and lives for days or weeks. An experiment flag splits users to compare behaviour and lives until the result is known. An operational flag, often called a kill switch, turns off a costly or risky feature during an incident and may stay for years. A permission flag enables a feature for certain customers and is really part of the product's configuration. Only the first two should be removed on a schedule, and the others deserve names and documentation that say they are permanent.
Making decisions consistent
A user should see the same behaviour from one request to the next, and a given flag should give the same answer across every service that handles a single request. The percentage rollout in the example hashes a stable identifier together with the flag name, so that the same user falls in the same bucket every time, and different flags put the user in different buckets. A random number per request would flip a user between old and new behaviour, which makes bugs impossible to reproduce. Hashing the flag name into the input is important: without it, the same ten percent of users get every new feature first, and they become the unwitting test group for all of them.
In a pipeline of several services, evaluate the flag once at the edge and pass the decision along in a header or a field, instead of letting each service evaluate on its own. Otherwise a config change in the middle of a request can produce a mixed result.
Testing both sides
Every flag doubles the paths through the code, and both must be tested. Run the test suite once with the flag off and once with it on, or write tests that set the flag explicitly. Keep a test that proves the default is the safe one when the configuration file is missing or malformed, since a flag system that fails open can switch on an unfinished feature during an outage. Log the flag decisions that matter, with the flag name and the result, so a support request about odd behaviour can be answered by looking at what the user was given. A small metric that counts decisions per flag also shows flags that nobody evaluates any more, which are the ones to remove.
When to move to a flag service
A file and a hash function cover a single team with a handful of flags. Move to a dedicated service or an open standard such as OpenFeature when several teams need to share flags, when product managers need a user interface, when targeting rules depend on many attributes, or when an audit trail of who changed what is required. Until then, the simple approach is easier to understand, has no extra service to fail, and can be read in a code review, which are real advantages.
Where this does not apply
If you need targeting by account attributes, audit trails, many teams editing flags or experiments with statistics, use a service such as OpenFeature with a provider, which standardises the client API. Flags that change database schemas or data formats cannot be flipped back freely, so treat migrations separately, as in migrations in deploy scripts. Read the OpenFeature specification and Martin Fowler's feature toggles article for the categories of flags.


