Flaggly is a lightweight, self-hosted feature flag service running on Cloudflare Workers. Deploy your own worker in minutes with boolean flags, payload flags, A/B testing, and progressive rollouts.
Docs - flaggly.dev
The worker uses the following service bindings to function.
FLAGGLY_KV- Cloudflare Workers KV - The main database for storing flags.JWT_SECRET- Secret - The secret for to sign and verify keys for the API.ORIGIN- Environment variable - Allowed CORS origin or list of origins which can use the service. Use a comma separated list to allow multiple origins.
The quickest way to get a worker up and running is by using the automatic GitHub integration with Cloudflare Workers. This is the recommended way.
The automatic deployment will essentially do the following:
- Clone the repository in your Github account.
- Use that to build a project.
- You can configure the variables, secrets and the project name in the setup. Keep note of the
JWT_SECRET. You will need it later to generate the JWT tokens.
You need to install the following:
- pnpm - https://pnpm.io/installation
- wrangler - https://developers.cloudflare.com/workers/wrangler/install-and-update/
- node - https://nodejs.org/en/download
Then you can manually deploy your project without connecting it to GitHub.
- Clone the repository
git clone https://github.com/butttons/flaggly- Login with wrangler
cd flaggly
npx wrangler login- Setup the KV namespace. You will need to remove the default entry in the
wrangler.jsonbefore you can create this binding with the same name. You can safely remove the entirekv_namespacesfield. Then use the following command to create a KV store or use the dashboard to create one.
npx wrangler kv namespace create FLAGGLY_KVThe command should prompt you to add the configuration to the wrangler.json. In case you've created the KV store using the dashboard, copy the ID of the KV store from the dashboard and add the following in wrangler.json:
// ...
"kv_namespaces": [
{
"binding": "FLAGGLY_KV",
"id": "[KV_STORE_ID]"
}
]
// ...-
Setup the
ORIGINvariable - Update thevars.ORIGINvalue in thewrangler.json -
Deploy to Cloudflare
pnpm run deploy- Set the
JWT_SECRETvia CLI or (with the dashboard)[https://developers.cloudflare.com/workers/configuration/secrets/#via-the-dashboard].
npx wrangler secret put JWT_SECRETYou can update your Flaggly worker by pulling the latest changes from the upstream repository. Your wrangler.jsonc configuration will be preserved during the update.
Note: This will discard any local changes except
wrangler.jsonc. Back up any custom modifications before updating.
./update.sh- Add the upstream remote (first time only)
git remote add flaggly https://github.com/butttons/flaggly.git- Backup config, fetch and merge upstream
cp wrangler.jsonc wrangler.jsonc.bak
git fetch flaggly
git merge -X theirs flaggly/main -m "Update from upstream"- Restore your config
cp wrangler.jsonc.bak wrangler.jsonc
rm wrangler.jsonc.bak- Push and deploy
git pushYou can interact with your worker once it's deployed. Before proceeding, you will need the following:
- URL of the worker. You can find this in the
Settingstab of your worker, underDomains & Routes. Here you can also add a custom domain and disable the default worker domain entirely. - The JWT keys for the API. You can generate the keys by using the
/__generateendpoint. By default, it will generate a token with a 6 month expiry. You can create your own longer one at jwt.io or pass in a valid date string asexpiresAtfield to set the expiry time of the tokens.
curl -X POST https://flaggly.[ACCOUNT].workers.dev/__generate \
-H "Content-Type: application/json" \
-d '{
"secret": "[JWT_SECRET]"
}'Response
{
"user": "JWT_STRING",
"admin": "JWT_STRING"
}All /admin/* requests require a Bearer token:
Authorization: Bearer ADMIN_JWTAdditional headers can be used to define the app and environment:
X-App-Id: default # defaults to "default"
X-Env-Id: production # defaults to "production"Use these to manage flags across different apps and environments:
# Manage staging environment
curl https://flaggly.[ACCOUNT].workers.dev/admin/flags \
-H "Authorization: Bearer ADMIN_JWT" \
-H "X-Env-Id: staging"
# Manage different app
curl https://flaggly.[ACCOUNT].workers.dev/admin/flags \
-H "Authorization: Bearer ADMIN_JWT" \
-H "X-App-Id: mobile-app" \
-H "X-Env-Id: production"Now you can interact with the API easily:
Get all data
curl https://flaggly.[ACCOUNT].workers.dev/admin/flags \
-H "Authorization: Bearer {SERVICE_KEY}"Response
{
"flags": {
"new-checkout": { ... },
"dark-mode": { ... }
},
"segments": {
"beta-users": "'@company.com' in user.email",
"premium": "user.tier == 'premium'"
}
}Create / update flag: Boolean flag:
curl -X PUT https://flaggly.[ACCOUNT].workers.dev/admin/flags \
-H "Authorization: Bearer ADMIN_JWT" \
-H "Content-Type: application/json" \
-d '{
"id": "new-checkout",
"type": "boolean",
"enabled": true,
"label": "New Checkout Flow",
"description": "Redesigned checkout experience"
}'Variant flag: (A/B test):
curl -X PUT https://flaggly.[ACCOUNT].workers.dev/admin/flags \
-H "Authorization: Bearer ADMIN_JWT" \
-H "Content-Type: application/json" \
-d '{
"id": "button-color",
"type": "variant",
"enabled": true,
"variations": [
{ "id": "control", "label": "Blue", "weight": 50, "payload": "#0000FF" },
{ "id": "treatment", "label": "Green", "weight": 50, "payload": "#00FF00" }
]
}'Payload flag:
curl -X PUT https://flaggly.[ACCOUNT].workers.dev/admin/flags \
-H "Authorization: Bearer ADMIN_JWT" \
-H "Content-Type: application/json" \
-d '{
"id": "config",
"type": "payload",
"enabled": true,
"payload": {
"apiUrl": "https://api.example.com",
"timeout": 5000
}
}'Update a flag:
curl -X PATCH https://flaggly.[ACCOUNT].workers.dev/admin/flags \
-H "Authorization: Bearer ADMIN_JWT" \
-H "Content-Type: application/json" \
-d '{
"enabled": false,
"description": "Temporarily disabled"
}'Delete a flag:
curl -X DELETE https://flaggly.[ACCOUNT].workers.dev/admin/flags/[FLAG_ID] \
-H "Authorization: Bearer ADMIN_JWT"Create / update a segment:
curl -X PUT https://flaggly.[ACCOUNT].workers.dev/admin/segments \
-H "Authorization: Bearer ADMIN_JWT" \
-H "Content-Type: application/json" \
-d '{
"id": "team-users",
"rule": "'\''@company.com'\'' in user.email"
}'Delete a segment:
curl -X DELETE https://flaggly.[ACCOUNT].workers.dev/admin/segments/[SEGMENT_ID] \
-H "Authorization: Bearer ADMIN_JWT"Sync all flags and segments between environments.
curl -X POST https://flaggly.[ACCOUNT].workers.dev/admin/sync \
-H "Authorization: Bearer ADMIN_JWT" \
-H "Content-Type: application/json" \
-d '{
"targetEnv": "development",
"sourceEnv": "production",
"overwrite": true,
}'Sync a single flags and all its between environments.
curl -X POST https://flaggly.[ACCOUNT].workers.dev/admin/sync/[FLAG_ID] \
-H "Authorization: Bearer ADMIN_JWT" \
-H "Content-Type: application/json" \
-d '{
"targetEnv": "development",
"sourceEnv": "production",
"overwrite": true,
}'Once you have your flags ready for use, you can install the client side SDK to evaluate them. This guide assumes this is being set up in the front-end. Server side evaluations are handled a little differently when working with cloudflare workers.
pnpm i @flaggly/sdk
The SDK uses nanostores to manage the flags' state.
Setup the client:
// src/lib/flaggly.ts
import { FlagglyClient } from '@flaggly/sdk';
type Flags = {
'new-checkout': { type: 'boolean' };
'button-color': { type: 'variant'; result: string };
config: { type: 'payload'; result: { apiUrl: string; timeout: number } };
};
export const flaggly = new FlagglyClient<Flags>({
url: 'BASE_URL',
apiKey: 'USER_JWT',
});
// Evaluation
const isNewCheckout = flaggly.getBooleanFlag('new-checkout');
const buttonColor = flaggly.getVariant('button-color');
const config = flaggly.getPayloadFlag('config')For react:
// src/lib/flaggly.ts
import { FlagValueResult, FlagglyClient } from '@flaggly/sdk';
import { useSyncExternalStore } from 'react';
type Flags = {
'new-checkout': { type: 'boolean' };
'button-color': { type: 'variant'; result: string };
config: { type: 'payload'; result: { apiUrl: string; timeout: number } };
};
export const flaggly = new FlagglyClient<Flags>({
url: 'BASE_URL',
apiKey: 'USER_JWT',
lazy: true,
bootstrap: {
'new-checkout': false,
'button-color': '#00FF00'
}
});
// Called once you have the user info
// flaggly.identify(userId: string, user: unknown);
export const useFlags = () => useSyncExternalStore(flaggly.store.subscribe, flaggly.store.get, flaggly.store.get);
export const useFlag = <K extends keyof Flags>(key: K): FlagValue<Flags[K]> => {
const data = useFlags();
return data?.[key].result as FlagValue<Flags[K]>;
};
// Component usage
const isNewCheckout = useFlag('new-checkout');Identifying a user once they log in:
flaggly.identify(userId: string, user: unknown);This will re-evaluate the flags again and reset the state.
You can disable the flag evaluation on load by passing lazy: false to the constructor.
You can use the same SDK in backend code too, but if you're using workers you must use a service binding to first attach your flaggly worker to your worker and then interact with it.
In your worker's wrangler.jsonc:
// ...
"services": [
{
"binding": "FLAGGLY_SERVICE",
"service": "flaggly"
}
]
//...Now you can pass in the fetch from the worker, in the SDK:
// src/lib/flaggly.ts
import { FlagglyClient } from '@flaggly/sdk';
type Flags = {
//
};
export const createFlaggly = (env: Env) => new FlagglyClient<Flags>({
url: 'BASE_URL',
apiKey: 'USER_JWT',
lazy: true,
workerFetch: (url, init) => env.FLAGGLY_SERVICE.fetch(url, init)
});// src/index.ts
const flaggly = createFlaggly(env);Instantly disable a feature without redeploying:
{
"id": "payments-enabled",
"type": "boolean",
"enabled": true,
"label": "Payment Processing",
"description": "Master switch for payment processing"
}if (!flaggly.getBooleanFlag('payments-enabled')) {
return showMaintenanceMessage();
}Release a feature to 20% of users, then gradually increase:
{
"id": "new-dashboard",
"type": "boolean",
"enabled": true,
"rollout": 20,
"label": "New Dashboard",
"description": "Redesigned dashboard UI"
}Test different button colors with weighted distribution:
{
"id": "cta-button-color",
"type": "variant",
"enabled": true,
"variations": [
{ "id": "control", "label": "Blue (Control)", "weight": 50, "payload": "#0066CC" },
{ "id": "green", "label": "Green", "weight": 25, "payload": "#00AA44" },
{ "id": "orange", "label": "Orange", "weight": 25, "payload": "#FF6600" }
]
}const buttonColor = flaggly.getVariant('cta-button-color');
// User always sees the same color based on their IDStore dynamic configuration without code changes:
{
"id": "api-config",
"type": "payload",
"enabled": true,
"payload": {
"timeout": 5000,
"retries": 3,
"baseUrl": "https://api.example.com/v2"
}
}Release a feature at a specific time:
{
"id": "black-friday-sale",
"type": "boolean",
"enabled": true,
"rollouts": [
{ "start": "2024-11-29T00:00:00Z", "percentage": 100 }
]
}Roll out to internal users first, then beta users, then everyone:
{
"id": "new-editor",
"type": "boolean",
"enabled": true,
"segments": ["internal-users", "beta-users"],
"rollouts": [
{ "start": "2024-01-01T00:00:00Z", "segment": "internal-users" },
{ "start": "2024-01-15T00:00:00Z", "segment": "beta-users" },
{ "start": "2024-02-01T00:00:00Z", "percentage": 100 }
]
}Segments are reusable JEXL expressions that define user groups.
Target users with a specific email domain:
{
"id": "internal-users",
"rule": "'@company.com' in user.email"
}Target users on paid plans:
{
"id": "premium-users",
"rule": "user.tier == 'premium' || user.tier == 'enterprise'"
}Target users in specific regions (requires geo data from Cloudflare):
{
"id": "eu-users",
"rule": "geo.country in ['DE', 'FR', 'IT', 'ES', 'NL']"
}Target users who opted into beta features:
{
"id": "beta-users",
"rule": "user.betaOptIn == true"
}Target users based on multiple conditions:
{
"id": "high-value",
"rule": "user.totalSpend > 1000 && user.accountAge > 90"
}Flaggly uses two types of JWT tokens:
- User token (
flaggly.user) - For client-side SDK, can only evaluate flags - Admin token (
flaggly.admin) - For admin API, can create/update/delete flags
Both tokens are signed with your JWT_SECRET and have an expiration date.
If your tokens are compromised or expired, generate new ones:
curl -X POST https://flaggly.[ACCOUNT].workers.dev/__generate \
-H "Content-Type: application/json" \
-d '{
"secret": "[YOUR_JWT_SECRET]",
"expireAt": "2025-12-31T23:59:59Z"
}'This returns new user and admin tokens. Update your applications with the new tokens.
If your JWT_SECRET is compromised, you must rotate it:
- Generate a new secret (minimum 32 characters):
openssl rand -base64 32- Update the secret in Cloudflare:
npx wrangler secret put JWT_SECRET
# Enter your new secret when promptedOr update via the Cloudflare dashboard.
- Generate new tokens with the new secret:
curl -X POST https://flaggly.[ACCOUNT].workers.dev/__generate \
-H "Content-Type: application/json" \
-d '{
"secret": "[NEW_JWT_SECRET]"
}'- Update all applications with the new tokens.
Note: Rotating the secret immediately invalidates ALL existing tokens. Plan for a brief service interruption or coordinate the update across your applications.
Flaggly runs as a single Cloudflare Worker with these components:
- KV Storage - All flags and segments for an app/environment are stored as a single JSON entry in Cloudflare KV. Key format:
v1:{appId}:{envId} - Evaluation Engine - Uses JEXL for rule expressions with custom transforms (
split,lower,upper) and functions (ts(),now()) - Deterministic Hashing - FNV-1a 32-bit hash ensures consistent flag evaluations across requests
The id field passed during evaluation is critical for consistent user experiences:
flaggly.identify(userId, { email: user.email, tier: user.tier });This id is combined with the flag key to create a deterministic hash:
- Percentage rollouts: A user with
id: "user-123"will always be in the same rollout bucket for a given flag - A/B test variants: The same user always sees the same variant, ensuring consistent experiences
- Cross-session consistency: Even without cookies, the same
idproduces the same results
For anonymous users, generate a stable ID (e.g., fingerprint or localStorage UUID) to maintain consistency.
All flags are stored in a single KV entry per app/environment in this shape:
type AppData = {
flags: Record<string, FeatureFlag>;
segments: Record<string, string>;
};
type FeatureFlag = {
id: string;
segments: string[];
enabled: boolean;
rules: string[];
rollout: number;
rollouts: {
start: string;
percentage?: number;
segment?: string;
}[];
label?: string;
description?: string;
isTrackable?: boolean
} & (
| {
type: "boolean";
}
| {
type: "payload";
payload: unknown;
}
| {
type: "variant";
variations: {
id: string;
label: string;
weight: number;
payload?: unknown;
}[];
}
);How flag evaluations work:
flowchart TD
A["Start Evaluation"] --> B["Is flag enabled?"]
B -->|"No"| Z["Return default result (isEval = false)"]
B -->|"Yes"| C["Do all rules pass?"]
C -->|"No"| Z
C -->|"Yes"| D{"Has rollout steps?"}
D -->|"Yes"| E["Evaluate rollout steps (time, segment, percentage)"]
E -->|"No match"| Z
E -->|"Match"| G["Flag passes rollout"]
D -->|"No"| F["Check global rollout percentage"]
F -->|"Not included"| Z
F -->|"Included"| G
G --> H{"Flag type"}
H -->|"Boolean"| I["Return true (isEval = true)"]
H -->|"Payload"| J["Return payload (isEval = true)"]
H -->|"Variant"| K["Choose variant deterministically → Return variant (isEval = true)"]
I --> L["End"]
J --> L
K --> L
Z --> L