Feature flags are the missing half of a safe serverless release process. Canary deploys shift traffic between code versions, but sometimes you want to ship code dark, turn a feature on for 5% of tenants, or kill a misbehaving integration without a redeploy. On Lambda, the cleanest AWS-native way to do this is AWS AppConfig read through the AppConfig Lambda extension. This tutorial wires it up with Serverless Framework V4 and shows the patterns we use on client engagements.
Why not environment variables or SSM?
- Environment variables change only with a deploy (a new function configuration). That is slow and not a kill switch.
- SSM Parameter Store works, but every cold container needs API calls, you must write your own caching, and there is no built-in validation or gradual rollout.
- AppConfig gives you JSON schema validation, deployment strategies (linear or exponential rollout), automatic rollback on a CloudWatch alarm, and a local cache via the extension, so your handler makes a call to
localhostrather than to the AWS API.
Architecture
- An AppConfig application, environment, and a feature flags configuration profile are created in your stack.
- The AppConfig extension runs as a Lambda layer and polls AppConfig in the background.
- Your handler reads the flags from
http://localhost:2772, which is served from the extension cache.
Step 1: Define the AppConfig resources
In serverless.yml:
service: flags-demo
frameworkVersion: '4'
provider:
name: aws
runtime: nodejs22.x
architecture: arm64
stage: ${opt:stage, 'dev'}
environment:
APPCONFIG_APP: !Ref FlagsApplication
APPCONFIG_ENV: !Ref FlagsEnvironment
APPCONFIG_PROFILE: !Ref FlagsProfile
AWS_APPCONFIG_EXTENSION_POLL_INTERVAL_SECONDS: '45'
iam:
role:
statements:
- Effect: Allow
Action:
- appconfig:StartConfigurationSession
- appconfig:GetLatestConfiguration
Resource: '*' # tighten to the profile ARN once deployed
functions:
api:
handler: src/api.handler
layers:
# Region- and architecture-specific. Look up the current ARN in the
# AWS docs ("AppConfig Lambda extension versions") for your region.
- ${param:appconfigLayerArn}
events:
- httpApi: 'GET /report'
resources:
Resources:
FlagsApplication:
Type: AWS::AppConfig::Application
Properties:
Name: ${self:service}-${sls:stage}
FlagsEnvironment:
Type: AWS::AppConfig::Environment
Properties:
ApplicationId: !Ref FlagsApplication
Name: ${sls:stage}
FlagsProfile:
Type: AWS::AppConfig::ConfigurationProfile
Properties:
ApplicationId: !Ref FlagsApplication
Name: feature-flags
LocationUri: hosted
Type: AWS.AppConfig.FeatureFlags
Store the layer ARN as a Serverless parameter (params: in serverless.yml, per stage or region) so you can bump the version in one place.
Step 2: Seed the flag data
Hosted feature-flag content is JSON in the AppConfig feature flag format. Create it as a AWS::AppConfig::HostedConfigurationVersion:
FlagsVersion:
Type: AWS::AppConfig::HostedConfigurationVersion
Properties:
ApplicationId: !Ref FlagsApplication
ConfigurationProfileId: !Ref FlagsProfile
ContentType: application/json
Content: |
{
"version": "1",
"flags": {
"new-report-engine": {
"name": "new-report-engine",
"attributes": { "maxRows": { "constraints": { "type": "number", "required": true } } }
}
},
"values": {
"new-report-engine": { "enabled": false, "maxRows": 1000 }
}
}
A practical caution: after the first deploy, manage flag values through AppConfig (console, CLI, or a separate pipeline), not by editing this template. If CloudFormation owns the content, every flag flip becomes a stack update, and you lose the whole point. Many teams seed the initial version with CloudFormation and then let a small flags repo own subsequent versions.
Step 3: Roll out with a deployment strategy
Creating a hosted version does not make it live. A deployment does. Define a strategy that rolls out gradually and bakes before finishing:
GradualStrategy:
Type: AWS::AppConfig::DeploymentStrategy
Properties:
Name: ${sls:stage}-linear-10-over-10m
DeploymentDurationInMinutes: 10
GrowthFactor: 10
FinalBakeTimeInMinutes: 5
ReplicateTo: NONE
Then start deployments from CI using the CLI:
aws appconfig start-deployment \
--application-id "$APP_ID" \
--environment-id "$ENV_ID" \
--configuration-profile-id "$PROFILE_ID" \
--configuration-version 2 \
--deployment-strategy-id "$STRATEGY_ID"
For production, attach a CloudWatch alarm (for example on your API's 5xx rate) as an environment monitor. AppConfig will roll the configuration back automatically if the alarm fires during the deployment or bake time. For a true emergency kill switch, use a strategy with zero duration.
Step 4: Read flags in the handler
The extension exposes a local HTTP endpoint. For a feature flag profile you can request a single flag with the flag query parameter:
// src/api.js
const base = 'http://localhost:2772';
async function getFlag(name) {
const { APPCONFIG_APP, APPCONFIG_ENV, APPCONFIG_PROFILE } = process.env;
const url = `${base}/applications/${APPCONFIG_APP}/environments/${APPCONFIG_ENV}` +
`/configurations/${APPCONFIG_PROFILE}?flag=${encodeURIComponent(name)}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`flag lookup failed: ${res.status}`);
return res.json(); // e.g. { enabled: true, maxRows: 1000 }
}
export const handler = async (event) => {
let flag = { enabled: false, maxRows: 1000 };
try {
flag = await getFlag('new-report-engine');
} catch (err) {
console.warn('flag lookup failed, using default', err.message);
}
const body = flag.enabled ? await newEngine(flag.maxRows) : await legacyEngine();
return { statusCode: 200, body: JSON.stringify(body) };
};
Always hard-code a safe default and fall back to it. A flag service outage should degrade to "feature off", never to a failed request.
Step 5: Per-tenant and percentage rollouts
AppConfig feature flags support multi-variant flags with rules in newer configurations, but a simple and portable pattern is to keep the flag as data and evaluate it yourself:
"new-report-engine": { "enabled": true, "rolloutPercent": 10, "allowTenants": ["acme", "globex"] }
import { createHash } from 'node:crypto';
function inRollout(tenantId, percent) {
const h = createHash('sha256').update(tenantId).digest().readUInt16BE(0);
return (h % 100) < percent;
}
const on = flag.enabled &&
(flag.allowTenants?.includes(tenantId) || inRollout(tenantId, flag.rolloutPercent ?? 0));
Hashing the tenant ID gives a stable assignment, so a tenant does not flip between old and new behaviour across requests.
Operational gotchas
- Polling interval is your propagation delay. With a 45-second poll, a flag flip reaches warm containers within roughly that time plus the deployment duration. Tune
AWS_APPCONFIG_EXTENSION_POLL_INTERVAL_SECONDSagainst how fast you need a kill switch to act. - Layer and cold start. The extension adds a small amount of init time. Measure it with
Init Durationbefore and after. - Permissions. The extension uses the function's role, so the two
appconfig:actions above are required. Scope the resource to your profile ARN in production. - Flag debt. Every flag needs an owner and a removal date. Delete the flag and the dead code path once the rollout reaches 100%.
- Test both paths. Unit test the handler with the flag on and off, and include the lookup-failure fallback.
- Other options. If you already run LaunchDarkly, Unleash, or similar, the pattern is the same; AppConfig simply keeps the data plane inside your AWS account with no extra vendor.
Verify
- Deploy:
serverless deploy --stage dev. - Call
GET /reportand confirm the legacy path runs. - Publish version 2 of the profile with
enabled: trueusingstart-deployment. - Call the endpoint again after the poll interval and confirm the new path runs, with no function redeploy.
- Publish an "off" version to confirm the kill switch works.
Need help shipping safely?
Feature flags, canary deploys, and automatic rollback together make serverless releases boring, which is the goal. If you want a team to set this up across your accounts and stages, get in touch about our DevOps for Serverless Apps and CI/CD engagements.