FeatureSteward
A self-hosted feature-flag and config service. Toggle features safely, roll out gradually, and know who's accountable for every flag.

Why this exists
Shipping code and releasing features shouldn't be the same event. Feature flags let teams deploy code "dark," then turn it on for a few users, a percentage of traffic, or everyone, without redeploying.
Most teams either pay for a hosted service or hack flags into config files with no history of who changed what, and no clear owner once a flag goes stale. FeatureSteward is a small, self-hosted alternative built around four ideas:
- Every flag has a steward: A named person accountable for it, borrowed from the Feature Steward role in FaST Agile.
- Safe by default: Production changes require approval from a second person.
- Accountable: Every change is recorded in an append-only audit log.
- Fast to evaluate: Flag checks are cached and served from a lightweight API.
Features
- Boolean flags per environment (
dev, staging, prod)
- Percentage rollouts (deterministic per user)
- Targeting rules (user IDs, groups)
- A steward (owner) on every flag
- Role-based access control (viewer / editor / approver / admin)
- Approval workflow for production changes, routed to the flag's steward
- Stale-flag detection with steward notifications
- Append-only audit log (tamper-evident hash chain as a stretch goal)
- Redis-backed evaluation cache
- Rate-limited evaluation endpoint
- Web dashboard
-
stew command-line tool
- VS Code extension (hover status and steward, autocomplete flag keys, stale-flag finder)
Architecture
flowchart LR
A[Web dashboard<br/>TypeScript] -->|REST| B[FeatureSteward API<br/>Go]
G[stew CLI] -->|REST| B
E[VS Code extension] -->|REST| B
F[Your app / SDK] -->|evaluate| B
B --> C[(PostgreSQL<br/>flags, users, audit)]
B --> D[(Redis<br/>eval cache, rate limits)]
Tech stack
| Layer |
Choice |
| Backend |
Go (net/http) |
| CLI |
Go |
| Database |
PostgreSQL + SQL migrations |
| Cache |
Redis |
| Frontend |
TypeScript, HTML, CSS |
| Infra |
Docker, Docker Compose, GitHub Actions |
Quick start
Prerequisites: Docker, and Go 1.27 or later for the make commands.
git clone https://github.com/Melmonster13/featuresteward.git
cd featuresteward
cp .env.example .env
docker compose up -d --build
make migrate
make admin HANDLE=yourname # prints an API token once; save it
Open http://localhost:8080 and sign in with that token. The API is at http://localhost:8080/api/v1.
To work on the dashboard with live reload, run make web-dev (needs Node.js 24) and open http://localhost:3000. It forwards API calls to the server on port 8080.
The dashboard
Sign in with an API token; the dashboard swaps it for a 12-hour session.
- Flags: every flag's state per environment and its steward, with search and filters for environment, steward (yours, or none), and stale flags. Stale flags are tagged, and stewards see how many of theirs are stale.
- Flag page: turn a flag on or off, set its rollout and targeting rules per environment, reassign the steward, mark it permanent, archive it, and read its history. A stale flag shows why and what to do about it. In
prod, saving becomes Request change, except turning the flag off.
- Reviews: change requests waiting for you, your own, and recently closed ones, with the current and requested settings side by side.
- Your tokens: create tokens for the CLI and scripts, and revoke them.
- Admin pages: add users (with a first sign-in token to send them), change roles, disable users, manage SDK keys and environments.
Controls you can't use are disabled with the reason shown, and the server enforces the same rules. New tokens and SDK keys are shown once.
The stew CLI
Install (needs Go 1.27.1 or later), or run make stew to build bin/stew from a checkout:
go install github.com/Melmonster13/featuresteward/cmd/stew@latest
Log in with an API token. stew reads it from stdin, not a flag, so it stays out of your shell history:
stew login --url http://localhost:8080 # paste the token at the prompt
stew whoami
The token is saved to ~/.config/stew/config.json (mode 0600). stew logout revokes it on the server and deletes the file. In CI, set STEW_URL and STEW_TOKEN instead. stew refuses to send a token over plain http except to localhost.
Common commands:
stew list --env dev # flags in an environment
stew list --steward none # flags with no active steward
stew status new-checkout # state per environment, rules, steward
stew create new-checkout --name "New checkout"
stew rollout new-checkout staging 25 # 25% of users
stew toggle new-checkout staging on
stew steward new-checkout @sam # hand the flag to another steward
stew archive new-checkout --yes # admins only
stew rollout new-checkout prod 25 --reason "launch to a quarter" # files a change request
stew requests # pending requests, and whether you can review them
stew approve 12 --comment "ship it" # or: stew reject 12, stew cancel 12
stew toggle new-checkout prod off # the kill switch applies immediately
stew stale --steward me # your flags that look safe to remove
stew permanent ops-maintenance "ops kill switch" # never report it stale (or: --clear)
toggle and rollout change one setting and keep the rest, including targeting rules. Every change carries an Idempotency-Key, so stew retries network errors and 502/503/504 responses without applying a change twice. See Production approvals for how prod changes work.
For scripts, most commands take --json, and exit codes tell failures apart:
| Code |
Meaning |
| 0 |
Success |
| 1 |
Other error |
| 2 |
Bad usage |
| 3 |
Not logged in, or not allowed |
| 4 |
Flag or environment not found |
The VS Code extension
The extension in extensions/vscode brings flags into the editor:
- Hover a flag key in any quoted string to see its state per environment, its steward, and whether it's stale, with a link to the dashboard.
- Autocomplete flag keys after typing a quote, or with Ctrl+Space inside the quotes.
- Stale Flags in the Explorer lists flags that look safe to remove, with each place your code uses them.
Run FeatureSteward: Sign In and paste an API token; a viewer's token is enough, since the extension only reads. The token stays in VS Code's secret storage, and only your user settings can set the server URL, so a cloned repository can't redirect it. See the extension's README for details.
Build and install it locally:
cd extensions/vscode
npm ci --ignore-scripts
npm run package # builds featuresteward-<version>.vsix
code --install-extension featuresteward-0.1.0.vsix
CI also builds the .vsix on every push; download it from the run's Artifacts.
Publish it (only the maintainer does this, and the tokens never go in the repo):
- Create a publisher at https://marketplace.visualstudio.com/manage whose ID matches
publisher in extensions/vscode/package.json.
- Create an Azure DevOps personal access token with the Marketplace (Manage) scope, then run
npx vsce login <publisher> and paste it at the prompt.
- Run
npx vsce publish from extensions/vscode.
- For Open VSX (VSCodium, Cursor, and others), create a token at https://open-vsx.org, then run
read -rs OVSX_PAT && export OVSX_PAT (paste it; nothing is shown), npx ovsx create-namespace <publisher> once, and npx ovsx publish featuresteward-0.1.0.vsix.
Usage
Authentication
Every API request sends Authorization: Bearer <credential>. There are two kinds:
- API tokens (
fs_…) belong to a user and carry that user's role. Changes made with one are recorded under the user's handle in the audit log.
- SDK keys (
fs_sdk_…) belong to one environment and can only call /api/v1/evaluate in that environment. Give these to your applications.
Both are shown once when created. Only a SHA-256 hash is stored.
Browser sessions: The dashboard signs in by sending an API token to POST /api/v1/session, which sets an HttpOnly, SameSite=Strict session cookie for 12 hours. The cookie is Secure everywhere except http://localhost, so the dashboard needs HTTPS in production. A session ends at logout (DELETE /api/v1/session), or when its token is revoked or its user is disabled. Requests that use the cookie to change something must come from the dashboard's own origin.
Evaluate a flag
curl -X POST http://localhost:8080/api/v1/evaluate \
-H "Authorization: Bearer <sdk-key or token>" \
-H "Content-Type: application/json" \
-d '{"flag": "new-checkout", "environment": "prod", "user_id": "user-42"}'
{ "flag": "new-checkout", "enabled": true, "reason": "percentage_rollout" }
Safe retries
Send an Idempotency-Key header (any unique string, up to 255 characters) with a POST, PUT, or DELETE. If the same user retries with the same key and the same request within 24 hours, the API returns the original response with Idempotent-Replayed: true instead of applying the change again. Reusing a key for a different request returns 422.
Responses that contain a new API token or SDK key are replayed without the secret, since secrets are never stored. Revoke the replayed id and create a new one if the first response was lost.
How percentage rollouts work
Each user is assigned a stable bucket from hash(flag_key + user_id) % 100. A flag at 25% is on for buckets 0–24. The same user always gets the same result, and raising the percentage only adds users; nobody who already has the feature loses it.
Stewards and roles
Steward: Every flag has one. The steward is the point of contact for that flag, reviews production change requests for it, and is notified when it goes stale. Stewardship is per flag, not a permission level. A new flag's steward is its creator unless another is named; stewards must be active editors or above. Admins can reassign any flag, and a steward can hand their own flag to someone else. GET /api/v1/flags?steward=none lists flags with no steward or a disabled one.
Roles control what each user can do across the system:
| Role |
Can do |
| Viewer |
See flags, stewards, and audit history |
| Editor |
Create and change flags in dev / staging, request changes in prod, and turn prod flags off |
| Approver |
Approve or reject prod change requests |
| Admin |
Manage users, roles, environments, and steward assignments |
Roles are cumulative: each includes the permissions of the roles above it in the table. Permissions are enforced on the server for every request, never only in the UI. Self-approval is blocked.
prod is a protected environment; admins can protect others. Only admins can archive flags.
Production approvals
A change to a protected environment is a change request that someone else approves:
- An editor requests the new settings, with an optional reason: Request change in the dashboard,
stew rollout … prod, or POST /api/v1/flags/{key}/environments/prod/requests.
- The flag's steward, an approver, or an admin approves or rejects it, optionally with a comment. The requester can't review their own request, but can cancel it.
- Approving applies the change, but only if
prod still has the settings the request was based on. If someone changed it in the meantime, the approval fails and the request needs to be made again.
Requests that nobody reviews expire after 7 days, and only one request per flag and environment can be pending at a time.
Two changes skip approval:
- The kill switch: anyone who can edit can turn a flag off in
prod right away, as long as nothing else changes.
- Emergency changes: an admin can apply any change directly by giving a reason (
"reason" in the API, --emergency in stew). The reason is kept in the audit log.
Every request, review, and change is recorded in the flag's history.
Stale flags
A flag that has done its job becomes dead code. FeatureSteward reports a flag as stale, with a suggestion, when it has been one of these for STALE_AFTER_DAYS (30 by default):
| Stale as |
Meaning |
Suggestion |
| Unused |
Nothing has evaluated it in any environment |
Check the code no longer uses it, then archive it |
| Always on |
Every environment serves on to everyone, unchanged |
Remove the flag and keep the new code |
| Always off |
Every environment serves off to everyone, unchanged |
Remove the flag and the code behind it |
| Settled |
Unchanged, serving one value to everyone in each environment, but on in some and off in others |
Decide on one, then remove the flag |
"Everyone" means the flag is off, or on at 0% or 100% with no rules that serve the other value. Protected environments decide between always on and always off when they agree. Flags with a pending change request, archived flags, and permanent flags are never stale.
Where it shows up: the dashboard's flag list (tag and filter) and flag page, stew stale, stew status, and GET /api/v1/flags?stale=true. Every flag in the API has a stale field (null when it isn't) and its last change and evaluation per environment.
Permanent flags: some flags are meant to last, like an operations kill switch. The steward or an admin can mark one permanent with a reason (the flag page, stew permanent, or PUT /api/v1/flags/{key}/permanent), and it's never reported stale. The mark and its removal are in the flag's history.
Daily digest: with STALE_WEBHOOK_URL set, the server posts stale flags once a day, grouped by steward, with links to the dashboard when PUBLIC_URL is set. It lists flag keys and steward handles only, and repeats a flag at most once a week. With several servers, one sends it.
Evaluations are counted in memory and saved to Postgres every minute, so recording them doesn't slow down /evaluate. A server that stops abruptly can lose up to a minute of this, which can only make a flag look stale a minute early.
Configuration
| Variable |
Description |
Default |
DATABASE_URL |
PostgreSQL connection string |
— |
REDIS_URL |
Redis connection string. Optional: without it, or while Redis is down, evaluations aren't cached or rate limited. /healthz reports its status. |
— |
PORT |
API port |
8080 |
CACHE_TTL_SECONDS |
How long evaluations and SDK key lookups stay cached. Changes clear it right away; this only bounds how stale it can get if Redis misses a change. 0 turns the cache off. |
30 |
RATE_LIMIT_PER_MIN |
Evaluations per minute for each SDK key or user. Over it, /evaluate returns 429 with Retry-After; every response has RateLimit-* headers. 0 turns it off. Needs Redis. |
600 |
STALE_AFTER_DAYS |
Days before a flag counts as stale: not evaluated anywhere, or serving one value to everyone, unchanged, for this long. See Stale flags. |
30 |
STALE_WEBHOOK_URL |
Optional webhook for a daily digest of stale flags, grouped by steward, as {"text": ...} (Slack-compatible). It's a secret: it's never logged. Must be https, except to localhost. |
— |
PUBLIC_URL |
The dashboard's URL, used for links in the digest. |
— |
Project structure
cmd/featuresteward/ API server entry point
cmd/stew/ CLI
cmd/evalload/ evaluation load test
internal/flag/ flags, environments, and change requests
internal/eval/ rule matching and rollout hashing
internal/audit/ append-only audit events
internal/auth/ users, tokens, SDK keys, sessions, and roles
internal/idempotency/ Idempotency-Key storage
internal/store/ PostgreSQL implementations
internal/cache/ Redis cache for flag evaluation and SDK keys
internal/ratelimit/ per-client rate limits in Redis
internal/redisguard/ fallback when Redis is down
internal/httpapi/ routes, handlers, and middleware
internal/client/ Go API client used by stew
migrations/ versioned SQL (up/down)
web/ dashboard (TypeScript), embedded in the server
extensions/vscode/ VS Code extension (TypeScript)
Design decisions
- Postgres is the source of truth; Redis is only a cache. If Redis goes down, evaluation reads the database and rate limits are skipped. After a Redis failure the server leaves Redis alone for 5 seconds, so an outage doesn't add a timeout to every request.
- Changes clear the cache right after they're saved, and again a second later in case a read raced the change, so the kill switch and approvals take effect on the next evaluation. The TTL only bounds staleness when Redis misses a change, for example during an outage.
- SDK key lookups are cached too, and revoking any key clears them all, right away and again a second later, so a revoked key stops working on its next request. If Redis can't be cleared during a revoke, the server clears it before using it again; another server sharing that Redis could accept the revoked key for at most
CACHE_TTL_SECONDS. Failed lookups are never cached, so random keys can't fill Redis, and Redis stores a hash of each key, never the key.
- Rate limits count per SDK key or user, not per IP address, so they work the same behind a proxy. Keys in Redis hold a hash of the SDK key, never the key itself.
- The audit log is append-only. Events are never updated or deleted.
- The CLI, dashboard, and extension are all API clients. Every rule lives in the server, so no client can bypass approvals.
- Storage sits behind interfaces, so business logic is tested against in-memory fakes.
- State-changing requests accept an idempotency key, so retries can't apply a change twice.
- An approval applies only to the settings it was requested against. Approving checks and changes
prod in one transaction, so an approval can't overwrite a newer change.
Testing
make test # unit tests
make test-int # integration tests (Postgres and Redis from docker compose)
cd web && npm test # dashboard tests
cd extensions/vscode && npm test # extension tests
CI runs go vet, unit and integration tests against real Postgres and Redis, govulncheck and npm audit, a secret scan, the dashboard's typecheck, tests, and build, a Docker build, the VS Code extension's tests and package, and a stew build for Linux, macOS, and Windows on every pull request.
cmd/evalload sends evaluations as fast as it can and reports throughput and latency. The SDK key comes from an environment variable, so it stays out of your shell history:
EVALLOAD_KEY=fs_sdk_... go run ./cmd/evalload -flag new-checkout -c 32 -d 20s
On a MacBook Pro with Postgres and Redis in Docker, 32 concurrent clients, rate limiting off, and a flag with a rollout and one rule:
|
Evaluations/s |
p50 |
p99 |
Database transactions per evaluation |
| Cache off |
9,656 |
3.3 ms |
5.2 ms |
2 |
| Cache on |
19,121 |
1.6 ms |
2.9 ms |
0 |
With the cache on, an evaluation reads neither the flag nor the SDK key from Postgres. These numbers come from one laptop running everything, so treat them as a comparison, not a capacity estimate.
Roadmap
- ✅ Core flags + evaluation API
- ✅ Auth, RBAC, and audit log
- ✅ Stewards +
stew CLI
- ✅ Dashboard
- ✅ Approvals for production
- ✅ Redis cache + rate limiting
- ✅ Stale-flag detection
- ✅ VS Code extension
- Stretch: OpenFeature-compatible provider
Author
Built by Mel · MelStackBox · GitHub
License
MIT