Manage feature flags as code with API automation, CI/CD pipelines, and version-controlled configurations
Last updated April 4, 2026
Infrastructure as Code
Manage your feature flags as code alongside your application. Use Flaggr's API to automate flag creation, promote configurations between environments, validate changes in CI/CD, and maintain flag definitions in version control.
Why IaC for Feature Flags?
| Benefit | How |
|---|---|
| Reproducibility | Flag configurations are version-controlled and can be recreated from scratch |
| Auditability | Changes go through pull requests with code review |
| Consistency | Same flags across environments, applied automatically |
| Rollback | Git revert restores previous flag state |
| Testing | Validate flag configurations before applying them |
API-Driven Automation
All flag operations are available through the REST API. Use JWT tokens for programmatic access with fine-grained permissions.
Creating a JWT Token for CI/CD
curl -X POST /api/tokens \
-H "Authorization: Bearer flg_admin_token" \
-H "Content-Type: application/json" \
-d '{
"name": "CI/CD Pipeline",
"tokenType": "jwt",
"permissions": { "read": true, "write": true, "delete": false },
"scopes": ["service:web-app:read", "service:web-app:write"],
"expiresAt": "2027-01-01T00:00:00Z"
}'Use scoped JWT tokens for CI/CD — limit access to specific services and avoid granting delete permissions. Rotate tokens regularly.
Flag Definitions in Version Control
Store your flag definitions as JSON files in your repository:
repo/
├── flags/
│ ├── development.json
│ ├── staging.json
│ └── production.json
└── .github/
└── workflows/
└── sync-flags.yml
Flag Definition File
{
"flags": [
{
"key": "checkout-v2",
"name": "Checkout V2",
"description": "New streamlined checkout flow",
"type": "boolean",
"enabled": false,
"defaultValue": false,
"serviceId": "web-app",
"environment": "production",
"tags": ["checkout", "q1-2026"],
"targeting": [
{
"id": "beta-users",
"conditions": [
{ "property": "plan", "operator": "equals", "value": "enterprise" }
],
"rolloutPercentage": 100
}
]
},
{
"key": "dark-mode",
"name": "Dark Mode",
"description": "Toggle dark mode support",
"type": "boolean",
"enabled": true,
"defaultValue": false,
"serviceId": "web-app",
"environment": "production",
"tags": ["ui", "permanent"]
}
]
}CI/CD Pipeline Examples
GitHub Actions: Validate and Sync Flags
name: Sync Feature Flags
on:
push:
branches: [main]
paths:
- 'flags/**'
pull_request:
paths:
- 'flags/**'
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate flag definitions
run: |
for file in flags/*.json; do
echo "Validating $file..."
# Validate JSON syntax
jq empty "$file" || exit 1
# Validate required fields
jq -e '.flags | length > 0' "$file" > /dev/null || {
echo "Error: $file has no flags"
exit 1
}
# Validate flag keys match naming convention
jq -e '.flags[] | select(.key | test("^[a-zA-Z][a-zA-Z0-9_-]*$") | not) | .key' "$file" \
| while read -r key; do
echo "Error: Invalid flag key: $key"
exit 1
done
done
echo "All flag definitions valid"
- name: Dry run import (PR only)
if: github.event_name == 'pull_request'
env:
FLAGGR_API_URL: ${{ vars.FLAGGR_API_URL }}
FLAGGR_TOKEN: ${{ secrets.FLAGGR_CI_TOKEN }}
run: |
for file in flags/*.json; do
echo "Dry run: $file"
RESULT=$(curl -s -X POST "${FLAGGR_API_URL}/api/flags/import" \
-H "Authorization: Bearer ${FLAGGR_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq '{flags: .flags, conflictResolution: "skip", dryRun: true}' "$file")")
echo "$RESULT" | jq .
ERRORS=$(echo "$RESULT" | jq '.errors | length')
if [ "$ERRORS" -gt 0 ]; then
echo "::error::Import validation failed with $ERRORS errors"
exit 1
fi
done
sync:
needs: validate
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Sync flags to Flaggr
env:
FLAGGR_API_URL: ${{ vars.FLAGGR_API_URL }}
FLAGGR_TOKEN: ${{ secrets.FLAGGR_CI_TOKEN }}
run: |
for file in flags/*.json; do
echo "Syncing $file..."
RESULT=$(curl -s -X POST "${FLAGGR_API_URL}/api/flags/import" \
-H "Authorization: Bearer ${FLAGGR_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq '{flags: .flags, conflictResolution: "overwrite", dryRun: false}' "$file")")
echo "$RESULT" | jq .
SUCCESS=$(echo "$RESULT" | jq '.success')
if [ "$SUCCESS" != "true" ]; then
echo "::error::Flag sync failed"
exit 1
fi
done
echo "All flags synced successfully"Generic Bash Script
A reusable script for any CI/CD system:
#!/bin/bash
# sync-flags.sh — Sync flag definitions to Flaggr
set -euo pipefail
FLAGGR_API_URL="${FLAGGR_API_URL:?Missing FLAGGR_API_URL}"
FLAGGR_TOKEN="${FLAGGR_TOKEN:?Missing FLAGGR_TOKEN}"
FLAGS_DIR="${FLAGS_DIR:-./flags}"
DRY_RUN="${DRY_RUN:-false}"
CONFLICT="${CONFLICT:-skip}"
for file in "$FLAGS_DIR"/*.json; do
[ -f "$file" ] || continue
echo "Processing: $file"
RESULT=$(curl -sf -X POST "${FLAGGR_API_URL}/api/flags/import" \
-H "Authorization: Bearer ${FLAGGR_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq --arg cr "$CONFLICT" --argjson dr "$DRY_RUN" \
'{flags: .flags, conflictResolution: $cr, dryRun: $dr}' "$file")")
CREATED=$(echo "$RESULT" | jq '.created')
UPDATED=$(echo "$RESULT" | jq '.updated')
SKIPPED=$(echo "$RESULT" | jq '.skipped')
ERRORS=$(echo "$RESULT" | jq '.errors | length')
echo " Created: $CREATED, Updated: $UPDATED, Skipped: $SKIPPED, Errors: $ERRORS"
if [ "$ERRORS" -gt 0 ]; then
echo "$RESULT" | jq '.errors[]'
exit 1
fi
done
echo "Done"Usage:
# Dry run
DRY_RUN=true CONFLICT=skip ./sync-flags.sh
# Sync with overwrite
DRY_RUN=false CONFLICT=overwrite ./sync-flags.shEnvironment Promotion
Automate flag promotion from staging to production:
#!/bin/bash
# promote-flags.sh — Promote flags from staging to production
set -euo pipefail
FLAGGR_API_URL="${FLAGGR_API_URL:?Missing FLAGGR_API_URL}"
FLAGGR_TOKEN="${FLAGGR_TOKEN:?Missing FLAGGR_TOKEN}"
PROJECT_ID="${PROJECT_ID:?Missing PROJECT_ID}"
# 1. Export from staging
echo "Exporting staging flags..."
curl -sf "${FLAGGR_API_URL}/api/flags/export?projectId=${PROJECT_ID}&environment=staging" \
-H "Authorization: Bearer ${FLAGGR_TOKEN}" \
-o staging-export.json
# 2. Transform for production (disabled by default for safety)
echo "Transforming for production..."
jq '.flags |= map(.environment = "production" | .enabled = false)' \
staging-export.json > production-import.json
FLAG_COUNT=$(jq '.flags | length' production-import.json)
echo " $FLAG_COUNT flags to promote"
# 3. Dry run
echo "Running dry run..."
RESULT=$(curl -sf -X POST "${FLAGGR_API_URL}/api/flags/import" \
-H "Authorization: Bearer ${FLAGGR_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq '{flags: .flags, conflictResolution: "skip", dryRun: true}' production-import.json)")
echo "$RESULT" | jq '{created, updated, skipped, errors: (.errors | length)}'
# 4. Confirm and apply
read -p "Apply to production? (yes/no): " CONFIRM
if [ "$CONFIRM" = "yes" ]; then
curl -sf -X POST "${FLAGGR_API_URL}/api/flags/import" \
-H "Authorization: Bearer ${FLAGGR_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq '{flags: .flags, conflictResolution: "skip", dryRun: false}' production-import.json)" | jq .
echo "Promotion complete"
else
echo "Cancelled"
fi
# Cleanup
rm -f staging-export.json production-import.jsonPR Validation with Dry Run
Add flag validation to your PR checks to catch issues before merge:
# .github/workflows/validate-flags.yml
name: Validate Flags
on:
pull_request:
paths: ['flags/**']
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate and dry-run import
env:
FLAGGR_API_URL: ${{ vars.FLAGGR_API_URL }}
FLAGGR_TOKEN: ${{ secrets.FLAGGR_CI_TOKEN }}
run: |
# Find changed flag files
CHANGED=$(git diff --name-only origin/main -- 'flags/*.json')
if [ -z "$CHANGED" ]; then
echo "No flag files changed"
exit 0
fi
for file in $CHANGED; do
echo "::group::Validating $file"
RESULT=$(curl -sf -X POST "${FLAGGR_API_URL}/api/flags/import" \
-H "Authorization: Bearer ${FLAGGR_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq '{flags: .flags, conflictResolution: "skip", dryRun: true}' "$file")")
echo "$RESULT" | jq .
ERRORS=$(echo "$RESULT" | jq '.errors | length')
if [ "$ERRORS" -gt 0 ]; then
echo "::error::Validation failed for $file"
exit 1
fi
echo "::endgroup::"
done
- name: Comment on PR
if: success()
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: 'Flag validation passed. All flag definitions are valid and ready to sync.'
})Token Management for CI/CD
Best Practices
| Practice | Why |
|---|---|
| Use JWT tokens with scopes | Limit blast radius if token is compromised |
| Set expiration dates | Force periodic rotation |
| Store as CI/CD secrets | Never commit tokens to code |
| Separate read and write tokens | Validation needs read-only; sync needs write |
| Rotate every 90 days | Reduce risk from leaked credentials |
Creating Purpose-Specific Tokens
# Read-only token for PR validation
curl -X POST /api/tokens \
-H "Authorization: Bearer flg_admin_token" \
-H "Content-Type: application/json" \
-d '{
"name": "CI Validation (read-only)",
"tokenType": "jwt",
"permissions": { "read": true, "write": false, "delete": false }
}'
# Write token for flag sync (main branch only)
curl -X POST /api/tokens \
-H "Authorization: Bearer flg_admin_token" \
-H "Content-Type: application/json" \
-d '{
"name": "CI Flag Sync (write)",
"tokenType": "jwt",
"permissions": { "read": true, "write": true, "delete": false }
}'Terraform Provider
A Terraform provider for Flaggr is planned. In the meantime, use the REST API with the local-exec provisioner or the scripts above.
# Planned — not yet available
resource "flaggr_flag" "checkout_v2" {
key = "checkout-v2"
name = "Checkout V2"
type = "boolean"
enabled = false
default_value = false
service_id = flaggr_service.web_app.id
environment = "production"
}Related
- Import/Export — Export and import flag configurations
- API Tokens — Token types, scopes, and management
- Flag Lifecycle — Planning and retiring flags
- Versioning — Track changes and roll back
- REST API Reference — Complete endpoint reference