← Back to list

Build a Feature Flag Service with AWS CDK, Lambda, and DynamoDB (Like LaunchDarkly)

A hands-on build of a serverless feature flag service on DynamoDB, Lambda, and CDK — deterministic rollouts, user targeting, a real kill…

Hoang Dinh in ITNEXT · 2026-07-16 01:14 · 57 claps · 15.7 min read paywalled
#aws #feature-flags #serverless #dynamodb #typescript
Open on Medium ↗
Wiki topics: 🌐 · Web Development ☁️ · DevOps & Cloud

Build a Feature Flag Service with AWS CDK, Lambda, and DynamoDB (Like LaunchDarkly)

A hands-on build of a serverless feature flag service on DynamoDB, Lambda, and CDK — deterministic rollouts, user targeting, a real kill switch, and the trade-offs (caching vs. propagation, auth) nobody mentions.

Navigating gradual rollouts

Navigating gradual rollouts

The worst deploy of my career wasn’t the one that broke production. It was the one I couldn’t turn off.

We’d shipped a rewrite of the checkout page behind what I thought was a safe rollout. It wasn’t safe. Conversion started sliding around 9pm, and the only lever I had was a full rollback — rebuild the previous artifact, push it through the pipeline, wait for it to bake. Twenty-odd minutes of watching money leak while a deploy crawled through staging gates that existed for exactly the situation I was no longer in.

That night taught me the thing every feature flag article buries under diagrams: deploying code and releasing a feature are two different decisions, and if you’ve welded them together, you’ve given up your fastest way to stop the bleeding.

A feature flag pulls them apart. The code goes out cold. You turn it on when you’re ready, to whoever you want, and — this is the part I paid twenty minutes to learn — you turn it off in one request when it goes wrong.

You don’t need LaunchDarkly to get that. You need one DynamoDB table, two Lambda functions, and about an afternoon. Here’s the whole thing, built with TypeScript and AWS CDK, deployable with cdk deploy, and honest about where it stops being enough.

The finished repo is linked at the end.

What a flag actually buys you

Three jobs come up over and over, and they’re worth naming because they pull the design in slightly different directions.

The kill switch. Something’s on fire. You flip one field and the feature is gone — no pipeline, no rollback. This one has to be fast and it has to be dead simple, because you’re using it at 9pm with your heart rate up.

The canary. You don’t go from 0 to 100. You go 5% -> 20% -> 50% -> 100%, watching error rates at each step, ready to stop. The hard requirement here is stickiness: a user who’s in the 5% has to stay in it on every request, or you’re not running a canary, you’re running a slot machine.

The A/B test. Two cohorts, two experiences, and a number at the end telling you which won. Same stickiness requirement, plus a way to target specific groups rather than a random slice.

Everything below serves those three. When I make a design call, it’s usually one of them casting the deciding vote.

The shape of it

Two APIs, one table.

APIs Flow

APIs Flow

The admin API is how flags get created and changed. It’s low traffic — a handful of humans and CI jobs — and it’s dangerous, because it can turn features on and off in production. So it’s locked behind IAM auth.

The evaluation API is what your application calls on every request to ask “is this flag on for this user?” It’s high traffic and latency-sensitive, so it’s public, read-only, and cached.

One decision worth explaining before we write anything: I’m using two Lambda functions, not five. The original sketch I started from had a separate function per operation — createFlag, updateFlag, deleteFlag, getFlags, evaluateFlag. That’s a lot of near-identical deployment units for a CRUD API nobody hammers. I’d rather fold all the admin operations into one function that routes internally, and keep evaluation on its own — because evaluation has a genuinely different profile: different traffic, different permissions (it only ever reads), and its own cache. Split things when they differ. These two do.

The data model: one table, no sort key

Each flag is one item, keyed by its name:

{
  "flagName": "new-dashboard",
  "enabled": true,
  "rollout": 25,
  "rules": [{ "attribute": "country", "value": "JP" }]
}

enabled is the kill switch. rollout is the percentage (0–100). rules is the targeting list. That's the entire schema.

No sort key, because every read is “give me the flag named X” — there’s no second dimension to query on. The day you add environments (dev/staging/prod) or versioning, a sort key like ENV#PROD earns its place. Today it'd just be a key with one possible value, which is a key that isn't doing anything.

