Rolling out risky changes to deployment and automation scripts under control
A new code path in a production Bash script is always a risk, no matter how carefully it was tested. A feature flag turns that all-or-nothing risk into a controllable decision: the new path exists in the script but is off by default, can be enabled for individual runs, and can be switched back off with a single value if something goes wrong.
Table of Contents
- 1. Why feature flags make sense in shell scripts too
- 2. Controlling feature flags through environment variables
- 3. Controlling feature flags through a central configuration file
- 4. Structuring conditional code paths cleanly instead of nested if chains
- 5. Simulating a staged rollout: not every server at once
- 6. A kill switch for emergencies: back to old behavior instantly
- 7. Logging active flags: traceability during debugging
- 8. Removing flags again: actively paying down technical debt
- 9. Environment variable vs. configuration file vs. external flag service
- 10. Summary
- 11. FAQ
1. Why feature flags make sense in shell scripts too
In application code, feature flags have long been standard practice for rolling out new functionality gradually, independent of deployment. In Bash scripts that automate deployments, backups or maintenance tasks, this pattern is often entirely missing, even though the risk there is at least as high: a broken new code path in a deployment script can take down an entire production environment, not just a single feature in one application.
The core idea stays the same: new, risky code is not switched on for all executions at once, it is hidden behind a condition that can be toggled centrally. That way a new backup procedure can first be tested on a single server while the rest of the fleet keeps using the proven old path, without maintaining two separate script versions.
2. Controlling feature flags through environment variables
The simplest approach uses environment variables as flags: a variable like FEATURE_NEW_BACKUP_STRATEGY is set to 1 or 0, and the script checks that value where old and new code paths diverge. This approach fits especially well for flags that should differ per execution, for example enabling a flag only for a manually triggered test run without touching any file.
A consistent naming convention matters here, so flags are instantly recognizable as such in the code and are not confused with regular configuration variables. A prefix like FEATURE_ makes it immediately clear when reading the script that this variable controls a conditional code path rather than, say, a connection parameter or a path.
#!/usr/bin/env bash
set -euo pipefail
# Feature flag via environment variable, default OFF
readonly FEATURE_NEW_BACKUP_STRATEGY="${FEATURE_NEW_BACKUP_STRATEGY:-0}"
run_backup() {
if [[ "$FEATURE_NEW_BACKUP_STRATEGY" == "1" ]]; then
echo "Running NEW incremental backup strategy"
# new, still-risky code path
else
echo "Running established full backup strategy"
# old, proven code path
fi
}
run_backup
# Test run: FEATURE_NEW_BACKUP_STRATEGY=1 ./backup.sh
3. Controlling feature flags through a central configuration file
Once several flags exist at the same time, or a flag needs to stay constant across multiple script runs, a central configuration file becomes more practical than passing individual environment variables on every invocation. A simple flags.conf with KEY=VALUE lines, loaded like a .env file, makes the current rollout status of every flag visible at a glance and versionable when the file lives in the same repository as the script.
This configuration file should live separately from the actual application configuration, so changing a flag never accidentally changes other settings like database credentials. A dedicated section or a dedicated file exclusively for flags also makes it immediately visible in code review which change actually affects a rollout.
#!/usr/bin/env bash
set -euo pipefail
# flags.conf:
# FEATURE_NEW_BACKUP_STRATEGY=1
# FEATURE_PARALLEL_UPLOAD=0
load_flags() {
local flags_file="${1:-flags.conf}"
[[ -f "$flags_file" ]] || return 0
while IFS='=' read -r key value; do
[[ -z "$key" || "$key" == \#* ]] && continue
export "$key=$value"
done < "$flags_file"
}
load_flags "flags.conf"
echo "Backup strategy flag: ${FEATURE_NEW_BACKUP_STRATEGY:-0}"
4. Structuring conditional code paths cleanly instead of nested if chains
A single feature flag maps cleanly to one if check, but once several flags affect the same function, nesting quickly grows into confusing if-in-if constructs that are hard to test and even harder to remove later. It is cleaner to write each code path as its own, clearly named function and decide which one actually gets called at a single, central point only.
This pattern has a decisive side effect: when a feature flag eventually becomes the permanent default, only the selection point needs to change, not the entire function body, and the old function can then be deleted safely because it is cleanly isolated. Nested code, by contrast, can rarely be pulled apart again without risk.
#!/usr/bin/env bash
set -euo pipefail
deploy_legacy() {
echo "Deploying via legacy rsync pipeline"
}
deploy_parallel() {
echo "Deploying via new parallel upload pipeline"
}
# single decision point -- easy to find, easy to remove later
if [[ "${FEATURE_PARALLEL_UPLOAD:-0}" == "1" ]]; then
deploy_parallel
else
deploy_legacy
fi
5. Simulating a staged rollout: not every server at once
Even without a real feature-flag backend, a staged rollout can be replicated in plain Bash by evaluating the flag per host or per execution instead of globally. A simple approach uses the hostname or a fixed list of allowed target systems to enable the new code path there first, while the rest of the fleet stays unchanged.
For a real percentage rollout across many similar instances, a deterministic hash of the hostname mapped into a value range from zero to ninety nine works well. If that value falls below the desired rollout quota, the script enables the new behavior, otherwise it does not, consistently on every run on the same host.
#!/usr/bin/env bash
set -euo pipefail
readonly ROLLOUT_PERCENT=20 # roll out to ~20% of hosts
# deterministic per-host bucket: same host always lands in the same bucket
host_bucket() {
local hash
hash=$(echo -n "$(hostname)" | md5sum | cut -c1-4)
echo $(( 16#$hash % 100 ))
}
if (( $(host_bucket) < ROLLOUT_PERCENT )); then
echo "Host $(hostname) is in the rollout group"
else
echo "Host $(hostname) stays on the stable path"
fi
6. A kill switch for emergencies: back to old behavior instantly
The most important purpose of a feature flag in an automation script is not the orderly rollout, it is the emergency exit: if a problem shows up in production, the new behavior needs to be switchable off immediately, without a script change. A kill switch that simply resets the same flag name back to 0 should not need any dedicated logic, it should simply be the normal way of turning something off.
For a kill switch to actually take effect immediately in an emergency, the flag must not sit cached in some file or long-running process memory, it needs to be re-read from the current configuration file or environment variable on every script invocation. A script that reads a flag once at startup and then keeps running for hours unnecessarily delays the effect of an emergency shutoff.
7. Logging active flags: traceability during debugging
When a script with an enabled feature flag produces an unexpected result, the first question during debugging is almost always which flags were actually active at the time of the run. Without logging that information, only a guess remains, because the state of the configuration file may well have changed between the failing run and the investigation.
A robust script therefore logs, at the start of every run, which flag values it started with, ideally in the same log file as the rest of its output. That single line at the top of the log makes later debugging considerably faster, because the cause of a problem can be traced back to a concrete flag state immediately, instead of having to be reconstructed after the fact.
#!/usr/bin/env bash
set -euo pipefail
log_active_flags() {
local flag
for flag in "${!FEATURE_@}"; do
echo "[FLAGS] $flag=${!flag}"
done
}
log_active_flags
# [FLAGS] FEATURE_NEW_BACKUP_STRATEGY=1
# [FLAGS] FEATURE_PARALLEL_UPLOAD=0
8. Removing flags again: actively paying down technical debt
A feature flag still sitting in the code a year after a full rollout is pure technical debt: it forces everyone reading the function to also think through the now-dead old code path, even though it never runs anymore. Once new behavior has proven itself in production and permanently sits at one hundred percent, the old path belongs removed and the flag itself deleted.
In practice it pays off to attach an expiry date or a ticket to every newly introduced flag that explicitly demands cleanup, rather than relying on remembering later. Without that discipline, long-lived deployment scripts accumulate dozens of dead flags over the years that nobody dares remove anymore, because it is unclear whether they are still needed somewhere.
9. Environment variable vs. configuration file vs. external flag service
For a single script or a handful of flags, environment variables or a simple configuration file are entirely sufficient and can be operated without any additional infrastructure. Only once many scripts across many servers need to see the same flags consistently and in real time does it become worth looking at an external flag service that distributes central changes to every execution location instantly.
| Mechanism | Scope | Change takes effect | Typical use |
|---|---|---|---|
| Environment variable | One script invocation | Immediately on next call | One-off test run, manual activation |
| Configuration file | All runs on one host | On the next file load | Persistent rollout status per server |
| Hostname bucket | Percentage of the fleet | Deterministic, consistent per host | Gradual rollout across many servers |
| External flag service | All servers in real time | Instantly, no deployment needed | Large fleets, central control |
Mironsoft
Shell automation, DevOps tooling and deployment infrastructure
Shell scripts that hold up in production?
We review existing Bash scripts, spot fragile patterns and replace them with robust Bash patterns: complete error handling, logging and safe parallelization for your deployment stack.
Code Review
ShellCheck analysis and manual review for critical Bash pattern violations.
Refactoring
Retrofitting error handling, logging and safe file operations.
CI Integration
Wiring ShellCheck and BATS into pipelines and building regression tests.
10. Summary
Feature Flags in Bash Scripts: The Essentials at a Glance
Name flags clearly
A prefix like FEATURE_ makes it immediately visible that a variable controls a conditional code path and is not regular configuration.
One central decision point
Write code paths as separate functions and select between them in a single place, instead of spreading if-in-if nesting across the whole script.
Kill switch without delay
Re-read flags on every run instead of caching them once, so an emergency shutoff takes effect immediately instead of on the next restart.
Actively clean up flags
Attach an expiry date or a ticket to every flag and remove it, along with the old code path, once the rollout is fully complete.