{"schemaVersion":"1.0","type":"TechArticle","types":["Article","TechArticle"],"slug":"feature-flags-without-a-500-month-platform-build-your-own-in-node-js-zjer5","url":"https://zyvop.com/feature-flags-without-a-500-month-platform-build-your-own-in-node-js-zjer5","title":"Feature Flags Without a $500/Month Platform: Build Your Own in Node.js","subtitle":"A full production feature flag system built on Postgres and Redis — percentage rollouts, tenant targeting, two-layer caching, and the hygiene that prevents flag debt","tldr":"Feature flags are one of those tools that sound like enterprise overhead until you ship a broken feature to all your users at once and spend 45 minutes rolling ...","keywords":["LaunchDarkly alternative self hosted","build feature flags Postgres","feature flag targeting 2026","feature toggles TypeScript","percentage rollout Node.js","feature flag Redis cache","feature flags Node.js"],"entities":["ZyVOP","LaunchDarkly alternative self hosted","build feature flags Postgres","feature flag targeting 2026","feature toggles TypeScript","percentage rollout Node.js","feature flag Redis cache","feature flags Node.js"],"keyTakeaways":["Feature flags are one of those tools that sound like enterprise overhead until you ship a broken feature to all your users at once and spend 45 minutes rolling back a deploy.","After that, they sound essential.","The commercial platforms — LaunchDarkly, Split, Unleash Cloud — are excellent but expensive."],"headings":["What a Feature Flag System Actually Needs","The Database Schema","Seed Some Flags","The Flag Evaluator","Usage in Your Application","The Admin API","Flag Hygiene: The Discipline That Prevents Flag Debt"],"outboundLinks":[],"contentText":"Feature flags are one of those tools that sound like enterprise overhead until you ship a broken feature to all your users at once and spend 45 minutes rolling back a deploy. After that, they sound essential. The commercial platforms — LaunchDarkly, Split, Unleash Cloud — are excellent but expensive. LaunchDarkly starts at several hundred dollars a month for teams. For most early-stage products, that is a hard sell when the underlying concept is not complicated. This guide builds a production-ready feature flag system from scratch: flags stored in Postgres, cached in Redis, evaluated per-user or per-tenant, with a simple admin API and a client that degrades gracefully when the flag store is unavailable. What a Feature Flag System Actually Needs At minimum: Flag storage — a record of which flags exist and their current state Evaluation — given a flag and a context (user, tenant, environment), return on or off Targeting — some flags should be on for 10% of users, or only for beta testers, or only for a specific tenant Caching — flag checks happen on every request; they cannot hit the database every time Fallback — if the flag store is unavailable, the system should degrade safely Optionally: an admin UI, audit logs, scheduled flag expiry. The Database Schema CREATE TYPE flag_status AS ENUM ('active', 'archived'); CREATE TYPE rule_type AS ENUM ('percentage', 'user_ids', 'tenant_ids', 'attribute'); CREATE TABLE feature_flags ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), key TEXT NOT NULL UNIQUE, -- 'new-checkout-flow', 'dark-mode' description TEXT, status flag_status NOT NULL DEFAULT 'active', enabled BOOLEAN NOT NULL DEFAULT false, -- Global on/off created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Targeting rules — evaluated in order, first match wins CREATE TABLE flag_rules ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), flag_id UUID NOT NULL REFERENCES feature_flags(id) ON DELETE CASCADE, rule_type rule_type NOT NULL, value JSONB NOT NULL, -- Rule data (e.g. percentage, list of IDs) result BOOLEAN NOT NULL, -- What to return if this rule matches priority INTEGER NOT NULL DEFAULT 0, -- Lower = evaluated first created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_flag_rules_flag_id ON flag_rules(flag_id); -- Audit log CREATE TABLE flag_audit_log ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), flag_key TEXT NOT NULL, action TEXT NOT NULL, changed_by UUID, old_value JSONB, new_value JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() );Seed Some Flags INSERT INTO feature_flags (key, description, enabled) VALUES ('new-checkout-flow', 'Redesigned checkout with one-page layout', false), ('ai-suggestions', 'AI-powered search suggestions in header', false), ('dark-mode', 'Dark mode UI toggle', true), ('export-csv', 'CSV export on all data tables', false); -- Enable new-checkout-flow for 20% of users INSERT INTO flag_rules (flag_id, rule_type, value, result, priority) SELECT id, 'percentage', '{\"percentage\": 20}'::jsonb, true, 0 FROM feature_flags WHERE key = 'new-checkout-flow'; -- Enable ai-suggestions for specific beta tenant IDs INSERT INTO flag_rules (flag_id, rule_type, value, result, priority) SELECT id, 'tenant_ids', '{\"ids\": [\"uuid-tenant-1\", \"uuid-tenant-2\"]}'::jsonb, true, 0 FROM feature_flags WHERE key = 'ai-suggestions';The Flag Evaluator // src/lib/featureFlags.ts import db from './db'; import redis from './redis'; import crypto from 'crypto'; import logger from './logger'; interface EvaluationContext { userId?: string; tenantId?: string; email?: string; plan?: string; [key: string]: string | undefined; // Custom attributes } interface FlagRule { rule_type: 'percentage' | 'user_ids' | 'tenant_ids' | 'attribute'; value: Record&lt;string, any&gt;; result: boolean; priority: number; } interface Flag { key: string; enabled: boolean; status: string; rules: FlagRule[]; } // Cache flag configs — refreshed every 60 seconds const FLAG_CACHE_TTL = 60; const LOCAL_CACHE = new Map&lt;string, { data: Flag; expiresAt: number }&gt;(); async function getFlagConfig(flagKey: string): Promise&lt;Flag | null&gt; { // L1: in-process cache (fastest, no network) const local = LOCAL_CACHE.get(flagKey); if (local &amp;&amp; local.expiresAt &gt; Date.now()) { return local.data; } // L2: Redis cache try { const cached = await redis.get(`flag:${flagKey}`); if (cached) { const flag = JSON.parse(cached); LOCAL_CACHE.set(flagKey, { data: flag, expiresAt: Date.now() + 30_000, // Local cache for 30s }); return flag; } } catch (err) { logger.warn({ flagKey }, 'Redis unavailable for flag lookup'); } // L3: Database try { const result = await db.query(` SELECT f.key, f.enabled, f.status, COALESCE( json_agg( json_build_object( 'rule_type', r.rule_type, 'value', r.value, 'result', r.result, 'priority', r.priority ) ORDER BY r.priority ASC ) FILTER (WHERE r.id IS NOT NULL), '[]' ) AS rules FROM feature_flags f LEFT JOIN flag_rules r ON r.flag_id = f.id WHERE f.key = $1 AND f.status = 'active' GROUP BY f.key, f.enabled, f.status `, [flagKey]); if (!result.rows[0]) return null; const flag = result.rows[0] as Flag; // Populate both caches try { await redis.setex(`flag:${flagKey}`, FLAG_CACHE_TTL, JSON.stringify(flag)); } catch {} LOCAL_CACHE.set(flagKey, { data: flag, expiresAt: Date.now() + 30_000, }); return flag; } catch (err) { logger.error({ flagKey, error: (err as Error).message }, 'Flag DB lookup failed'); return null; } } function evaluateRules(rules: FlagRule[], context: EvaluationContext): boolean | null { for (const rule of rules) { switch (rule.rule_type) { case 'user_ids': { if (context.userId &amp;&amp; rule.value.ids?.includes(context.userId)) { return rule.result; } break; } case 'tenant_ids': { if (context.tenantId &amp;&amp; rule.value.ids?.includes(context.tenantId)) { return rule.result; } break; } case 'percentage': { if (!context.userId) break; // Hash the user ID to get a stable 0-100 bucket // Same user always gets the same result — consistent experience const hash = crypto .createHash('sha256') .update(context.userId) .digest('hex'); const bucket = parseInt(hash.slice(0, 8), 16) % 100; if (bucket &lt; rule.value.percentage) { return rule.result; } break; } case 'attribute': { const { attribute, operator, value } = rule.value; const contextValue = context[attribute]; if (contextValue === undefined) break; const match = operator === 'equals' ? contextValue === value : operator === 'not_equals' ? contextValue !== value : operator === 'contains' ? contextValue.includes(value) : operator === 'in' ? value.includes(contextValue) : false; if (match) return rule.result; break; } } } return null; // No rules matched } /** * Check if a feature flag is enabled for a given context. * @param flagKey The flag key (e.g. 'new-checkout-flow') * @param context User/tenant context for targeting rules * @param defaultValue What to return if the flag is not found (safe default) */ export async function isEnabled( flagKey: string, context: EvaluationContext = {}, defaultValue: boolean = false ): Promise&lt;boolean&gt; { try { const flag = await getFlagConfig(flagKey); if (!flag) return defaultValue; // Flag not found — use default if (!flag.enabled) return false; // Globally disabled // Evaluate targeting rules — first match wins const ruleResult = evaluateRules(flag.rules, context); if (ruleResult !== null) return ruleResult; // No rules matched — use global enabled state return flag.enabled; } catch (err) { // If anything fails, return the safe default // Never let a flag check crash your application logger.error({ flagKey, error: (err as Error).message }, 'Flag evaluation error'); return defaultValue; } } /** * Check multiple flags at once — one cache/DB round trip */ export async function getFlags( flagKeys: string[], context: EvaluationContext = {} ): Promise&lt;Record&lt;string, boolean&gt;&gt; { const results = await Promise.all( flagKeys.map(async (key) =&gt; [key, await isEnabled(key, context)] as const) ); return Object.fromEntries(results); } /** * Invalidate the cache for a flag — call this after updating a flag */ export async function invalidateFlag(flagKey: string): Promise&lt;void&gt; { LOCAL_CACHE.delete(flagKey); await redis.del(`flag:${flagKey}`); }Usage in Your Application import { isEnabled, getFlags } from '../lib/featureFlags'; // Simple check in a route handler router.get('/checkout', authenticate, async (req, res) =&gt; { const useNewCheckout = await isEnabled('new-checkout-flow', { userId: req.user.id, tenantId: req.tenant?.id, plan: req.tenant?.plan, }); res.json({ checkoutVersion: useNewCheckout ? 'v2' : 'v1', }); }); // Check multiple flags at once (one round trip) router.get('/app-config', authenticate, async (req, res) =&gt; { const context = { userId: req.user.id, tenantId: req.tenant?.id, plan: req.tenant?.plan, email: req.user.email, }; const flags = await getFlags([ 'new-checkout-flow', 'ai-suggestions', 'dark-mode', 'export-csv', ], context); // Send flag state to frontend — client uses this to show/hide features res.json({ flags }); });The Admin API // src/routes/admin/flags.ts router.get('/admin/flags', authenticate, requireRole('admin'), async (req, res) =&gt; { const flags = await db.query(` SELECT f.*, COUNT(r.id) AS rule_count FROM feature_flags f LEFT JOIN flag_rules r ON r.flag_id = f.id WHERE f.status = 'active' GROUP BY f.id ORDER BY f.key `); res.json(flags.rows); }); router.patch('/admin/flags/:key', authenticate, requireRole('admin'), async (req, res) =&gt; { const { enabled } = req.body; const { key } = req.params; const [flag] = await db.query( `UPDATE feature_flags SET enabled = $1, updated_at = NOW() WHERE key = $2 RETURNING *`, [enabled, key] ).then(r =&gt; r.rows); if (!flag) return res.status(404).json({ error: 'Flag not found' }); // Audit the change await db.query( `INSERT INTO flag_audit_log (flag_key, action, changed_by, old_value, new_value) VALUES ($1, 'toggle', $2, $3, $4)`, [key, req.user.id, { enabled: !enabled }, { enabled }] ); // Bust the cache await invalidateFlag(key); res.json(flag); });Flag Hygiene: The Discipline That Prevents Flag Debt Feature flags accumulate. After six months you have 40 flags, nobody knows which are still in use, and the evaluation logic in your codebase has become a maze of nested conditionals. Name flags for the transition, not the feature. new-checkout-flow tells you this is a temporary flag for a migration. checkout-v2 sounds permanent. Set an expiry date when you create the flag. Add an expires_at column and alert when flags are past their intended lifetime: ALTER TABLE feature_flags ADD COLUMN expires_at TIMESTAMPTZ; -- Weekly job: alert on expired flags SELECT key, description, expires_at FROM feature_flags WHERE expires_at &lt; NOW() AND status = 'active';Delete flags when features are fully rolled out. Once you ship to 100% of users and the old code path is removed, delete the flag and its evaluation checks from the codebase. Flags are temporary by definition. Limit concurrent active flags. If you have more than 10–15 active flags at once, the combination of states becomes difficult to reason about. Treat flag debt the same way you treat technical debt.","contentHash":"sha256:1c3d22506fec37646225930be59ff9487494a4c6ea46bdbbbe6ae6db4149b2f6","authorName":"ZyVOP","authorUrl":"https://zyvop.com/author/zyvop","authorSameAs":["https://zyvop.com","https://github.com/zyvop","https://x.com/zyvop1"],"category":null,"tags":["LaunchDarkly alternative self hosted","build feature flags Postgres","feature flag targeting 2026","feature toggles TypeScript","percentage rollout Node.js","feature flag Redis cache","feature flags Node.js"],"audience":"Software engineers and developers building applications with LaunchDarkly alternative self hosted","tone":"Instructional, practical, code-first","readingTimeMinutes":7,"wordCount":1478,"faqs":null,"primaryTopic":"LaunchDarkly alternative self hosted","publishedAt":"2026-05-26T07:57:35.782Z","updatedAt":"2026-08-18T12:30:00.353Z","canonicalUrl":"https://zyvop.com/feature-flags-without-a-500-month-platform-build-your-own-in-node-js-zjer5"}