One table, because DynamoDB bills on throughput and storage, not table count. A single table for a low-traffic resource like flags is the cheap, boring choice, and cheap and boring is what you want holding your kill switch.

Here’s the CDK for it — we’ll add the Lambdas and APIs to this same stack as we go:

// lib/feature-flag-stack.ts
const table = new Table(this, "FlagsTable", {
  partitionKey: { name: "flagName", type: AttributeType.STRING },
  billingMode: BillingMode.PAY_PER_REQUEST, // spiky, low-volume traffic
  encryption: TableEncryption.AWS_MANAGED,
  removalPolicy: RemovalPolicy.DESTROY, // demo only; RETAIN in production
});

PAY_PER_REQUEST (on-demand) fits because flag traffic is unpredictable and low — you don't want to provision and pay for fixed capacity you'll rarely touch. And removalPolicy: DESTROY means cdk destroy cleans up the table; flip it to RETAIN before this holds anything you'd miss.

The project

feature-flag-service/
├── bin/app.ts                  # CDK entry point
├── lib/feature-flag-stack.ts   # table, Lambdas, APIs, IAM
├── src/
│   ├── flag.ts                 # types
│   ├── rollout.ts              # deterministic bucketing (pure)
│   ├── targeting.ts            # rule matching (pure)
│   ├── repository.ts           # DynamoDB access
│   ├── http.ts                 # JSON response helper
│   └── handlers/
│       ├── admin.ts            # CRUD router + validation
│       └── evaluate.ts         # cached evaluation
└── test/                       # unit tests for the pure logic

The split I care about most: the two files that decide whether a flag is on for a user — rollout.ts and targeting.ts — are pure functions. No AWS, no I/O, no clock. That's deliberate, because that logic is the part most likely to have a subtle bug, and I want to test it in milliseconds with plain inputs instead of a mocked DynamoDB client.

The rollout, and the bug everyone writes first

This is the heart of the service, and it’s where I’ve seen the most otherwise-good engineers ship something broken.

The broken version looks reasonable:

if (Math.random() < 0.2) return true; // 20% rollout — DON'T

Run that and you do get roughly 20% of requests returning true. But a single user gets a different answer every single call. They load the page, the new dashboard is there. They refresh, it’s gone. That’s not a rollout — it’s a flicker, and it makes your canary metrics meaningless because no user has a consistent experience to measure.

What you actually want is for the user to be in or out, consistently, until you move the percentage. The trick is to stop rolling dice and start hashing:

userId + flagName  →  SHA-256  →  a number  →  bucket 0–99

Same input, same hash, same bucket, forever. No database of assignments, no state to keep in sync. The hash function is the assignment.

// src/rollout.ts
import { createHash } from "node:crypto";

export function isInRollout(
  userId: string,
  flagName: string,
  rolloutPercentage: number,
): boolean {
  if (rolloutPercentage <= 0) return false;
  if (rolloutPercentage >= 100) return true;

  const digest = createHash("sha256").update(`${userId}:${flagName}`).digest();
  const bucket = digest.readUInt32BE(0) % 100;

  return bucket < rolloutPercentage;
}

A few things in here are load-bearing:

  • We hash userId:flagName, not just userId. If you hash only the user, then whoever lands in the first 20% of one flag lands in the first 20% of every flag. Your rollouts become correlated — the same unlucky users get every experiment — and your A/B tests quietly contaminate each other. Mixing the flag name in re-shuffles the deck per flag.
  • We read four bytes of the digest as a 32-bit integer (readUInt32BE(0)) instead of the old parseInt(hash.substring(0,8), 16) dance. Same result, less ceremony, and it's obvious what it does.
  • **node:crypto, not crypto.** The prefixed specifier is the unambiguous way to import a Node builtin in an ES module.

And the property that makes this genuinely nice to operate: raising the percentage never kicks anyone out. Go from 20% to 50% and everyone in buckets 0–19 is still under 50 — they stay in, and buckets 20–49 join them. You’re widening the door, not reshuffling the room. (Lowering it does drop people, which is exactly what you’d want a rollback to do.)

