DEV Community

Instant Feature Flags on ECS with AWS AppConfig — Zero Redeploys

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.

image.png

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

create app and profile

2. Add a Basic Flag

For a simple on/off switch, create a Basic Flag—for example isDebug.

basic flag

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.

rule builder

4. Define environments

Because flag values differ by environment, create dev, stg, and prod on the Environments tab.

environment list

Here is the form for dev:

environment detail

5. Deploy the configuration

Pick the environment, hosted configuration version, and a deployment strategy, then press "Start Deployment".

start deployment

A canary strategy such as Canary10Percent20minutes works like this:

  1. At 0 minutes → apply the new config to 10 percent of traffic.
  2. Bake for 10 minutes → monitor for errors; roll back if needed.
  3. Over the next 10 minutes → ramp from 10 to 100 percent (for example 30 → 60 → 100).
  4. At 20 minutes → all traffic uses the new config.

strategy diagram

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"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Example:

await featureFlagsService.evaluateFlag('isBetaUI', { sessionId: 1 }, false)
// → Header includes:  Context: sessionId=1
Enter fullscreen mode Exit fullscreen mode

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 }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)