Our service is built with React and NestJS, all in TypeScript, and the backend runs on ECS on Fargate.
Until recently we handled small adjustments—cutting the session-timeout during QA, rolling out a beta feature to a subset of users—by overriding environment variables and redeploying. Each redeploy triggered the whole CI/CD pipeline and kept us waiting three to five minutes. That delay soon felt heavier than the change itself. Three minutes felt trivial at first, but during a hot-fix it was an eternity.
Inspired by Kaminashi’s article, we adopted AWS AppConfig so we can flip feature flags straight from the console. It looked easy, but I still managed to hit a few snags—here’s the full write-up.
Why bring in feature flags?
- During QA we sometimes need a much shorter session timeout.
- Beta features should appear only for internal users at first.
- In an emergency we want the option to roll back without touching code.
With AppConfig each of these tweaks is now a single click.
Overall architecture
An AppConfig Agent runs as a sidecar in every ECS task and polls for configuration updates (the default interval is 45 seconds). The application itself just calls localhost:2772
; all SDK calls are hidden inside the sidecar, so the main container has zero AWS-SDK dependencies.
See What is AWS AppConfig Agent? in the official docs.
Setting up AppConfig
1. Create an application and a configuration profile
- Application:
feature-flags
- Configuration profile:
feature-flags-config
2. Add a Basic Flag
For a simple on/off switch, create a Basic Flag—for example isDebug
.
3. Add a Multi-Variant Flag
When the value depends on user attributes (in our case sessionId
), choose a Multi-Variant Flag. The rule builder in the side panel lets you describe the conditions freely.
4. Define environments
Because flag values differ by environment, create dev
, stg
, and prod
on the Environments tab.
Here is the form for dev
:
5. Deploy the configuration
Pick the environment, hosted configuration version, and a deployment strategy, then press "Start Deployment".
A canary strategy such as Canary10Percent20minutes
works like this:
- At 0 minutes → apply the new config to 10 percent of traffic.
- Bake for 10 minutes → monitor for errors; roll back if needed.
- Over the next 10 minutes → ramp from 10 to 100 percent (for example 30 → 60 → 100).
- At 20 minutes → all traffic uses the new config.
Adding the AppConfig Agent to an ECS task definition
Following the official docs, append this to the task definition:
{
"name": "aws-appconfig-agent",
"image": "public.ecr.aws/aws-appconfig/aws-appconfig-agent:2.x",
"cpu": 128,
"memoryReservation": 256,
"essential": true,
"environment": [
{
"name": "PREFETCH_LIST",
"value": "/applications/feature-flags/environments/dev/configurations/feature-flags-config"
}
],
"portMappings": [
{
"containerPort": 2772,
"protocol": "tcp"
}
]
}
Make sure the sum of CPU and memory for the main container and the sidecar fits the task limit.
PREFETCH_LIST
tells the agent which paths to fetch in advance.
If you need a different polling interval, set POLL_INTERVAL
(the default is 45 seconds).
Other options are listed in the official docs.
Loading feature flags in the NestJS application
Service implementation (excerpt)
We created a FeatureFlagsService
that retrieves feature flags.
@Injectable()
export class FeatureFlagsService {
async evaluateFlag<T = boolean>(
flagName: string,
context: Record<string, string | number | boolean> = {},
defaultValue: T,
): Promise<T> {
const environment = process.env.ENV || 'dev'
const isDevelopment = environment !== 'prod'
try {
// --- Local environment: override with an env var ---
if (environment === 'local') {
const envFlagName = `FEATURE_FLAG_${flagName
.replace(/([A-Z])/g, '_$1')
.toUpperCase()
.replace(/^_/, '')}`
const envValue = process.env[envFlagName]
if (envValue !== undefined) {
return this.parseEnvValue<T>(envValue, defaultValue)
}
return defaultValue
}
// --- Other environments: fetch through the AppConfig agent ---
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 5000)
const headers: Record<string, string> = {}
if (Object.keys(context).length > 0) {
headers.Context = Object.entries(context)
.map(([key, value]) => `${key}=${String(value)}`)
.join(',')
}
const response = await fetch(
`http://localhost:2772/applications/feature-flags/environments/${environment}/configurations/feature-flags-config`,
{ signal: controller.signal, headers },
)
clearTimeout(timeoutId)
if (!response.ok) {
this.warnDevelopment(
isDevelopment,
`[FeatureFlags] AppConfig response not ok: ${response.status} for flag ${flagName}`,
)
return defaultValue
}
const config = await response.json()
this.logDevelopment(
isDevelopment,
`[FeatureFlags] AppConfig response for ${flagName} with context ${JSON.stringify(context)}:`,
JSON.stringify(config, null, 2),
)
if (flagName.toLowerCase() in config || flagName in config) {
const flagKey = flagName in config ? flagName : flagName.toLowerCase()
const flagValue = config[flagKey]
this.logDevelopment(
isDevelopment,
`[FeatureFlags] Flag value for ${flagName}:`,
JSON.stringify(flagValue, null, 2),
)
if (typeof flagValue === 'object' && flagValue !== null && 'enabled' in flagValue) {
this.logDevelopment(isDevelopment, `[FeatureFlags] flagValue.enabled:`, flagValue.enabled)
return flagValue.enabled as T
}
}
this.warnDevelopment(isDevelopment, `Unexpected AppConfig response format for ${flagName}`)
return defaultValue
} catch (error) {
this.warnDevelopment(isDevelopment, `Feature flag evaluation error for ${flagName}:`, error)
return defaultValue
}
}
/** Convert env-var strings to the appropriate type */
private parseEnvValue<T>(envValue: string, defaultValue: T): T {
if (typeof defaultValue === 'boolean') {
return (envValue.toLowerCase() === 'true') as T
}
if (typeof defaultValue === 'number') {
const parsed = Number(envValue)
return (isNaN(parsed) ? defaultValue : parsed) as T
}
return envValue as T
}
/** Log only in non-production environments */
private logDevelopment(isDevelopment: boolean, message: string, ...args: unknown[]): void {
if (isDevelopment) console.log(message, ...args)
}
/** Warn only in non-production environments */
private warnDevelopment(isDevelopment: boolean, message: string, ...args: unknown[]): void {
if (isDevelopment) console.warn(message, ...args)
}
}
Local environment
Because there is no sidecar container locally, you can override flags with
FEATURE_FLAG_<FLAG_NAME>
environment variables.
if (environment === 'local') {
const envFlagName = `FEATURE_FLAG_${flagName
.replace(/([A-Z])/g, '_$1')
.toUpperCase()
.replace(/^_/, '')}`
const envValue = process.env[envFlagName]
if (envValue !== undefined) {
return this.parseEnvValue<T>(envValue, defaultValue)
}
return defaultValue
}
Sending context information
To enable rules such as “turn the flag on only for a specific sessionId
,”
context information must be passed in the Context
header.
const headers: Record<string, string> = {}
if (Object.keys(context).length > 0) {
const contextHeader = Object.entries(context)
.map(([key, value]) => `${key}=${String(value)}`)
.join(',')
headers.Context = contextHeader
}
Example:
await featureFlagsService.evaluateFlag('isBetaUI', { sessionId: 1 }, false)
// → Header includes: Context: sessionId=1
Switching the UI on the frontend
Controller
Reading flags on the backend is useful, but when you want to show a new UI only to certain users, the frontend also needs to know the flag state.
We added a FeatureFlagsController
that returns multiple flags at once, so React can call it directly.
@Controller('v1/feature-flags')
export class FeatureFlagsController {
constructor(private readonly featureFlagsService: FeatureFlagsService) {}
/** Get the state of multiple feature flags */
@Get()
async getFeatureFlags(
@Query() query: GetFeatureFlagsDto,
): Promise<{ flags: Record<string, boolean> }> {
const flagNames = query.flags ? query.flags.split(',') : []
const flags: Record<string, boolean> = {}
const context: Record<string, string | number | boolean> = {}
if (query.siteId !== undefined) context.siteId = String(query.siteId)
for (const name of flagNames) {
flags[name] = await this.featureFlagsService.evaluateFlag(name, context, false)
}
return { flags }
}
/** Get the state of a single feature flag */
@Get(':flagName')
async getFeatureFlag(
@Param('flagName') flagName: string,
@Query() query: GetFeatureFlagDto,
): Promise<{ flagName: string; enabled: boolean }> {
const defaultValue = query.defaultValue === 'true'
const context: Record<string, string | number | boolean> = {}
if (query.siteId !== undefined) context.siteId = String(query.siteId)
const enabled = await this.featureFlagsService.evaluateFlag(flagName, context, defaultValue)
return { flagName, enabled }
}
}
TanStack Query custom hooks
On the React side we prepared lightweight hooks with TanStack Query.
/** Thin wrapper for a single flag */
export const useFeatureFlag = (
flagName: string,
ctx: Partial<FeatureFlagContext> = {},
) => {
const { data, isLoading, error } = useFeatureFlags([flagName], ctx)
return {
enabled: data?.flags[flagName] ?? false, // default to false while loading
loading: isLoading,
error,
}
}
/** Fetch multiple flags at once */
export const useFeatureFlags = (
flagNames: string[],
ctx: Partial<FeatureFlagContext> = {},
) =>
useQuery({
queryKey: ['feature-flags', [...flagNames].sort(), ctx],
queryFn: () => fetchFeatureFlags(flagNames, ctx),
enabled: flagNames.length > 0,
staleTime: 5 * 60 * 1000,
retry: false,
})
/** HTTP call separated for clarity */
const fetchFeatureFlags = async (
flagNames: string[],
ctx: Partial<FeatureFlagContext>,
): Promise<GetFeatureFlagsResponseDto> => {
const res = await apiClient.get<GetFeatureFlagsResponseDto>('v1/feature-flags', {
params: { flags: flagNames.join(','), ...ctx },
})
if (res instanceof Failure) throw res
return res.value
}
Conclusion
With AppConfig in place, we no longer need to redeploy just to flip a flag, so the wait time has dropped to zero and the stress around releases has eased considerably.
At the same time, we have to watch for deployment mistakes—especially when multi-variant flag rules differ by environment. A slip could push a dev rule into prod.
Defining clear operational guidelines for these cases is the next item on our agenda.
Top comments (0)