What to watch: the bucket is only as stable as the string you hash. If you ever change the salt — rename the flag, tweak the separator — every user gets re-bucketed. Treat userId:flagName as a contract, not an implementation detail.

I don’t take stickiness on faith, so the test suite pins it down: same user is stable across 50 calls, raising the percentage never drops anyone, the spread across 20,000 users lands within a whisker of the target percentage, and the same user is bucketed independently across two flags. Those are pure-function tests — no mocks, no AWS, they run in well under a second.

Targeting: rules beat the dice

Percentages aren’t always what you want. Sometimes it’s “every Japanese user” or “all admins,” full stop, regardless of any roll.

// src/targeting.ts
import type { TargetingRule } from "./flag.js";

export function matchesRules(
  rules: TargetingRule[],
  context: Record<string, string>,
): boolean {
  return rules.some((rule) => context[rule.attribute] === rule.value);
}

Any single matching rule is enough — OR semantics. And the evaluator checks rules before it checks the rollout, so a rule match is a hard “on” that ignores the percentage entirely. Think of it as “always on for this group, whatever the rest of the population is rolling through.”

It’s a deliberately dumb engine — exact string equality, no operators, no nesting. That covers a surprising amount of real targeting, and when it stops being enough you’ll know precisely what to add (ranges, in lists, negation) instead of guessing up front.

The evaluator, and an honest word about caching

Now the endpoint your app hits on every request. Naively, it reads DynamoDB, applies the rules, applies the rollout, done. That works — but it puts a network round trip in front of every flag check, and flags don’t change second to second. That’s latency you’re paying for nothing.

The fix leans on a Lambda quirk: AWS keeps an execution environment warm between invocations, so anything you declare outside the handler survives across calls on that environment. A Map up there becomes a per-instance cache for free.

// src/handlers/evaluate.ts
const CACHE_TTL_MS = Number(process.env.CACHE_TTL_MS ?? 10_000);

// Survives across warm invocations of THIS execution environment.
const cache = new Map<string, { flag: Flag | undefined; expiresAt: number }>();

async function loadFlag(flagName: string, now: number): Promise<Flag | undefined> {
  const hit = cache.get(flagName);
  if (hit && hit.expiresAt > now) return hit.flag;

  const flag = await getFlag(flagName);
  cache.set(flagName, { flag, expiresAt: now + CACHE_TTL_MS });
  return flag;
}

export const handler = async (event: APIGatewayProxyEventV2) => {
  const q = event.queryStringParameters ?? {};
  const flagName = q.flag;
  const userId = q.userId;

  if (!flagName || !userId) {
    return json(400, { error: "query params 'flag' and 'userId' are required" });
  }

  let context: Record<string, string> = {};
  if (q.context) {
    try {
      context = JSON.parse(q.context) as Record<string, string>;
    } catch {
      return json(400, { error: "'context' must be URL-encoded JSON" });
    }
  }

  const flag = await loadFlag(flagName, Date.now());

  if (!flag || !flag.enabled) return json(200, { enabled: false, reason: "disabled" });

  if (flag.rules.length > 0 && matchesRules(flag.rules, context)) {
    return json(200, { enabled: true, reason: "rule" });
  }

  const enabled = isInRollout(userId, flagName, flag.rollout);
  return json(200, { enabled, reason: enabled ? "rollout" : "rollout-excluded" });
};

The decision, in order:

The decision, in order

The decision, in order

I return a reason alongside enabled on purpose. The first question anyone asks about a flag is "why did it evaluate that way for this user?" and answering it from a log line beats reproducing it by hand.

Now the part most tutorials skate past. A cache and a kill switch are in tension, and you have to pick a number that admits it.

I opened this whole thing bragging that a flag turns off in “one request.” With a cache, that’s not quite true — it turns off within CACHE_TTL_MS of the request, because warm environments keep serving the old value until their entry expires. At the 10-second default, your kill switch has up to a 10-second tail. There's no clever way around it; it's a straight trade between read cost and how stale you're willing to be.

So treat the TTL as a real dial, not a default to ignore:

  • For a genuine emergency-stop flag, drop it low — a second or two, or zero to bypass the cache entirely and eat the DynamoDB read on every call.
  • For a slow rollout you’re nudging over days, ten seconds (or sixty) is fine and saves you a mountain of reads.

