Feature Flags in CI/CD: Trunk-Based Development
Implement feature flags to enable trunk-based development, safer deployments, and continuous delivery without long-lived branches.
Long-lived feature branches are a deployment anti-pattern. They cause merge hell, delay feedback, and make releases risky. Feature flags solve this by decoupling deployment from release—you deploy code continuously but control who sees features.
Why Feature Flags?
Traditional branching workflows have serious problems:
- Merge conflicts: The longer a branch lives, the worse merges get
- Integration delays: Bugs aren’t found until the big merge
- Release risk: Large deployments are inherently risky
- Slow feedback: Features sit untested in production environments
Feature flags flip the model: deploy unfinished features behind flags, test in production safely, release to users gradually.
Trunk-Based Development
In trunk-based development, everyone commits to main (or trunk) at least daily. Features are hidden behind flags until ready.
Traditional:
main ─────────────────────────────────────────────────
\ /
feature/login ──────────────────────── (2 weeks)
Trunk-based:
main ─●─●─●─●─●─●─●─●─●─●─●─●─●─●─●─●─●─●─●─●─●─●─●─●─
↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑
All commits from all developers (behind flags)
Implementing Feature Flags
Basic Implementation
Start simple—feature flags don’t require a vendor:
// config/features.js
const features = {
newCheckoutFlow: {
enabled: process.env.FEATURE_NEW_CHECKOUT === 'true',
rolloutPercentage: parseInt(process.env.FEATURE_NEW_CHECKOUT_ROLLOUT || '0'),
},
darkMode: {
enabled: process.env.FEATURE_DARK_MODE === 'true',
enabledUsers: (process.env.FEATURE_DARK_MODE_USERS || '').split(','),
},
};
export function isFeatureEnabled(featureName, context = {}) {
const feature = features[featureName];
if (!feature) return false;
// Check if globally enabled
if (!feature.enabled) return false;
// Check user allowlist
if (feature.enabledUsers?.includes(context.userId)) {
return true;
}
// Check percentage rollout
if (feature.rolloutPercentage) {
const hash = simpleHash(context.userId || context.sessionId);
return (hash % 100) < feature.rolloutPercentage;
}
return feature.enabled;
}
function simpleHash(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash);
}
Usage in Code
// React component
import { isFeatureEnabled } from '../config/features';
function CheckoutPage({ user }) {
const showNewCheckout = isFeatureEnabled('newCheckoutFlow', {
userId: user.id
});
if (showNewCheckout) {
return <NewCheckoutFlow user={user} />;
}
return <LegacyCheckout user={user} />;
}
// Go backend
func (s *Server) handleCheckout(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value("user").(*User)
if features.IsEnabled("newCheckoutFlow", features.Context{UserID: user.ID}) {
s.newCheckoutHandler(w, r)
return
}
s.legacyCheckoutHandler(w, r)
}
Using LaunchDarkly
For production systems, use a dedicated feature flag service. LaunchDarkly is the industry standard:
// Initialize LaunchDarkly
import * as LaunchDarkly from 'launchdarkly-node-server-sdk';
const ldClient = LaunchDarkly.init(process.env.LAUNCHDARKLY_SDK_KEY);
await ldClient.waitForInitialization();
// Check flag
async function isFeatureEnabled(flagKey, user) {
const ldUser = {
key: user.id,
email: user.email,
custom: {
plan: user.plan,
company: user.companyId,
},
};
return ldClient.variation(flagKey, ldUser, false);
}
React Integration
// App.jsx
import { withLDProvider } from 'launchdarkly-react-client-sdk';
function App() {
return <Router>...</Router>;
}
export default withLDProvider({
clientSideID: process.env.REACT_APP_LD_CLIENT_ID,
user: {
key: 'anonymous',
},
})(App);
// Component using flags
import { useFlags } from 'launchdarkly-react-client-sdk';
function FeatureComponent() {
const { newDashboard, darkModeEnabled } = useFlags();
if (newDashboard) {
return <NewDashboard darkMode={darkModeEnabled} />;
}
return <LegacyDashboard />;
}
Feature Flag Types
Release Flags (Short-lived)
Hide incomplete features until ready:
// Remove after feature is stable
if (flags.newSearchAlgorithm) {
return searchV2(query);
}
return searchV1(query);
Lifecycle: Days to weeks. Delete after full rollout.
Experiment Flags (A/B Testing)
Compare feature variants:
const variant = flags.checkoutButtonVariant; // 'blue', 'green', 'orange'
return (
<Button
color={variant}
onClick={() => {
analytics.track('checkout_clicked', { variant });
handleCheckout();
}}
>
Complete Purchase
</Button>
);
Lifecycle: Weeks. Delete after experiment concludes.
Ops Flags (Kill Switches)
Disable features under load:
// Kill switch for expensive feature
if (!flags.enableRecommendations) {
return { recommendations: [] };
}
return fetchRecommendations(userId);
Lifecycle: Permanent. Keep for operational control.
Permission Flags (Entitlements)
Control feature access by plan:
const canUseAdvancedReports = flags.advancedReportsEnabled; // Based on user's plan
if (!canUseAdvancedReports) {
return <UpgradePrompt feature="Advanced Reports" />;
}
return <AdvancedReports />;
Lifecycle: Permanent. Tied to business logic.
Rollout Strategies
Percentage Rollout
# LaunchDarkly flag configuration
flag: new-checkout-flow
targeting:
rules: []
fallthrough:
rollout:
variations:
- variation: true
weight: 10000 # 10%
- variation: false
weight: 90000 # 90%
// Gradual rollout script
async function incrementRollout(flagKey, increment = 10) {
const flag = await ldClient.getFlag(flagKey);
const currentPercentage = flag.fallthrough.rollout.variations[0].weight / 1000;
const newPercentage = Math.min(100, currentPercentage + increment);
console.log(`Rolling out ${flagKey}: ${currentPercentage}% → ${newPercentage}%`);
await ldClient.updateFlag(flagKey, {
fallthrough: {
rollout: {
variations: [
{ variation: true, weight: newPercentage * 1000 },
{ variation: false, weight: (100 - newPercentage) * 1000 },
],
},
},
});
}
Canary Releases
// Target internal users first
const ldUser = {
key: user.id,
custom: {
isEmployee: user.email.endsWith('@company.com'),
environment: process.env.NODE_ENV,
},
};
// LaunchDarkly rule: if isEmployee === true, serve true
Ring-Based Deployment
Ring 0: Internal employees (1 day)
Ring 1: Beta users (3 days)
Ring 2: 10% of production (1 week)
Ring 3: 50% of production (1 week)
Ring 4: 100% (full rollout)
const userRing = determineUserRing(user);
const targetRing = flags.newFeatureRing; // 0, 1, 2, 3, or 4
const showFeature = userRing <= targetRing;
CI/CD Integration
Pipeline with Feature Flags
# .gitlab-ci.yml
stages:
- test
- deploy
- rollout
deploy-production:
stage: deploy
script:
- kubectl apply -f k8s/
- ./wait-for-healthy.sh
only:
- main
# Automated rollout stages
rollout-10-percent:
stage: rollout
script:
- |
curl -X PATCH "https://app.launchdarkly.com/api/v2/flags/production/${FEATURE_FLAG}" \
-H "Authorization: ${LD_API_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"op": "replace", "path": "/environments/production/fallthrough/rollout", "value": {"variations": [{"variation": 0, "weight": 10000}, {"variation": 1, "weight": 90000}]}}'
when: manual
rollout-50-percent:
stage: rollout
needs: [rollout-10-percent]
script:
- ./set-rollout-percentage.sh $FEATURE_FLAG 50
when: manual
rollout-100-percent:
stage: rollout
needs: [rollout-50-percent]
script:
- ./set-rollout-percentage.sh $FEATURE_FLAG 100
- echo "Remember to remove flag from code!"
when: manual
Testing with Feature Flags
// Jest tests
describe('CheckoutPage', () => {
it('renders new checkout when flag is enabled', () => {
// Mock flag as enabled
jest.mock('../config/features', () => ({
isFeatureEnabled: jest.fn((flag) => flag === 'newCheckoutFlow'),
}));
const { getByText } = render(<CheckoutPage user={mockUser} />);
expect(getByText('Express Checkout')).toBeInTheDocument();
});
it('renders legacy checkout when flag is disabled', () => {
jest.mock('../config/features', () => ({
isFeatureEnabled: jest.fn(() => false),
}));
const { getByText } = render(<CheckoutPage user={mockUser} />);
expect(getByText('Standard Checkout')).toBeInTheDocument();
});
});
# E2E tests with different flag states
e2e-new-checkout:
script:
- export FEATURE_NEW_CHECKOUT=true
- npm run test:e2e -- --spec checkout
e2e-legacy-checkout:
script:
- export FEATURE_NEW_CHECKOUT=false
- npm run test:e2e -- --spec checkout
Flag Hygiene
The Technical Debt Problem
Feature flags are technical debt by design. Without cleanup, you end up with:
// 6 months later...
if (flags.newCheckout && !flags.checkoutV2 && flags.checkoutExperiment !== 'control') {
if (flags.paymentProviderV2 || flags.stripeEnabled) {
// Nobody knows what this does anymore
}
}
Flag Lifecycle Management
// Add metadata to flags
const flags = {
newCheckoutFlow: {
enabled: true,
owner: 'payments-team',
createdAt: '2024-01-15',
expectedRemovalDate: '2024-03-01',
jiraTicket: 'PAY-1234',
},
};
// Automated stale flag detection
function findStaleFlags() {
const staleFlags = [];
const now = new Date();
for (const [name, config] of Object.entries(flags)) {
const removalDate = new Date(config.expectedRemovalDate);
if (removalDate < now && config.enabled) {
staleFlags.push({
name,
owner: config.owner,
overdueBy: Math.floor((now - removalDate) / (1000 * 60 * 60 * 24)),
ticket: config.jiraTicket,
});
}
}
return staleFlags;
}
Automated Cleanup Reminders
# .github/workflows/flag-cleanup.yml
name: Feature Flag Cleanup Check
on:
schedule:
- cron: '0 9 * * 1' # Monday 9am
jobs:
check-stale-flags:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Find stale flags
run: |
node scripts/find-stale-flags.js > stale-flags.json
- name: Notify Slack
if: ${{ steps.find.outputs.has_stale }}
uses: slack/action@v1
with:
payload: |
{
"text": "🚩 Stale feature flags need cleanup!",
"attachments": ${{ steps.find.outputs.flags }}
}
Monitoring and Observability
Flag-Aware Metrics
// Track metrics by flag variant
function trackCheckoutMetrics(success, duration, variant) {
metrics.histogram('checkout.duration', duration, {
variant: variant ? 'new' : 'legacy',
success: success.toString(),
});
if (!success) {
metrics.increment('checkout.failures', {
variant: variant ? 'new' : 'legacy',
});
}
}
Error Tracking
// Sentry integration
Sentry.setTag('feature.newCheckout', flags.newCheckoutFlow);
Sentry.setTag('feature.darkMode', flags.darkMode);
// Now errors are filterable by flag state
Dashboards
# Compare error rates between variants
sum(rate(http_requests_total{status="500", feature_variant="new"}[5m])) /
sum(rate(http_requests_total{feature_variant="new"}[5m]))
# vs
sum(rate(http_requests_total{status="500", feature_variant="legacy"}[5m])) /
sum(rate(http_requests_total{feature_variant="legacy"}[5m]))
When NOT to Use Feature Flags
Feature flags aren’t always the answer:
- Database migrations: Use proper migration tools
- API changes: Use versioning instead
- Simple bug fixes: Just deploy them
- One-time scripts: No need to flag
- Performance optimizations: A/B test, but don’t permanently flag
Self-Hosted Alternatives
If you can’t use LaunchDarkly:
| Tool | Type | Notes |
|---|---|---|
| Unleash | Open Source | Self-hosted, good SDK support |
| Flagsmith | Open Source | Cloud or self-hosted |
| GrowthBook | Open Source | Focus on A/B testing |
| ConfigCat | SaaS | Simpler, cheaper alternative |
// Unleash example
import { initialize } from 'unleash-client';
const unleash = initialize({
url: 'https://unleash.example.com/api/',
appName: 'my-app',
customHeaders: { Authorization: process.env.UNLEASH_API_KEY },
});
if (unleash.isEnabled('newCheckoutFlow')) {
// Feature is enabled
}
Key Takeaways
- Feature flags decouple deployment from release—deploy daily, release when ready
- Start simple—environment variables work for basic flags
- Categorize flags by type (release, experiment, ops, permission)
- Set removal dates when creating flags—they’re intentional debt
- Roll out gradually—percentage-based rollouts catch issues early
- Monitor by variant—compare metrics between flag states
- Clean up religiously—stale flags become impossible to understand
Trunk-based development with feature flags isn’t just about faster deployments. It’s about building a culture where small, frequent changes are the norm and big, scary releases are a thing of the past.