The cache is per-environment, not shared — each warm instance keeps its own copy and cold starts begin empty. That’s fine: API Gateway spreads traffic across the warm pool, so you still shed most of your DynamoDB reads without running a single extra piece of infrastructure. You just have to say out loud what “fast” means for your kill switch, and set the number to match.

A note on consistency: the evaluator uses an eventually-consistent read — cheaper, and we already tolerate staleness because of the cache. That’s the opposite call you’d make for something like a rate-limiter counter, where a stale read causes over-counting. Consistency isn’t free or universal; you buy exactly as much as the feature needs.

The admin API, where the sharp edges live

The evaluator is the fun part. The admin API is where the bugs that bite you in production actually live, so it gets the most care.

It’s one Lambda that routes on method and path:

// src/handlers/admin.ts (routing)
export const handler = async (event: APIGatewayProxyEventV2) => {
  const method = event.requestContext.http.method;
  const name = event.pathParameters?.name;

  try {
    if (method === "POST" && !name) return await create(event);
    if (method === "GET" && !name) return json(200, await listFlags());
    if (method === "GET" && name) return await getOne(name);
    if (method === "PUT" && name) return await update(name, event);
    if (method === "DELETE" && name) return await remove(name);
    return json(405, { error: `${method} not allowed on this path` });
  } catch (err) {
    if (err instanceof BadRequest) return json(400, { error: err.message });
    if (err instanceof FlagAlreadyExistsError) return json(409, { error: err.message });
    if (err instanceof FlagNotFoundError) return json(404, { error: err.message });
    console.error("admin handler error", err);
    return json(500, { error: "Internal error" });
  }
};

The repository throws domain errors — FlagAlreadyExistsError, FlagNotFoundError — and the handler maps them to status codes. The handler never sees a ConditionalCheckFailedException; that stays behind the repository boundary where it belongs.

Two footguns worth calling out, because the obvious implementation gets both wrong.

A partial update must not wipe the fields you didn’t send. The naive update writes every field with a default: SET enabled = :enabled, rollout = :rollout, rules = :rules, filling missing values with false, 0, []. So a PUT that means "just bump the rollout to 50" also quietly sets enabled to false — you've turned the feature off while trying to widen it. That's a self-inflicted outage hiding in a helper. The fix is to build the update expression from only the fields the caller actually sent:

// src/repository.ts (update)
export async function updateFlag(flagName, patch) {
  const names: Record<string, string> = {};
  const values: Record<string, unknown> = {};
  const sets: string[] = [];

  for (const [key, value] of Object.entries(patch)) {
    if (value === undefined) continue;
    names[`#${key}`] = key;          // alias to dodge reserved words
    values[`:${key}`] = value;
    sets.push(`#${key} = :${key}`);
  }

  const { Attributes } = await doc.send(new UpdateCommand({
    TableName: TABLE_NAME,
    Key: { flagName },
    UpdateExpression: `SET ${sets.join(", ")}`,
    ExpressionAttributeNames: names,
    ExpressionAttributeValues: values,
    ConditionExpression: "attribute_exists(flagName)", // 404 if it's not there
    ReturnValues: "ALL_NEW",
  }));
  return Attributes as Flag;
}

Note the #key aliasing. DynamoDB has a long list of reserved words, and referring to attribute names directly in an expression risks a ValidationException the day you add a field that collides with one. Aliasing every name sidesteps the whole class of problem — it's cheap insurance you'll never think about again. And attribute_exists(flagName) turns "update a flag that isn't there" into a clean 404 instead of silently creating a half-formed item.

**POST should create, not clobber.** A plain PutItem overwrites whatever's there, so re-posting a name blows away the existing flag. One condition fixes it:

// src/repository.ts (create)
await doc.send(new PutCommand({
  TableName: TABLE_NAME,
  Item: flag,
  ConditionExpression: "attribute_not_exists(flagName)", // 409 if taken
}));

Now to the thing the original design left wide open: auth. An admin API with no authentication is a public button that turns your production features on and off. If it’s reachable, it’s exploitable. This one is locked behind IAM authorization at the gateway — callers sign requests with SigV4, and anything unsigned is rejected before it reaches the Lambda.

Security: IAM auth is the right default for a machine-facing admin API — your CI role or an internal tool assumes a role and signs its calls, and you lean on IAM you already trust instead of inventing a token scheme. If a human admin UI needs in, put Cognito (a JWT authorizer) in front instead; for anything more custom, a Lambda authorizer. What you must not do is ship it open “just for now.” “For now” is how it’s still open a year later.

The infrastructure

The stack wires up the table (above), two Lambdas, and two HTTP APIs. Here’s the Lambda and API half:

// lib/feature-flag-stack.ts (continued)
const bundling: BundlingOptions = {
  format: OutputFormat.ESM,
  target: "node22",
  minify: true,
  externalModules: ["@aws-sdk/*"], // use the SDK from the runtime; don't bundle it
};

const adminFn = new NodejsFunction(this, "AdminFn", {
  entry: "src/handlers/admin.ts",
  handler: "handler",
  runtime: Runtime.NODEJS_22_X,
  architecture: Architecture.ARM_64,
  memorySize: 256,
  timeout: Duration.seconds(10),
  environment: { TABLE_NAME: table.tableName },
  bundling,
});

const evaluateFn = new NodejsFunction(this, "EvaluateFn", {
  entry: "src/handlers/evaluate.ts",
  handler: "handler",
  runtime: Runtime.NODEJS_22_X,
  architecture: Architecture.ARM_64,
  memorySize: 256,
  timeout: Duration.seconds(10),
  environment: { TABLE_NAME: table.tableName, CACHE_TTL_MS: "10000" },
  bundling,
});

// Least privilege: admin does full CRUD, evaluation only ever reads.
table.grant(adminFn, "dynamodb:GetItem", "dynamodb:PutItem",
  "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:Scan");
table.grant(evaluateFn, "dynamodb:GetItem");

// Admin API — every route requires IAM auth.
const adminApi = new HttpApi(this, "AdminApi", {
  apiName: "feature-flags-admin",
  defaultAuthorizer: new HttpIamAuthorizer(),
});
const adminIntegration = new HttpLambdaIntegration("AdminIntegration", adminFn);
adminApi.addRoutes({ path: "/flags", methods: [HttpMethod.POST, HttpMethod.GET], integration: adminIntegration });
adminApi.addRoutes({ path: "/flags/{name}", methods: [HttpMethod.GET, HttpMethod.PUT, HttpMethod.DELETE], integration: adminIntegration });

// Evaluation API — public.
const evalApi = new HttpApi(this, "EvaluationApi", { apiName: "feature-flags-eval" });
evalApi.addRoutes({ path: "/evaluate", methods: [HttpMethod.GET],
  integration: new HttpLambdaIntegration("EvalIntegration", evaluateFn) });

The choices that matter:

  • **NodejsFunction runs esbuild to bundle and transpile your TypeScript — no separate build step. `ARM_64`** (Graviton) is cheaper and usually faster for I/O-bound work like this; there's little reason to pick x86 for a new Node function.
  • **externalModules: ["@aws-sdk/*"] is not optional here.** The Node 22 runtime already ships SDK v3. If you instead bundle it into an ESM output, the function crashes on cold start with Dynamic require of "node:https" is not supported — esbuild can't rewrite the SDK's internal require() calls inside an ES module. Leaving the SDK external gives you a ~3 KB bundle, a faster cold start, and no crash. I learned this one the hard way on a different service; it transfers directly.
  • Two grants, two blast radii. The admin role gets the five actions it uses; the evaluator gets GetItem and nothing else. If the public evaluation function is ever compromised, it can read one flag at a time and do absolutely nothing else — it can't write, can't delete, can't scan. That's the entire point of granting named actions instead of reaching for grantReadWriteData.
  • IAM auth is set once as the admin API’s default authorizer, so every current and future admin route inherits it. The evaluation API has no authorizer — public by design.

Deploy the whole thing:

$ npm install
$ npx cdk diff      # read the IAM and resources before they exist
$ npm run deploy

cdk diff is worth the ten seconds every time. It shows you the policies, the auth types, and the resources before anything is created — which is how you catch an over-broad grant or a missing authorizer while it's still just a diff.

Driving it

Deploy prints both URLs. The admin API wants SigV4-signed requests — curl has done that since 7.75. If you're on AWS SSO (or any temporary credentials), export them first and send the session token as a header. curl --aws-sigv4 signs with your key and secret but does not add the token on its own, and without it every call comes back Forbidden:

$ eval "$(aws configure export-credentials --profile <PROFILE-NAME> --format env)"
$ ADMIN=https://AAAA.execute-api.<region>.amazonaws.com
$ REGION=<region>

$ curl -X POST "$ADMIN/flags" \
  --aws-sigv4 "aws:amz:$REGION:execute-api" \
  --user "$AWS_ACCESS_KEY_ID:$AWS_SECRET_ACCESS_KEY" \
  -H "x-amz-security-token: $AWS_SESSION_TOKEN" \
  -H "content-type: application/json" \
  -d '{"flagName":"new-dashboard","enabled":true,"rollout":20}'

(Permanent IAM user keys have no session token, so you can drop that header. Everyone on SSO needs it — or use awscurl, which adds it for you.)

The evaluation API is public:

EVAL=https://BBBB.execute-api.<region>.amazonaws.com/evaluate

$ curl "$EVAL?flag=new-dashboard&userId=12345"
# {"enabled":true,"reason":"rollout"}   (or rollout-excluded)

Call it again with the same userId a dozen times. Same answer every time — that's the deterministic hashing doing its job. Now widen the rollout with a partial update and watch that nobody who was already in drops out:

$ curl -X PUT "$ADMIN/flags/new-dashboard" \
  --aws-sigv4 "aws:amz:$REGION:execute-api" \
  --user "$AWS_ACCESS_KEY_ID:$AWS_SECRET_ACCESS_KEY" \
  -H "x-amz-security-token: $AWS_SESSION_TOKEN" \
  -H "content-type: application/json" \
  -d '{"rollout":50}'

Because that’s a partial update, enabled and rules are untouched — the exact bug we designed out earlier.

Where this stops and LaunchDarkly keeps going

What you’ve built is the real core: kill switch, sticky percentage rollout, targeting, two clean APIs, sane auth. A commercial platform is mostly this plus operational polish you can add when you feel the need for it:

  • Scheduled changes — “go to 50% Monday at 9am” — is a rollout value flipped by an EventBridge rule instead of a human.
  • An audit log — who changed what, when — is one more DynamoDB write on every admin mutation. Once more than one person can touch production flags, you’ll want this fast.
  • SDKs instead of raw HTTP hand your app a client that caches, retries, and falls back to a last-known value when the network hiccups, instead of a bare fetch per check.
  • Streaming updates push changes to clients over a live connection the moment they happen, collapsing that cache-TTL tail to near zero.
  • Environments let one flag hold different values in dev, staging, and prod — and that’s where the sort key we skipped finally earns its keep.

None of these change the shape of what’s here. They’re the same table and the same two functions, extended.

The one thing to take away

Strip away the DynamoDB and the CDK and the hashing, and a feature flag is a promise you make to your future self at 9pm: whatever we shipped, I can turn it off without a deploy. Everything in this build exists to keep that promise cheap and boring — a kill switch that’s just a boolean, a rollout that’s just a hash, an admin API that’s locked down so the promise can’t be turned against you.

Build it before you need it. The night you need it is a bad night to start.

Repository: the full project — CDK stack, both Lambdas, the rollout and targeting logic, and the tests — is ready to clone, cdk deploy, and extend.

https://github.com/codetheworld-io/feature-flag-service


메타데이터
post_id
94044fc3fb1a
slug
build-a-feature-flag-service-with-aws-cdk-lambda-and-dynamodb-like-launchdarkly-94044fc3fb1a
url
https://itnext.io/build-a-feature-flag-service-with-aws-cdk-lambda-and-dynamodb-like-launchdarkly-94044fc3fb1a
canonical_url
https://itnext.io/build-a-feature-flag-service-with-aws-cdk-lambda-and-dynamodb-like-launchdarkly-94044fc3fb1a
author_url
https://medium.com/@hoangdv
status
ok
fetched_at
2026-07-17 12:05:36