Tag Archives: Advanced (300)

Store and process large strings in Amazon Redshift SUPER columns

Post Syndicated from Ritesh Sinha original https://aws.amazon.com/blogs/big-data/store-and-process-large-strings-in-amazon-redshift-super-columns/

Storing large semi-structured data such as JSON and arrays in a data warehouse often means splitting or truncating your data. Amazon Redshift removes this with the SUPER data type and PartiQL language so you can ingest, store, transform, and analyze semi-structured data such as weblogs and sensor data at scale. When you turn on large string support, SUPER supports individual strings up to 16 million bytes and processes strings beyond the 65,535 bytes VARCHAR limit in memory. SUPER also works in case-insensitive databases.

In this post, we walk you through three key SUPER data type enhancements that simplify semi-structured data workflows and reduce common migration challenges.

  • Handling large SUPER objects with extended string support: Learn how the 16 million bytes improvement helps you work with large JSON documents that previously exceeded size limits
  • In-memory string function processing without truncation for improved efficiency
  • Migration from your data warehouse with case insensitivity support: Explore how case-insensitive database compatibility simplifies migrations from legacy databases

Prerequisites

To implement this solution, you need the following prerequisites:

Use case overview

The SUPER data type helps you process and query semi-structured or nested data. This includes web logs, industrial Internet of Things (IoT) data from machines and equipment, sensor data, genomics data, and more.

The following diagram illustrates the data flow. It shows how data is generated, collected, and stored in Amazon Redshift as the SUPER data type:

Figure 1: Use case overview flow

Use case one: Handling large JSON documents with extended string support

The challenge

Today, organizations need to store and analyze large semi-structured documents such as detailed customer profiles, comprehensive transaction records, IoT sensor data, and complex event logs.

The solution

Amazon Redshift now supports storing objects up to 16 million bytes in SUPER data type columns. When you turn on large string support, individual strings within SUPER columns now support up to 16 million bytes. You can store complete legal documents, detailed product catalogs with formatting intact, full application logs, extensive customer interaction histories, and entire articles, with no splitting or truncating your data.

To follow along with this solution, complete the following:

Turn on large string support. Create a parameter group (or edit an existing one), set enable_large_strings_opt_in to Yes, and attach the parameter group to your data warehouse. For a provisioned cluster, reboot the cluster to apply the change. For Amazon Redshift Serverless, set the enable_large_strings_opt_in configuration parameter on your workgroup instead, because Serverless doesn’t use parameter groups.

Confirm the setting:

SELECT is_large_string_enabled(); 

Create the orders table:

CREATE TABLE orders ( data SUPER);

Load large JSON documents (up to 16 million bytes) with strings > 65535 bytes into orders table using COPY:

COPY orders
FROM 's3://<your-bucket>/orders.json'
IAM_ROLE 'arn:aws:iam::1234567890:role/service-role/<Redshift-copy-unload-role>'
FORMAT JSON 'noshred';

Select data from orders table:

SELECT * FROM orders;

Figure 2: Orders result set

The following query extracts specific fields from nested JSON documents stored in the data SUPER column of the orders table. Before querying, you set enable_case_sensitive_super_attribute to true, so you can reference camelCase attribute names such as orderId and shippingAddress without double quotation marks. The SELECT clause retrieves order and customer identifiers, shipping address details, and a description field that supports up to 16 million bytes of text, along with its character length. The WHERE clause returns only orders with a status of ‘processing‘.

-- Set case sensitivity
SET enable_case_sensitive_super_attribute TO true;

-- Query nested order data with large string values
SELECT
    data.orderId::VARCHAR                     AS order_id,
    data.customerId"::VARCHAR                  AS customer_id,
    data.shippingAddress.state::VARCHAR     AS shipping_state,
    data.shippingAddress.zipCode::VARCHAR   AS zip_code,
    data.detailedDescription::VARCHAR         AS detailed_description,
    LENGTH(data."detailedDescription"::VARCHAR) AS description_length
FROM orders
WHERE data.status::VARCHAR = 'processing';

Figure 3: Large string result set

Use case two: In-memory string function operations without truncation

The challenge

Running string operations on large semi-structured data has trade-offs such as either truncating at 65,535 bytes and losing critical information, or disk spills impacting query performance. Standard SQL string functions like substring extraction, pattern matching, and text transformations become impractical when your data exceeds these limits. This leaves you with incomplete analysis or unacceptable latency.

The solution

When you set enable_large_strings_opt_in to Yes, Amazon Redshift supports in-memory string function operations on SUPER data that exceed the 65,535 bytes VARCHAR limit. By casting SUPER values to VARCHAR within string functions, Amazon Redshift processes the full string in memory without truncation to upper limit of 16 million bytes. Table columns defined as VARCHAR(MAX) stay at 65,535 bytes. The larger limit applies to in-memory operations such as CAST expressions, views, UDFs, and stored procedures.

How it works:

  • Without CAST: String functions directly on SUPER columns can cause errors
  • With CAST: Cast SUPER values to VARCHAR first, then apply string functions without truncation or errors

Problem scenario (causes error):

Run the following query to retrieve customer IDs from the orders table and convert them to lowercase; you will get an error.

SELECT
lower(data.customerId) AS customer_id
FROM orders;

Figure 4: Output without using CAST

Solution (works without truncation):

Run the following query to retrieve customer IDs from the orders table, cast them to VARCHAR and convert them to lowercase, query executes successfully.

SELECT
lower(data.customerId::VARCHAR) AS customer_id
FROM orders;

Figure 5: Query result showing customer IDs

Next, let’s see how the SUPER data type accommodates string values larger than 65,535 bytes. We use the LISTAGG and LEN functions to test this enhancement. The LISTAGG window function orders rows for each group according to the ORDER BY expression, then concatenates the values into a single string. The LEN function returns the length of the specified string as the number of characters. This operation wasn’t possible with the previous 65,535 bytes limit.

SELECT
LEN(LISTAGG(data.detailedDescription::VARCHAR, ', ')WITHIN GROUP (ORDER BY data.orderId::VARCHAR)) AS description_length
FROM orders
WHERE data.status::VARCHAR = 'processing';

Figure 6: Query result showing description length

The output shows a string length of 1,212,008 characters. This demonstrates that SUPER can now handle strings beyond the previous 65,535-byte limit. If the output of LISTAGG exceeds the 65,535-byte limit of a VARCHAR column, you can store the result in a SUPER column, which supports up to 16 million bytes.

With large string support turned on, VARCHAR(MAX) in a view resolves to VARCHAR(16000000), so the view can return full-length strings. Because views keep the case settings from their creation, run SET first, then create the view after you turn on the setting:

SET enable_case_sensitive_super_attribute TO true;
CREATE VIEW order_descriptions AS
SELECT     
   data.orderId::VARCHAR AS order_id, 
   data.detailedDescription::VARCHAR(MAX) AS detailed_description
FROM orders; 

Views that you created before you turned on the setting stay capped at 65,535 bytes until you recreate them. For more information, see Behavior changes in Amazon Redshift.

Use case three: Migration from your data warehouse with case insensitivity support

The challenge

Organizations migrating from legacy data warehouses to Amazon Redshift face a critical decision regarding database collation. Your data warehouse operates with case-insensitive string comparisons by default, and maintaining this behavior is essential for application compatibility. However, until recently, SUPER data types were only supported in Amazon Redshift databases with case-sensitive collation, creating a migration barrier. If you created Amazon Redshift case-insensitive databases to ease your migration process (avoiding the extensive testing required for case-sensitive conversion), you couldn’t use the SUPER data type for ingesting and querying semi-structured data such as JSON and arrays in Amazon Redshift native and data lake tables.

The solution

Amazon Redshift announced support for SUPER data types in databases with case-insensitive collation, which has the following benefits:

  • Maintain case-insensitive behavior for legacy applications
  • Use SUPER columns for semi-structured data in the same database
  • Use the COLLATE function to specify case sensitivity preferences for SUPER columns

Amazon Redshift native table with SUPER data type

  1. Log in to Amazon Redshift Query Editor V2. Create database with case insensitive collation.
CREATE DATABASE custdb_case_insensitive COLLATE CASE_INSENSITIVE;
  1. Create the customer_data table with SUPER data type columns and insert sample records.
CREATE TABLE customer_data (
    customer_id    INTEGER,
    customer_name  VARCHAR(100),
    customer_profile SUPER,
    order_history  SUPER COLLATE case_sensitive,
    metadata       SUPER,
    created_date   TIMESTAMP DEFAULT GETDATE());

INSERT INTO customer_data (customer_id, customer_name, customer_profile, order_history, metadata)
VALUES (
    1,
    'John Doe',
    JSON_PARSE('{"age": 30, "email": "[email protected]", "address": {"street": "123 Main St", "city": "New York", "zipcode": "10001"}}'),
    JSON_PARSE('[{"order_id": 101, "amount": 250.50, "items": ["laptop", "mouse"]}, {"order_id": 102, "amount": 89.99, "items": ["keyboard"]}]'),
    JSON_PARSE('{"source": "web", "campaign": "spring_sale", "tags": ["premium", "verified"]}')
);
INSERT INTO customer_data (customer_id, customer_name, customer_profile, order_history, metadata)
VALUES (
    2,
    'Alice Smith',
    JSON_PARSE ('{
        "age": 35,
        "email": "[email protected]",
        "preferences": {
            "newsletter": true,
            "notifications": ["email", "sms"],
            "language": "en"
        },
        "address": {
            "street": "321 Elm St",
            "city": "Boston",
            "state": "MA",
            "zipcode": "01234"
        }
    }'),
    JSON_PARSE ('[
        {
            "order_id": 106,
            "amount": 599.99,
            "date": "2024-01-15",
            "items": [
                {"name": "smartphone", "price": 499.99, "quantity": 1},
                {"name": "screen_protector", "price": 19.99, "quantity": 2}
            ],
            "status": "delivered"
        }
    ]'),
    JSON_PARSE ('{"source": "web", "device": "desktop", "browser": "Chrome"}')
);
  1. Query customer_data table to analyze super data type columns.

SET enable_case_sensitive_super_attribute TO true;
SELECT * FROM customer_data;


Figure 7: Result set of customer_data

  1. Use the following two queries to unnest the order_history array column using PartiQL.
SELECT
customer_id,
customer_name,
customer_profile.age,
customer_profile.email,
customer_profile.address.city,
order_history[0].order_id AS first_order_id,
order_history[0].amount AS first_order_amount,
order_history[0].items[0] AS first_item
FROM customer_data c;

Figure 8: Result set of customer_data using partiql

SELECT
customer_id,
customer_name,
customer_profile.age,
customer_profile.email,
customer_profile.address.city,
orders.order_id,
orders.amount
FROM customer_data c, c.order_history AS orders;

Figure 9: Result set of customer_data and order_history using partiql

  1. Run a query that depends on the case-insensitive collation of the database. The filter uses lowercase “new york” and still matches “New York”.
    -- Matches 'New York' because the database is case insensitive 
    SELECT 
    customer_id, 
    customer_profile.address.city 
    FROM customer_data 
    WHERE customer_profile.address.city::VARCHAR = 'new york';

In this example, the order_history column uses COLLATE case_sensitive, which overrides the case-insensitive default of the database for that column. To compare both behaviors, you can also use the COLLATE function in a WHERE clause.

Query nested data type on data lake

  1. The following is the sample input data for the customer profile. Place this data into a file and upload it to Amazon S3, name the S3 prefix “customer_profile”.
{
  "customer_id": 1001,
  "name": "John Smith",
  "email": "[email protected]",
  "registration_date": "2023-01-15",
  "profile": {
    "age": 35,
    "gender": "M",
    "occupation": "Software Engineer",
    "income_range": "75000-100000",
    "preferences": {
      "communication": ["email", "sms"],
      "categories": ["electronics", "books", "sports"]
    },
    "address": {
      "street": "123 Main St",
      "city": "Seattle",
      "state": "WA",
      "zip": "98101",
      "country": "USA"
    },
    "loyalty_tier": "gold",
    "interests": ["technology", "hiking", "photography"]
  }
}
  1. Create the AWS Glue database datalake_withsuper_datatype. For instructions on how to create the AWS Glue database, see Creating databases.
  2. Create the AWS Glue crawler to crawl customer_profile prefix, use AWS Glue database datalake_withsuper_datatype in crawler. For instructions on how to create a crawler, see Tutorial: Adding an AWS Glue crawler.
  3. Create external schema on Amazon Redshift in database custdb_case_insensitive.
CREATE EXTERNAL SCHEMA IF NOT EXISTS datalake_cust_profile
FROM DATA CATALOG
DATABASE 'datalake_withsuper_datatype'
IAM_ROLE default
CREATE EXTERNAL DATABASE IF NOT EXISTS;
  1. Select the external table customer_profile to view the column profile, which is the struct column in the AWS Glue table.
SELECT * FROM
"custdb_case_insensitive"."datalake_cust_profile"."customer_profile";


Figure 10: Result set of customer_profile using glue data catlog

Best practices

In this section, we discuss the best practices that you can follow while using SUPER data type in Amazon Redshift.

  1. Configure case sensitivity settings

Set the following session setting before you query SUPER columns for accurate field name matching. With this setting on, reference field names without double quotation marks:

SET enable_case_sensitive_super_attribute TO true;

Without this setting, Amazon Redshift converts unquoted field names to lowercase. When your JSON contains orderId but Amazon Redshift searches for orderid, queries return NULL values instead of your data.

Example: Impact of enabling case sensitivity

The following example shows the difference in query results with and without case sensitivity enabled:

-- Without the setting - returns NULL
SELECT data.orderId::VARCHAR FROM orders;
-- With the setting - returns actual values
SET enable_case_sensitive_super_attribute TO true;
SELECT data.orderId::VARCHAR FROM orders;
  1. Cast SUPER values to specific data types

Always cast SUPER values to appropriate data types for better performance and accuracy:

SELECT
    data.orderId::VARCHAR          AS order_id,
    data.totalAmount::DECIMAL(10,2) AS total_amount,
    data.shippingAddress."zipCode"::VARCHAR AS zip_code,
    data.items."quantity"::INTEGER AS first_item_qty
FROM orders
WHERE data.status::VARCHAR = 'processing';

Explicit casting eliminates ambiguity and improves query performance by helping Amazon Redshift optimize execution plans.

  1. Optimize performance for production workloads

Promote frequently queried fields

Extract commonly accessed SUPER attributes to regular columns for faster queries:

ALTER TABLE orders ADD COLUMN customer_id VARCHAR(50);
UPDATE orders SET customer_id = data.customerId::VARCHAR;

Create materialized views for complex queries

Turn on enable_case_sensitive_super_attribute, then build materialized views for queries you run repeatedly:

SET enable_case_sensitive_super_attribute TO true;
CREATE MATERIALIZED VIEW order_summary AS
SELECT
    data.orderId::VARCHAR           AS order_id,
    data.customerId::VARCHAR        AS customer_id,
    data.totalAmount::DECIMAL(10,2) AS total_amount,
    LENGTH(data.detailedDescription::VARCHAR) AS description_length
FROM orders;
REFRESH MATERIALIZED VIEW order_summary;
  1. Migrate from legacy databases

Use AWS Schema Conversion Tool (AWS SCT) to convert your schema automatically, then configure case sensitivity for SUPER columns.

Set user-level configuration

Enable case sensitivity for all of a single user’s sessions:

ALTER USER myuser SET enable_case_sensitive_super_attribute TO true;

Test both column types

Verify that regular columns maintain case-insensitive behavior:

-- Regular columns remain case-insensitive
SELECT COUNT(*) FROM customer_data WHERE cust_name = 'JOHN DOE';
SELECT COUNT(*) FROM customer_data WHERE cust_name = 'john doe';
-- Both return identical results

Confirm that SUPER columns enforce case sensitivity:

SET enable_case_sensitive_super_attribute TO true;
-- Correct - matches JSON field exactly
SELECT customer_profile.address.city::VARCHAR FROM customer_data;
-- Incorrect - returns NULL due to case mismatch
SELECT customer_profile.Address.City::VARCHAR FROM customer_data;

Update application queries:

Modify your application code to use correct case and explicit casting for all SUPER column attributes. This ensures consistent results and prevents NULL values from case mismatches.

  1. Align the large string setting

Keep the enable_large_strings_opt_in setting the same on data sharing producer and consumer data warehouses.

Recreate existing views, UDFs, and stored procedures after you turn on the setting. They stay capped at 65,535 bytes until you recreate them.

After you set the parameter to Yes or No, you can’t reset it to the default value.
For more information about best practices for working with semi-structured data types, see documentation.

Conclusion

The enhanced SUPER data type of Amazon Redshift solves the previously mentioned critical challenges that you face when working with semi-structured data. This post showed you how to turn on large string support, store individual strings in JSON documents up to 16 million bytes, perform string operations without truncation errors, and maintain case-insensitive compatibility during database migrations.

With SUPER, you can handle comprehensive data objects including legal contracts, product catalogs, customer profiles, and IoT sensor readings without breaking them into smaller pieces.

You can also cast SUPER values to VARCHAR and run pattern matching, text manipulation, and data cleansing operations directly on SUPER columns, and remove the truncation errors that previously blocked your analysis workflows.

Start implementing these capabilities in your environment with the following steps:

  1. Turn on large string support
  2. Configure case sensitivity settings for your specific use case
  3. Cast data types explicitly during data extraction
  4. Create materialized views for complex queries
  5. Test thoroughly to optimize query performance

Learn more


About the authors

Ritesh Kumar Sinha

Ritesh Ritesh Kumar Sinha is an Senior Analytics Specialist Solutions Architect based out of San Francisco. He has helped customers build scalable data warehousing and big data solutions for over 16 years. He loves to design and build efficient end-to-end solutions on AWS. In his spare time, he loves reading, walking, and doing yoga.

Raghu Kuppala

Raghu is an Analytics Specialist Solutions Architect experienced working in the databases, data warehousing, and analytics space. Outside of work, he enjoys trying different cuisines and spending time with his family and friends.

Raza Hafeez

Raza is a Senior Product Manager at Amazon Redshift. He has over 13 years of professional experience building and optimizing enterprise data warehouses and is passionate about enabling customers to realize the power of their data. He specializes in migrating enterprise data warehouses to AWS Modern Data Architecture.

Harshida Patel

Harshida Patel

Harshida is a Specialist Sr. Solutions Architect for Analytics with AWS.

How OrderServ achieved up to 64% cost savings with multi-tenant ECS

Post Syndicated from Chetan Yewale original https://aws.amazon.com/blogs/architecture/how-orderserv-achieved-up-to-64-cost-savings-with-multi-tenant-ecs/

When your food-ordering platform doubles its brand count, dedicated clusters become your biggest cost problem, and every failed request during a traffic surge is a lost sale.

Here is how Cognizant and AWS Professional Services solved it.

Cognizant is a global services company that helps organizations modernize technology, reimagine processes, and transform customer experiences. Its OrderServ service is an omnichannel ordering solution for the food services industry that supports ordering across devices and integrates with point-of-sale, kitchen display, payment, loyalty, and other enterprise systems. Cognizant is an AWS Premier Tier Services Partner with multiple AWS Competencies and a member of the AWS Managed Service Provider (MSP) and AWS Well-Architected Partner Programs.

In 2022, Cognizant published how OrderServ 2.0 reimagined digital food ordering by migrating from a monolith to microservices on AWS Fargate, achieving 30 percent cost savings over traditional hosted architectures.

Since then, the service grew from serving a single quick-service restaurant (QSR) brand to powering multiple brands across markets. Each brand ran as a dedicated Amazon Elastic Container Service (Amazon ECS) cluster with 21 microservices. This provided hard isolation, but cost scaled linearly with every brand onboarded, and scaling speed could not keep up with promotional traffic surges that spike 5–10× within minutes.

In this post, we describe how AWS Professional Services partnered with Cognizant to transform OrderServ into a multi-tenant software as a service (SaaS) offering, achieving 47–64 percent compute cost reduction, scaling time reduced from 5–7 minutes to 30–60 seconds, and authentication costs cut by 80 percent.

The problem: Dedicated clusters don’t scale with brands

OrderServ 2.0 ran 21 microservices per brand on dedicated AWS Fargate clusters. For five brands, this meant:

  • 105 ECS services (21 × 5), each with idle capacity during off-peak hours.
  • 315 sidecar containers for observability, configuration, and log routing, duplicated per brand.
  • Linear cost growth, because onboarding a new brand required provisioning an entire cluster.
  • 5-minute scaling gap, because large container images increased image-pull and task startup times on AWS Fargate, delaying the addition of capacity during promotional traffic surges.

In QSR, every failed request during a surge is a lost sale and users may switch to competitor services immediately. The service needed to serve over 10 brands cost-effectively while handling unpredictable traffic patterns: lunch rushes, promotional events, and festival surges.

Solution overview

We addressed these challenges through four architectural changes:

  • Shared ECS cluster with a tenancy library: Application-level isolation replaces per-brand infrastructure.
  • EC2 warm pools with Bottlerocket: Sub-minute scaling replaces AWS Fargate cold starts.
  • Crash observability pipeline: Zero-loss logging and 90-day forensics at $10/month per service.
  • Passkeys for multi-tenant authentication: Biometric login reduces recurring SMS costs.

Solution 1: Shared cluster with application-level isolation

As OrderServ scaled from one to five brands, the cost of replicating 21 microservices per tenant made the economics unsustainable. We needed to consolidate infrastructure while preserving the data isolation guarantees each brand required.

Why we moved away from dedicated clusters

The dedicated-cluster model provided strong isolation but no economy of scale. With the platform growing from five brands, onboarding a new brand needed to become a configuration change, not an infrastructure provisioning exercise.

Model Pros Cons
Dedicated cluster per brand (status quo) Hard isolation, simple AWS Identity and Access Management (IAM) Linear cost, idle capacity, slow onboarding
Shared cluster with opt-out Aggregate scaling, config-only onboarding Requires application-level isolation, wider IAM scope

We chose the shared cluster as the default, with the ability to place high-volume brands on dedicated clusters when needed.

Figure 1 illustrates this transformation, from five dedicated ECS clusters (105 services, 315 sidecar containers) to a single shared cluster with 21 services and an opt-out path for high-volume brands. The tenancy library handles tenant resolution, resource routing, and context propagation, while per-tenant data stores (Amazon DynamoDB, Amazon Aurora, Valkey) maintain strict data isolation.

Evolution from dedicated clusters per brand to a shared cluster model with opt-out for high-volume brands

Figure 1: Evolution from dedicated clusters per brand to a shared cluster model with opt-out for high-volume brands

The service uses AWS Key Management Service (AWS KMS) keys to encrypt every data store at rest, and the architecture implements Transport Layer Security (TLS) to protect all service-to-service and client-to-service communication. Amazon DynamoDB automatically encrypts tables by default. Customers enable encryption when creating Amazon Aurora Clusters and the Amazon Elastic File System (Amazon EFS) file system, and customers configure both at-rest and in-transit encryption for the Amazon ElastiCache for Valkey cluster.

The tenancy library as primary isolation

Most multi-tenant architectures enforce isolation at the infrastructure level: separate accounts, separate IAM roles, or separate compute. We took a different approach: a shared .NET Core library as the primary enforcement point for tenant isolation. IAM and resource policies are the backstop that contains the blast radius when the library has a defect.

Every request carries an X-Tenant-Id header injected by the Amazon API Gateway authorizer. The tenancy library:

  • Resolves tenant context per request using request-scoped storage (AsyncLocal<T>), never static or singleton state.
  • Routes all data access: Amazon DynamoDB table names, Amazon ElastiCache for Valkey key prefixes, Amazon Aurora schema selection, and S3 paths.
  • Refuses to construct resource IDs without a tenant prefix, so a missing tenant context fails closed instead of reading another tenant’s data.
  • Propagates tenant context on service-to-service calls and asynchronous messages (SQS/SNS attributes).
  • Enriches every log line and distributed trace with the tenant ID.

This design means the same service binary runs identically in a shared cluster or a dedicated cluster. The compute model becomes a deployment decision, not an architectural constraint.

Application-level isolation moves the isolation boundary into code, and code could potentially have defects. Because an Amazon ECS task role is granted per task rather than per request, a task in the shared cluster necessarily holds permissions spanning every brand it serves. To narrow that scope, the tenancy library acquires tenant-scoped credentials at run time: it calls sts:AssumeRole with the tenant ID as a session tag, and the role’s policies reference that tag so the credentials reach only that tenant’s DynamoDB tables, S3 prefixes, and Aurora schema. Amazon ElastiCache for Valkey enforces its own layer, with per-tenant role-based access control (RBAC) users restricted to the tenant’s key pattern, so the cache engine itself refuses a cross-tenant key access rather than trusting the library to have built the key correctly.

Impact

The following table compares key operational metrics (service count, sidecar overhead, onboarding time, and capacity utilization) before and after the migration to a shared cluster model.

Metric Before (dedicated) After (shared + library)
ECS services to deploy 21 × N brands 21 total
Sidecar containers 315 (for 5 brands) 63 total
Onboarding a new brand Provision full cluster (days) Configuration change (minutes)
Idle capacity Per-brand (wasted) Aggregate (efficient)

Solution 2: EC2 warm pools with Bottlerocket for sub-minute scaling

With all brands now sharing a single cluster, traffic spikes from any one brand could impact the others. We needed a scaling strategy that could absorb sudden surges in seconds, not minutes.

Why we moved away from AWS Fargate

AWS Fargate removes infrastructure management, but OrderServ required additional capacity in under a minute during unpredictable demand surges. New tasks took 5–7 minutes to become healthy, primarily because Fargate had to download and extract the workload’s large container image before starting the application. Task provisioning, application initialization and health checks added to the delay, but image retrieval was the largest contributor. During this interval, existing ECS tasks become saturated, latency increased and some orders started failing.

The solution: EC2 Auto Scaling groups with warm pools

We migrated to Amazon EC2 Auto Scaling groups with Bottlerocket (the AWS purpose-built container OS) and warm pools:

  • The system pre-initializes and stops warm pool instances, and you pay only for Amazon Elastic Block Store (Amazon EBS) storage (~$3/month per instance).
  • When ECS needs capacity, the Auto Scaling group (ASG) starts a stopped instance (image pre-cached, ECS agent registered) in 30–60 seconds.
  • We set capacity headroom (target utilization at 80 percent), so that the first wave absorbs instantly.
  • Bottlerocket provides atomic auto-updates, eliminating the Amazon Elastic Compute Cloud (Amazon EC2) patching burden.

Five-layer scaling strategy

Rather than relying on a single scaling mechanism, we implemented five complementary layers, each targeting a specific traffic pattern to remove gaps between detection and response.

Layer Mechanism Traffic pattern solved
Predictive ECS predictive scaling (machine learning, 14-day history). Scheduled scaling for specific known events (New Year, Christmas) was already handled manually via ASG scheduled actions Daily lunch/dinner peaks, scheduled scaling
Reactive Target tracking on ALBRequestCountPerTarget (60s) Unexpected moderate spikes
Headroom EC2 ASG target capacity at 80% First wave of any spike
Downstream ECS Service Connect RequestCount metric Bottleneck shifting
Throttling API Gateway per-tenant rate limits via AWS Lambda authorizer Extreme surges (noisy neighbor)

Figure 2 illustrates how the five scaling layers work together with the EC2 warm pool. Pre-initialized warm pool instances launch in 30–60 seconds versus 5–7 minutes for an AWS Fargate cold start, at a cost of approximately $3/month per stopped instance.

Five-layer scaling strategy. Predictive scaling handles daily patterns, reactive scaling triggers warm pool activation, capacity headroom absorbs initial spikes, and per-tenant throttling protects system integrity

Figure 2: Five-layer scaling strategy

Cost and performance impact

In addition to improving scaling performance, the move from AWS Fargate to Amazon EC2 reduced the projected monthly cost of running 21 services in the Asia Pacific (Mumbai) AWS Region (ap-south-1). Two instruments do different jobs here: On-Demand Capacity Reservations is designed to make sure capacity is available in the Availability Zone when a surge activates the warm pool, and Compute Savings Plan supply the discount. The reservation carries no term commitment and can be resized or released as the fleet changes, so the following one-year and three-year terms apply to the Savings Plan alone.

Approach Monthly cost (21 services) versus AWS Fargate
AWS Fargate On-Demand (before) ~$1,568 Baseline
EC2 ASG with 1-year Compute Savings Plan and warm pool ~$832
  • ~47%
EC2 ASG with 3-year Compute Savings Plan with warm pool ~$568
  • ~64%

The reservation covers the steady-state fleet, while you stop warm pool instances, adding approximately $30 per month in Amazon EBS storage costs. Scaling time falls from 5–7 minutes to 30–60 seconds.

Solution 3: Crash observability pipeline

When containers crash in production, the support team needs three things: what happened, the root cause, and the container’s execution state at the time of failure. In the prior state, OrderServ used the awslogs driver to send container output to CloudWatch Logs. Although this captured normal application output, it did not provide durable task-stop metadata, cross-service request correlation or persistent crash artifacts.

  • Potential loss of final logs records during crash: When a task terminates abruptly, records still in the log driver’s delivery buffer might not reach Amazon CloudWatch Logs.
  • Short lived task metadata: Amazon ECS makes stopped-task information available through the API for a limited period of 1 hour, reducing the investigation window.
  • No cross-service correlation: Plain-text logs lacked trace IDs and consistent task, container, and service identifiers needed to follow a failed request across services.
  • No persistent crash artifacts: The ephemeral task storage of AWS Fargate disappeared when the task stopped, taking local crash dumps and diagnostic files with it.

The solution: FireLens sidecar + Amazon EventBridge persistence

Figure 3 shows the crash observability pipeline, where the application container emits structured JSON logs with X-Ray trace IDs to a FireLens (Fluent Bit) sidecar. The sidecar survives container termination and routes logs to CloudWatch Logs with Infrequent Access class and 30-day retention, as well as to Amazon Simple Storage Service (Amazon S3) with gzip partitioning and 365-day retention. Amazon EventBridge captures task-stop events with full metadata, while the AWS Distro for OpenTelemetry (ADOT) collector provides auto-instrumented traces to X-Ray and EFS persists crash dumps across availability zones, all at approximately $10/month per service.

Crash observability pipeline. FireLens operates at the container runtime level, surviving application container crashes. Amazon EventBridge persists task metadata for 90 days

Figure 3: Crash observability pipeline. FireLens operates at the container runtime level, surviving application container crashes. Amazon EventBridge persists task metadata for 90 days

FireLens (Fluent Bit) as a sidecar is the key architectural choice. ECS redirects the application container’s stdout at the container runtime level. If the app container receives an out-of-memory (OOM) kill, Fluent Bit still has the logs.

For hard crashes where application logs are not sufficient (stack overflow, native code fault), the .NET Core runtime automatically writes a mini dump to an Amazon EFS mount at /tmp/dumps/<container-id>. Because Amazon EFS writes are immediately durable and the mount survives container death, the process state (thread call stacks, heap objects, loaded modules) helps preserve diagnostic information after a SIGKILL. An AWS Lambda function copies dumps to S3 for analysis without VPC access. Cost: ~$0.02/month.

Amazon EventBridge to Amazon CloudWatch Logs persists the complete Amazon ECS task state change event with 90-day retention. No AWS Lambda function is required.

Every log line is structured JSON with the AWS X-Ray trace ID. The support workflow: search for an error, find the trace ID, open X-Ray, and see the full request flow across all 21 services.

We also use CloudWatch Logs Infrequent Access class with FireLens dual output (CloudWatch + S3), reducing observability costs by 62 percent per service.

Cost: approximately $10/month per service for the complete pipeline.

Solution 4: Passkeys for multi-tenant authentication

QSR mobile apps need instant login. Every second of friction is a lost order. The service previously relied on SMS one-time password (OTP) for every login, which at millions of monthly active users meant significant costs and user friction.

Passkeys with per-brand cryptographic isolation

We implemented passkeys (WebAuthn) using the native support in Amazon Cognito:

  • First-time registration: Phone number + OTP verifies identity, then the device registers a passkey bound to the Secure Enclave.
  • Every subsequent login: Face ID or fingerprint, which completes in about 2 seconds with no SMS cost.
  • New device or stale profile: Because carriers reassign phone numbers, possession of a number is treated as a claim rather than proof of identity. Registering a passkey on a new device repeats the OTP verification, and a profile whose last verification predates our revalidation window is re-verified before any registered credential is honoured.
  • Per-brand isolation: Each brand operates its own Cognito User Pool with a distinct Relying Party ID that is cryptographically isolated at the OS level.

A Post-Authentication Lambda (about 60 lines) maintains a customer authentication profile in DynamoDB, keyed by a hash of the brand/tenant id and the customer’s phone number. Because the brand ID is part of the key, the same customer registering with two brands produces two unrelated records, and the system cannot locate any record without the brand context. The profile stores only authentication state, such as which devices were registered and when each was last verified. It holds no order history, loyalty balance, or other brand-specific data. Passkey credentials are isolated by the same principle: because each brand uses a distinct Relying Party ID, the authentication flow prevents presenting a credential registered for one brand to another brand, and the device enforces that boundary. The implementation required about 95 lines of custom code to integrate with Amazon Cognito’s authentication logic.

Metric Before After
Cost per login ~$0.03 (SMS OTP) $0 (passkey)
Login latency 15–30 seconds ~2 seconds
SMS cost reduction — 80%+ at steady state
Phishing resistance None Cryptographic (device-bound)

Overall results

The architectural evolution delivered compounding savings:

Dimension Before After Improvement
Compute cost AWS Fargate On-Demand EC2 + Warm pools + ODCR + Compute Savings Plan 47–64% reduction
Scaling speed ~ 5-7 minutes 30–60 seconds 5–10× faster
Observability cost Standard CW Logs IA + FireLens + S3 62% reduction
Authentication cost SMS OTP every login Passkeys 80%+ reduction
Tenant onboarding Manual, multi-day Configuration-driven Days → minutes
Crash diagnosis “Exit Code: 1” Full trace + stack trace Blind → full visibility

For a service serving over 5 brands, the combined annual savings exceed 60 percent of the previous infrastructure cost, while simultaneously improving scaling speed, operational visibility, and user experience.

Conclusion

In this post, we showed how Cognizant OrderServ evolved from a single tenant microservices service to a multi-tenant SaaS service by making four architectural changes: shared compute with a tenancy library, Amazon EC2 warm pools for sub-minute scaling, a crash observability pipeline, and passkeys for frictionless authentication. The architecture addresses key failure modes through tenant validation, capacity headroom, per-tenant throttling and dedicated-cluster for high-volume brands. These capabilities introduce trade-offs, including greater application-level isolation, EC2 capacity management and compute Savings Plan commitments.

The key insight is that multi-tenancy does not require infrastructure isolation for every tenant. A well-designed tenancy library with strict resource-routing conventions provides application-level isolation while sharing compute, making the isolation model a deployment decision rather than an architectural constraint.

If you operate a multi-brand or multi-tenant service on Amazon ECS and face similar challenges with cost scaling or surge handling, consider these patterns:

  • Evaluate whether your isolation requirements truly need per-tenant infrastructure, or whether application-level isolation with per-tenant data stores is sufficient.
  • For workloads with unpredictable surges, compare the total cost of AWS Fargate over-provisioning against EC2 warm pools with compute Savings Plan.
  • Use FireLens sidecars for log delivery in any environment where container crashes are possible.

To learn more, explore these resources:


About the authors

Deliver multi-tenant AI analytics at scale with AgentCore and Lake Formation

Post Syndicated from Fortune Hui original https://aws.amazon.com/blogs/big-data/deliver-multi-tenant-ai-analytics-at-scale-with-agentcore-and-lake-formation/

Governed, multi-tenant data access is a critical requirement for software and business-to-consumer (B2C) products that embed AI-powered analytics. A data agent adds significant value by answering natural-language queries over structured data, yet when that agent serves external users, the hardest problem isn’t natural language to SQL. It’s making sure each tenant can only access their authorized rows, columns, and tables.

The challenge of fine-grained data access for external users

Modern-day data agents are often built with an orchestrating framework, connected to a Model Context Protocol (MCP) server for data queries. The data agent translates validated natural-language input into SQL and executes queries on behalf of users across tenant boundaries. Because the agent operates with broad data reach, it needs a robust, fine-grained access control mechanism that makes sure each user can access only their authorized tables, columns, rows, or cells.

AWS offers various data authorization mechanisms to fit different use cases.

  1. AWS IAM Identity Center (IDC) + Trusted Identity Propagation (TIP) + AWS Lake Formation (see the blog post Simplify data lake access control for your enterprise users with trusted identity propagation in AWS IAM Identity Center, AWS Lake Formation, and Amazon S3 Access Grants).
  2. Amazon Cognito + Lake Formation.

Choosing the right identity model for external users

IAM Identity Center with Trusted Identity Propagation (TIP) is designed for workforce identity, propagating enterprise identities across AWS analytics services through SAML 2.0 and SCIM federation, making it well suited for enterprise federation scenarios.

For external-facing B2C or multitenant applications, Amazon Cognito is a better fit because it offers:

  • Mobile and single page applications (SPAs): Cognito User Pools provide OpenID Connect (OIDC) integration with hosted UI and native SDKs for browser and mobile apps.
  • Social identity federation: Cognito federates with social providers such as Google, Facebook, Apple, and other OIDC-compatible providers for frictionless consumer sign-up.
  • Self-service user management: Cognito offers built-in flows for self-registration, email/phone verification, password recovery, and multi-factor authentication (MFA), reducing operational overhead for large external user bases.

For these reasons, Cognito User Pools combined with Identity Pools provide a natural fit for external users at scale.

Who this is for

  • Software as a service (SaaS) application architects building customer-facing analytics where each tenant sees only their data.
  • B2C data product teams serving hundreds of thousands of external users.
  • Partner portal developers providing scoped data access to network partners.
  • ISVs migrating to AWS that need agentic data capabilities with tenant isolation.

Solution overview

This post shows how to apply this traditional Cognito and Lake Formation approach on AgentCore Gateway, a capability of Amazon Bedrock AgentCore. The result is a data agent that enforces row- and column-level security based on the authenticated user’s identity. Amazon Bedrock AgentCore provides a managed environment for deploying and operating agents at scale. AgentCore Gateway is a fully managed gateway that provides a single, secure entry point for agentic traffic, connecting agents to tools and to other agents.

Architecture and flow

The following diagram illustrates the end-to-end architecture.

End-to-end architecture of the multi-tenant data agent

Figure 1: End-to-end architecture of the multi-tenant data agent

  1. User signs in through Cognito Hosted UI and receives OAuth tokens. The ID token contains the custom:team claim.
  2. User sends a natural language query in the frontend.
  3. The frontend invokes AgentCore runtime with the access token (Authorization header) and ID token (X-ID-Token header).
  4. AgentCore runtime forwards both tokens through the agent code to the MCP Gateway.
  5. MCP Gateway validates the access token with JWT authorizer and propagates X-ID-Token header to the AWS Lambda Target.
  6. Target Lambda (with minimal permissions) receives the ID token from X-ID-Token.
  7. Cognito Identity Pool exchanges the ID token with GetCredentialsForIdentity operation for temporary AWS credentials scoped to the user’s AWS Identity and Access Management (IAM) role through rule-based mapping.
  8. Lambda executes the Amazon Athena query using those user-scoped credentials.
  9. Lake Formation evaluates the IAM role’s LF-Tag permissions and data cell filters to enforce row- and column-level security.
  10. User receives only their authorized data.

Technical design: defining permissions

The following diagram shows how the custom:team attribute flows through the permission chain.

Permission chain mapping the team tag into Lake Formation permissions

Figure 2: Data access permission chain mapping the team tag into Lake Formation permissions

  1. Custom attribute (Cognito User Pool): Each user is tagged with custom:team. The value appears as a claim in the ID token.
  2. Rule-based mapping (Cognito Identity Pool): The team claim maps to a corresponding IAM role ARN through rule-based mapping.
  3. Data permission for IAM role (Lake Formation): The IAM role is granted access to AWS Glue Data Catalog tables through the Lake Formation permission system, which supports tag-based access control (TBAC) and role-based access control (RBAC).
  4. Tag-based access control (Lake Formation): A Lake Formation tag (LF-Tag) applied at the table or row level (through data cell filters) determines what data each role can access.

Custom attribute (Cognito User Pool)

The User Pool holds your external users with custom attributes that drive access control:

# User Pool with custom team attribute
self.user_pool = cognito.UserPool(
    self, "UserPool",
    custom_attributes={
        "team": cognito.StringAttribute(mutable=True),
    },
)

Users authenticate and receive an ID token containing their claims:

{
    "sub": "abc-123",
    "email": "[email protected]",
    "custom:team": "Team A"
}

Here’s how the user attribute looks in the Cognito User Pool.

The custom:team attribute defined in the Cognito User Pool console

Figure 3: The custom:team attribute defined as a custom attribute in the Cognito User Pool

Rule-based mapping (Cognito Identity Pool)

The Identity Pool maps these claims to IAM roles using rules-based role mapping:

# Rules: custom:team claim → IAM role
cognito.CfnIdentityPoolRoleAttachment(
    self, "RoleAttachment",
    identity_pool_id=identity_pool.ref,
    role_mappings= {
        "cognito": {
            "type": "Rules",
            "rules_configuration": {
                "rules": [
                    {
                        "claim": "custom:team",
                        "match_type": "Equals",
                        "value": "Team A",
                        "role_arn": team_a_role.role_arn,
                    },
                    {
                        "claim": "custom:team",
                        "match_type": "Equals",
                        "value": "Team B",
                        "role_arn": team_b_role.role_arn,
                    },
                ],
            },
        },
    },
)

When the Target Lambda exchanges the ID token, Identity Pool reads custom:team, matches it to a rule, and returns temporary credentials for the corresponding IAM role.

Role mapping rules in the Cognito Identity Pool that map the team attribute

Figure 4: The team attribute mapped by role mapping rules defined in the Cognito Identity Pool

Data permission granted to IAM role (Lake Formation)

With Lake Formation TBAC, you can control access declaratively with tags instead of writing filtering logic in your application. The two IAM roles are granted access to resources assigned to an LF-Tag.

Define an LF-Tag:

Tag Key: team
Tag Values: TeamA, TeamB, shared

The following figure shows the LF-Tag definition in the Lake Formation console, with the tag key “team” and its three allowed values.

Lake Formation LF-Tags for team-level data access

Figure 5: Lake Formation LF-Tags for team-level data access

Grant tag-based permissions to each role:

# TeamA role can access TeamA + shared tagged resources
aws lakeformation grant-permissions \
    --principal '{"DataLakePrincipalIdentifier": "arn:aws:iam::ACCOUNT:role/TeamA"}' \
    --resource '{"LFTagPolicy": {"ResourceType": "TABLE", "Expression": [{"TagKey": "team", "TagValues": ["TeamA", "shared"]}]}}' \
    --permissions "SELECT" "DESCRIBE"

The following two figures show the resulting Lake Formation data permissions for the TeamA and TeamB IAM roles, including table-level access through LF-Tags and row-level access through data cell filters.

Lake Formation permissions granting the TeamA IAM role table and row-level access

Figure 6: Lake Formation data permissions granting the TeamA IAM role table access through LF-Tags and row-level access through a data cell filter

Lake Formation permissions granting the TeamB IAM role table and row-level access

Figure 7: Lake Formation data permissions granting the TeamB IAM role table access through LF-Tags and row-level access through a data cell filter

Lake Formation data access control (TBAC)

AWS Glue Data Catalog resources are assigned to an LF-Tag. Assignment can be at the database, table, or row level. This example includes table-level and row-level assignment (through the use of a data cell filter).

Assign tags to tables:

Table LF Tag Who Can Access
venue team=TeamA Team A only
category team=TeamB Team B only
users team=shared Both teams
sales_extended Row filter Per-team rows

The three figures that follow show the LF-Tag assignment for each table in the Lake Formation console: “venue” tagged as TeamA, “category” tagged as TeamB, and “users” tagged as shared.

LF-Tag assignment on the venue table with team value TeamA

Figure 8: LF-Tag assignment on the venue table for the team tag with value TeamA

LF-Tag assignment on the category table with team value TeamB

Figure 9: LF-Tag assignment on the category table for the team tag with value TeamB

LF-Tag assignment on the users table with team value shared

Figure 10: LF-Tag assignment on the users table for the team tag with value shared

For row-level security, create data cell filters:

# TeamA only sees rows where team = 'Team A'
aws lakeformation create-data-cells-filter \
    --table-data '{
    "DatabaseName": "tickit2",
    "TableName": "sales_extended",
    "Name": "TeamAFilter",
    "RowFilter": {"FilterExpression": "team = '\''Team A'\''"},
    "ColumnWildcard": {}
}'

The following three figures show the data cell filter configuration in the Lake Formation console: an overview of the two filters applied to the “sales_extended” table, followed by the row filter expression for Team A and the row filter expression for Team B.

Two data cell filters applied to the sales_extended table

Figure 11: Two data cell filters applied to the sales_extended table, one per team

Data cell filter with the row filter expression for Team A

Figure 12: Data cell filter defined with the row filter expression team = ‘Team A’

Data cell filter with the row filter expression for Team B

Figure 13: Data cell filter defined with the row filter expression team = ‘Team B’

Technical design: enforcing permissions

A key design consideration in this solution is how role mapping runs securely without tampering. The design uses the Lambda target as a credential broker that exchanges the user-scoped ID token for an IAM data role through Identity Pool. The Lambda execution role has minimal IAM permissions to the Cognito Identity Pool and no data permissions. It only assumes the IAM data roles. The ID token propagated through AgentCore Gateway is validated intact with an inbound JWT authorizer.

AgentCore Gateway: Header propagation

The AgentCore Gateway propagates the ID token to the Target Lambda using metadataConfiguration on the gateway target:

{
    "metadataConfiguration": {
        "allowedRequestHeaders": ["X-ID-Token"],
        "allowedResponseHeaders": ["X-ID-Token"]
    }
}

The following figure shows this metadata configuration in the AgentCore Gateway console, with “X-ID-Token” listed under both the propagated request headers and propagated response headers.

X-ID-Token configured as a propagated request and response header on the AgentCore Lambda target

Figure 14: X-ID-Token configured as a propagated request header and response header on the AgentCore Lambda target

The agent code extracts both tokens from the AgentCore runtime headers and passes them to the Gateway:

@app.entrypoint
async def invoke(payload, context):
    headers = context.request_headers
    # Runtime lowercases custom headers
    access_token = headers.get('Authorization')
    id_token = headers.get(
        'x-amzn-bedrock-agentcore-runtime-custom-x-id-token')
    with get_mcp_client(access_token, id_token) as client:
        tools = client.list_tools_sync()
        agent = Agent(..., tools=tools)
        # ...

The Target Lambda receives the propagated header in:

propagated = context.client_context.custom.get(
    'bedrockAgentCorePropagatedHeaders', {}
)
id_token = propagated.get('X-ID-Token')

The Target Lambda: A credential broker, not a data accessor

This is the key architectural decision. The Target Lambda execution role has only two permissions:

self.target_fn.add_to_role_policy(iam.PolicyStatement(
    actions=[
        "cognito-identity:GetId",
        "cognito-identity:GetCredentialsForIdentity",
    ],
    resources=["*"],
))

No Athena permissions. No S3 permissions. No Glue permissions. The Lambda exchanges the user’s ID token for scoped credentials and uses those to query:

def get_identity_pool_session(id_token: str):
    """Exchange ID token for user-scoped IAM credentials."""
    cognito_identity = boto3.client('cognito-identity',
        region_name=COGNITO_REGION)
    provider = f'cognito-idp.{COGNITO_REGION}.amazonaws.com/{USER_POOL_ID}'

    # Step 1: Get identity ID from token
    identity = cognito_identity.get_id(
        IdentityPoolId=IDENTITY_POOL_ID,
        Logins={provider: id_token}
    )

    # Step 2: Get credentials (role determined by Identity Pool rules)
    creds = cognito_identity.get_credentials_for_identity(
        IdentityId=identity['IdentityId'],
        Logins={provider: id_token}
    )

    # Step 3: Create session with user-scoped credentials
    return boto3.Session(
        aws_access_key_id=creds['Credentials']['AccessKeyId'],
        aws_secret_access_key=creds['Credentials']['SecretKey'],
        aws_session_token=creds['Credentials']['SessionToken']
    )

The authenticated role: Trust and permissions

Each team role trusts the Cognito Identity Pool and has Lake Formation + Athena permissions:

{
    "Effect": "Allow",
    "Principal": {
        "Federated": "cognito-identity.amazonaws.com"
    },
    "Action": "sts:AssumeRoleWithWebIdentity",
    "Condition": {
        "StringEquals": {
            "cognito-identity.amazonaws.com:aud": "<IDENTITY_POOL_ID>"
        },
        "ForAnyValue:StringLike": {
            "cognito-identity.amazonaws.com:amr": "authenticated"
        }
    }
}

The role’s IAM policy grants Amazon Athena, AWS Glue, Amazon Simple Storage Service (Amazon S3), and lakeformation:GetDataAccess. But Lake Formation TBAC further restricts what data the role can actually see based on its LF-Tag permissions.

The Athena query then runs under the user’s assumed role:

def execute_query(session, sql):
    # This Athena client uses the user's credentials, not Lambda's
    athena = session.client('athena', region_name=ATHENA_REGION)
    response = athena.start_query_execution(
        QueryString=sql,
        QueryExecutionContext = {'Database': 'tickit2'},
        ResultConfiguration = { 'OutputLocation': ATHENA_OUTPUT }
    )
    # Lake Formation enforces TBAC transparently
    ...

Deployment

Our solution is implemented and wrapped within AWS Cloud Development Kit (AWS CDK) stacks, which are available in the GitHub repository.

Prerequisites

To deploy the solution, you need the following prerequisites:

  1. An AWS account.
  2. AWS Command Line Interface (AWS CLI).
  3. Valid AWS credentials configured for the AWS CLI. For more information, see Configuring settings for the AWS CLI.
  4. Python 3.10+.
  5. uv Python package manager. For more information, see Installing uv.
  6. Docker.
  7. Node.js and npm installed for the frontend.
  8. AWS CDK. For more information, see Getting started with the AWS CDK.

Manual preparation before deployment

Before deploying the CDK stacks, complete the following manual steps:

  1. Register the CDK CloudFormation execution role as a Lake Formation administrator.

The FgacLakeFormationStack creates LF-Tags and grants Lake Formation permissions. This requires the CloudFormation execution role to be a Lake Formation Data Lake Administrator. Follow Create a data lake administrator for detailed instructions.

Adding an IAM role as a Data Lake Administrator in Lake Formation

Figure 15: Adding an IAM role identity as a Data Lake Administrator in Lake Formation

Run the following AWS CLI command (replace [<YOUR_PROFILE>], [<ACCOUNT_ID>], [<REGION>] with your values):

aws lakeformation put-data-lake-settings \
    --profile [<YOUR_PROFILE>] \
    --region [<REGION>] \
    --data-lake-settings '{
    "DataLakeAdmins": [
        {"DataLakePrincipalIdentifier": "arn:aws:iam:: [<ACCOUNT_ID>]:role/cdk-hnb659fds-cfn-exec-role-[<ACCOUNT_ID>]-[<REGION>]"}
    ]
}'

To verify, run the following AWS CLI command:

aws lakeformation get-data-lake-settings \
    --profile [<YOUR_PROFILE>] \
    --region [<REGION>] \
    --query "DataLakeSettings.DataLakeAdmins"

Note: If you already have existing Lake Formation admins, include them in the DataLakeAdmins array to avoid removing them. Use get-data-lake-settings first to check.

  1. Download seed data.

The data lake stack deploys seed CSV data to S3. Run below python script to download it first on your environment:

python backend/infra/seed_data/download_tickit.py
  1. Log in to Amazon Elastic Container Registry (Amazon ECR) before deploying.

Run this AWS CLI command (replace [<YOUR_PROFILE>], [<ACCOUNT_ID>], [<REGION>] with your values)

aws ecr get-login-password --region [<REGION>] --profile [<YOUR_PROFILE>] | docker login --username AWS --password-stdin [<ACCOUNT_ID>].dkr.ecr. [<REGION>].amazonaws.com

Deploying the solutions

  1. Deploy the backend CDK stack.
cd backend/infra
uv sync
uv run cdk deploy --region [<REGION>] --profile [<YOUR_PROFILE>] --all

Enter “y” when asked to deploy security-sensitive updates on various stacks.

"--require-approval" is enabled and stack includes security-sensitive updates: Do you wish to deploy these changes? (y/n)y
  1. Install dependencies and start frontend.
cd ../..
cd frontend/data-chatbot
npm install

# Download backend configurations that powers the frontend
bash scripts/load-config.sh --region [<REGION>] --profile [<YOUR_PROFILE>]
#
# Loading config from SSM Parameter Store (region: [<REGION>], profile: [<YOUR_PROFILE>])...
# user_pool_id: us-east-1_2*******4
# user_pool_client_id: nkp9p****************ebv
# cognito_region: us-east-1
# agent_arn: arn:aws:bedrock-agentcore: [<REGION>]:[<ACCOUNT_ID>]:runtime/fgacdataagent-D6******hQ
agentcore_region: us-east-1
# Generated .env.local
# Generated amplify_outputs.json
# Done.

npm run dev
# > [email protected] dev
# > next dev
#
# ▲ Next.js 14.2.10
# - Local: http://localhost:3000
# - Environments: .env.local
#
# ✓ Starting...
# ✓ Ready in 2.4s

Frontend should start at http://localhost:3000

The sample data chat frontend application

Figure 16: The sample data chat frontend application

Scenarios walkthrough

This section walks through four scenarios that verify tenant-scoped data access. You log in as Team A, then Team B, and confirm each user sees only their authorized data. You then grant Team A additional access through Lake Formation and verify the updated permissions take effect.

View per-team data access

Each user’s custom:team attribute maps to a team-specific IAM role that scopes their data access. The following steps verify isolation for both Team A and Team B.

Expected access:

 

Team A user Team B user
sales_extended rows team = ‘Team A’ only team = ‘Team B’ only
Accessible tables venue (TeamA), users (shared) category (TeamB), users (shared)
Blocked table Category venue

Steps (repeat for each team user):

  1. Navigate to http://localhost:3000 and log in. You can find the username and password in the cdk.json file.
The Cognito hosted UI in the chat application

Figure 17: The Cognito hosted UI in the chat application

  1. Enter “Show me all the tables available in the tickit2 database” (or choose Show all tables). Confirm the table list matches the expected access.
Chatbot showing the table list for Team A

Figure 18: Chatbot returning the table list from natural-language input for Team A

Chatbot showing the table list for Team B

Figure 19: Chatbot returning the table list from natural-language input for Team B

  1. Enter “Query the sales_extended table and count the number of rows grouped by team.” (or choose Count sales by team). Confirm only the user’s own team rows appear.
Chatbot showing sales_extended rows for Team A only

Figure 20: Chatbot returning sales_extended records for Team A only from natural-language input

Chatbot showing sales_extended rows for Team B only

Figure 21: Chatbot returning sales_extended records for Team B only from natural-language input

  1. Choose Sign out in the top-right corner before switching users.

Figure 22: Signing out of the Team A user

Figure 23: Signing out of the Team B user

Grant extra access to Team A user

In Lake Formation, grant the Team A user access to the Team B records in the sales_extended table and to the category table. Use the following AWS CLI commands or follow the equivalent console steps shown in Figures 24–29.

Step 1: Grant ASSOCIATE permission on the team LF-Tag (replace [<ADMIN_ROLE_OR_USER_ARN>] with the ARN of the IAM identity you use to manage LF-Tags):

aws lakeformation grant-permissions \
    --principal '{"DataLakePrincipalIdentifier": "[<ADMIN_ROLE_OR_USER_ARN>]"}' \
    --resource '{"LFTag": {"TagKey": "team", "TagValues": ["TeamA", "TeamB", "shared"]}}' \
    --permissions "ASSOCIATE" \
    --region [<REGION>] --profile [<YOUR_PROFILE>]

Alternatively, in the Lake Formation console, navigate to LF-Tags and permissions, open the LF-Tag permissions tab, choose Grant permissions, select LF-Tag key-value pair permissions, pick your admin identity as the principal, select the team key with all values, check Associate, and choose Grant (Figures 24–26).

Choosing LF-Tag key-value pair permissions

Figure 24: Choosing LF-Tag key-value pair permissions

Choosing the identity used to manage the LF-Tag

Figure 25: Choosing the identity used to manage the LF-Tag

Assigning ASSOCIATE permission on all values of the team LF-Tag

Figure 26: Assigning the ASSOCIATE permission for the identity on all values of the team LF-Tag

Step 2: Change the category table tag from TeamB to shared:

# Remove the existing TeamB tag
aws lakeformation remove-lf-tags-from-resource \
    --resource '{"Table": {"DatabaseName": "tickit2", "Name": "category"}}' \
    --lf-tags '[{"TagKey": "team", "TagValues": ["TeamB"]}]' \
    --region [<REGION>] --profile [<YOUR_PROFILE>]

# Assign the shared tag
aws lakeformation add-lf-tags-to-resource \
    --resource '{"Table": {"DatabaseName": "tickit2", "Name": "category"}}' \
    --lf-tags '[{"TagKey": "team", "TagValues": ["shared"]}]' \
    --region [<REGION>] --profile [<YOUR_PROFILE>]

Alternatively, in the Lake Formation console, navigate to Tables and materialized views, find category in database tickit2, open the LF-Tags tab, and change the value from TeamB to shared.

Editing the LF-Tag on the category table

Figure 27: Editing the LF-Tag on the category table

Editing the LF-Tag value from TeamB to shared

Figure 28: Editing the LF-Tag value from TeamB to shared to grant table access to Team A as well

Modifying the row filter expression in the data cell filter

Figure 29: Modifying the row filter expression in the data cell filter to grant Team A users access to Team B records

Step 3: Update the data cell filter to include Team B rows for Team A:

aws lakeformation update-data-cells-filter \
    --region [<REGION>] --profile [<YOUR_PROFILE>] \
    --table-data '{
    "DatabaseName": "tickit2",
    "TableName": "sales_extended",
    "Name": "TeamAFilter",
    "RowFilter": {"FilterExpression": "team = '\''Team A'\'' OR team = '\''Team B'\''"},
    "ColumnWildcard": {}
}'

Alternatively, in the Lake Formation console, navigate to Data filters, find TeamAFilter, and modify the row filter expression to team = 'Team A' OR team = 'Team B'.

Verify updated Team A user permissions

Note: It may take several minutes for Lake Formation permissions to propagate.

Log in again as the Team A user. Run the same two queries from the View per-team data access section:

  1. Show all tables: You should now also see the category table.
Team A user can now also see the category table

Figure 30: Team A user can now also see the category table

  1. Count sales by team: You should now see rows for both Team A and Team B.
Team A user can now see Team B records in the sales_extended table

Figure 31: Team A user can now see records in the sales_extended table that belong to Team B

Clean up

To remove all components generated, run the following command:

uv run cdk destroy --all --profile <YOUR_PROFILE> --region <REGION>

Security considerations

  • Principle of least privilege: The Lambda has no data permissions and can only broker credential exchange.
  • Scoped data access: Data access flows through user-scoped credentials with Lake Formation enforcement.
  • OAuth token validated at Cognito level: IAM authentication at AgentCore Gateway level.
  • Audit trail: AWS CloudTrail and Lake Formation audit logs trace data access to individual users.
  • No tag injection risk: Session tags derived from validated token claims through Identity Pool.

Conclusion

If you’re building a SaaS application, partner portal, or B2C product where external users query a shared data lake, the hardest part isn’t the AI. It’s making sure every user sees only the data they’re authorized to access.

By combining Cognito User Pools (authentication), Cognito Identity Pools (token-to-credential exchange), Amazon Bedrock AgentCore (agentic platform), and Lake Formation TBAC (declarative access control), you can build a governed multi-tenant data agent that delivers on both fronts:

  • Zero-trust data isolation: Lake Formation scopes each query to the authenticated user’s role, enforcing access in the authorization chain rather than in prompt logic.
  • Minimal-privilege architecture: The Target Lambda acts only as a credential broker, without holding broad data permissions.
  • Declarative governance at scale: LF-Tags enforce row- and column-level security centrally, with no filtering logic in application code.
  • Audit readiness: CloudTrail and Lake Formation audit logs trace data access from authentication through query execution.

This pattern applies wherever external users need scoped data lake access: SaaS analytics, partner portals, customer-facing dashboards, or multi-tenant data products. To get started, review the Lake Formation tag-based access control documentation and the Amazon Bedrock AgentCore documentation, then deploy the sample solution to try it in your own account.


About the author

Fortune Hui

Fortune Hui

Fortune is a Solutions Architect at AWS Hong Kong, working with conglomerate customers. He helps customers and partners build big data platform and Agentic AI applications. In his free time, he plays badminton and enjoys whisky.

Implementing PostgreSQL logical replication between Amazon RDS for PostgreSQL instances on separate AWS Outposts

Post Syndicated from Belma Canik original https://aws.amazon.com/blogs/compute/implementing-postgresql-logical-replication-between-amazon-rds-for-postgresql-instances-on-separate-aws-outposts/

Organizations in regulated industries such as healthcare, financial services, and government often face strict data residency mandates that require all data, including database backups, to remain within their on-premises environment. You can keep backups on-premises with Amazon Relational Database Service (Amazon RDS) local backups on AWS Outposts by setting the backup target to AWS Outposts (--backup-target outposts). Your AWS Outposts must have Amazon Simple Storage Service (Amazon S3) on Outposts capacity to store those backups locally. While Amazon RDS local backups on Outposts can keep backup data on-premises, organizations that need high availability across two separate Outposts racks, without any data traversing the AWS Region, lack a built-in, native replication mechanism between Outpost-hosted RDS instances.

In this post, we demonstrate how you can configure PostgreSQL logical replication between two Amazon RDS instances running on separate AWS Outposts racks, so that all data remains within your on-premises environment. By using Customer-owned IP addresses (CoIP) and Local Gateways (LGW), you can establish secure, low-latency replication between Outposts without exposing any data to the Region.

Architecture overview

Our solution uses two AWS Outpost racks, each with its own Local Gateway (LGW) configured in Customer-owned IP (CoIP) mode. In this architecture, all database traffic flows through your on-premises network infrastructure:

Two AWS Outposts racks replicating Amazon RDS data through Local Gateways over the on-premises network, bypassing the AWS Region

Figure 1: Replication architecture across two AWS Outposts racks using Customer-owned IP addresses and Local Gateways

Prerequisites

Before beginning the configuration, make sure you have:

  • Two AWS Outpost racks with Local Gateways (LGW) configured in CoIP mode.
  • CoIP pools configured and shared with your AWS account.
  • LGW route table associations with your virtual private cloud (VPC) in place.
  • On-premises network connectivity between the two Outpost Local Gateways.
  • VPC with subnets spanning both Outposts.
  • Security groups configured to allow PostgreSQL traffic (port 5432).

Step 1: Create Outpost subnets with CoIP configuration

Each Outpost needs a subnet with CoIP auto-assign enabled.

While Amazon Elastic Compute Cloud (Amazon EC2) instances can inherit CoIP assignment from subnet settings, RDS instances require explicit CoIP enablement (covered in Step 3).

Subnet on Outpost A

aws ec2 create-subnet \
--vpc-id vpc-xxx \
--cidr-block 10.0.1.0/24 \
--availability-zone us-west-2a \
--outpost-arn arn:aws:outposts:us-west-2:OWNER_ACCOUNT:outpost/op-xxx

# Enable CoIP auto-assign (applies to EC2, not RDS)
aws ec2 modify-subnet-attribute \
--subnet-id subnet-xxx \
--map-customer-owned-ip-on-launch \
--customer-owned-ipv4-pool ipv4pool-coip-xxx

Subnet on Outpost B

aws ec2 create-subnet \
--vpc-id vpc-xxx \
--cidr-block 10.0.2.0/24 \
--availability-zone us-west-2b \
--outpost-arn arn:aws:outposts:us-west-2:OWNER_ACCOUNT:outpost/op-yyy

aws ec2 modify-subnet-attribute \
--subnet-id subnet-outpost-b \
--map-customer-owned-ip-on-launch \
--customer-owned-ipv4-pool ipv4pool-coip-yyy

Important: The MapCustomerOwnedIpOnLaunch setting works for EC2 instances but doesn’t automatically apply to RDS instances. RDS requires explicit CoIP enablement during instance creation.

Create DB subnet group

Create a DB subnet group spanning both Outposts:

aws rds create-db-subnet-group \
--db-subnet-group-name rds-outposts-subnet-group \
--db-subnet-group-description "Subnet group spanning Outpost A and B" \
--subnet-ids subnet-outpost-a subnet-outpost-b

Step 2: Configure route tables for cross-Outpost communication

This configuration step is often overlooked. Each subnet’s route table requires two Local Gateway routes:

  1. Cross-outpost route: To reach the other Outpost’s CoIP range.
  2. Same-outpost hairpin route: To reach its own CoIP range, which is required because RDS DNS resolves to CoIP. This affects any resource on the subnet that needs to reach the RDS instance by endpoint, including Amazon EC2 application servers on the same Outpost.

Why the hairpin route is important

When you enable CoIP on RDS, the DNS endpoint resolves to the CoIP address instead of the private IP. EC2 instances (and other RDS instances) on the same subnet need a route through the LGW to reach CoIP addresses, even for same-Outpost traffic. Without the hairpin route, traffic to the local CoIP range matches the default route (NAT gateway or internet gateway) and is lost.

Route configuration for Outpost A

# Cross-outpost: reach Outpost B's CoIP via Outpost A's LGW
aws ec2 create-route \
--route-table-id rtb-outpost-a \
--destination-cidr-block 198.19.5.0/24 \
--local-gateway-id lgw-A

# Same-outpost hairpin: reach Outpost A's own CoIP via its LGW
aws ec2 create-route \
--route-table-id rtb-outpost-a \
--destination-cidr-block 198.19.9.0/24 \
--local-gateway-id lgw-A

Route configuration for Outpost B

# Cross-outpost: reach Outpost A's CoIP via Outpost B's LGW
aws ec2 create-route \
--route-table-id rtb-outpost-b \
--destination-cidr-block 198.19.9.0/24 \
--local-gateway-id lgw-B

# Same-outpost hairpin: reach Outpost B's own CoIP via its LGW
aws ec2 create-route \
--route-table-id rtb-outpost-b \
--destination-cidr-block 198.19.5.0/24 \
--local-gateway-id lgw-B

Final route table summary

Outpost A Route Table:

Destination Target Purpose
10.0.0.0/16 local VPC internal traffic
198.19.5.0/24 (Outpost B CoIP) lgw-A Cross-outpost
198.19.9.0/24 (Outpost A CoIP) lgw-A Same-outpost hairpin
0.0.0.0/0 NAT GW Internet access

Outpost B Route Table:

Destination Target Purpose
10.0.0.0/16 local VPC internal traffic
198.19.9.0/24 (Outpost A CoIP) lgw-B Cross-outpost
198.19.5.0/24 (Outpost B CoIP) lgw-B Same-outpost hairpin
0.0.0.0/0 NAT GW Internet access

Step 3: Create RDS instances with CoIP enabled

In this step, you create the Amazon RDS for PostgreSQL instances on each Outpost using the aws rds create-db-instance CLI command. CoIP provides local or external connectivity to resources in your Outpost subnets through your on-premises network. It provides lower latency for connections to the DB instance from outside of its VPC on your local network. When you create an Amazon RDS instance on an Outpost, you can enable CoIP to make sure the instance is reachable over your on-premises network through the Local Gateway rather than the Region.

The --enable-customer-owned-ip flag must be explicitly set. RDS doesn’t inherit the subnet’s MapCustomerOwnedIpOnLaunch setting.

Keep in mind that this property isn’t supported in AWS CloudFormation. Use the AWS Command Line Interface (AWS CLI), Terraform (customer_owned_ip_enabled = true), or the console (select Customer-owned IP address (CoIP) under Access type).

Create the publisher instance (Outpost A)

aws rds create-db-instance \
--db-instance-identifier rds-publisher \
--db-instance-class db.r5.xlarge \
--engine postgres \
--engine-version 17.9 \
--master-username postgres \
--master-user-password <SecurePassword> \
--allocated-storage 200 \
--storage-type gp2 \
--storage-encrypted \
--db-subnet-group-name rds-outposts-subnet-group \
--availability-zone us-west-2a \
--no-multi-az \
--no-publicly-accessible \
--enable-customer-owned-ip \
--db-parameter-group-name <parameter-group-with-logical-replication> \
--vpc-security-group-ids sg-xxx \
--region us-west-2

Create the subscriber instance (Outpost B)

aws rds create-db-instance \
--db-instance-identifier rds-subscriber \
--db-instance-class db.r5.xlarge \
--engine postgres \
--engine-version 17.9 \
--master-username postgres \
--master-user-password <SecurePassword> \
--allocated-storage 200 \
--storage-type gp2 \
--storage-encrypted \
--db-subnet-group-name rds-outposts-subnet-group \
--availability-zone us-west-2b \
--no-multi-az \
--no-publicly-accessible \
--enable-customer-owned-ip \
--vpc-security-group-ids sg-xxx \
--region us-west-2

Verify CoIP assignment

After the instances become available, verify that CoIP addresses have been assigned:

aws ec2 describe-addresses --region us-west-2 \
--query "Addresses[?CustomerOwnedIp!=null].[CustomerOwnedIp,PrivateIpAddress,NetworkInterfaceId]" \
--output table

You should see output similar to:

-------------------------------------------------
|                DescribeAddresses              |
+------------------+-------------+--------------+
| 198.19.9.10      | 10.0.1.15   | eni-xxx      |
| 198.19.5.20      | 10.0.2.25   | eni-yyy      |
+------------------+-------------+--------------+

Step 4: Configure security groups

Security groups must allow PostgreSQL traffic (port 5432) from both CoIP ranges and private subnet ranges:

# Allow traffic from Outpost A CoIP range
aws ec2 authorize-security-group-ingress \
--group-id sg-xxx \
--protocol tcp \
--port 5432 \
--cidr 198.19.9.0/24

# Allow traffic from Outpost B CoIP range
aws ec2 authorize-security-group-ingress \
--group-id sg-xxx \
--protocol tcp \
--port 5432 \
--cidr 198.19.5.0/24

# Allow traffic from Outpost A private subnet
aws ec2 authorize-security-group-ingress \
--group-id sg-xxx \
--protocol tcp \
--port 5432 \
--cidr 10.0.1.0/24

# Allow traffic from Outpost B private subnet
aws ec2 authorize-security-group-ingress \
--group-id sg-xxx \
--protocol tcp \
--port 5432 \
--cidr 10.0.2.0/24

Make sure egress rules allow all outbound traffic (default) or at minimum port 5432 to the CoIP ranges.

Step 5: Configure PostgreSQL logical replication

Enable logical replication on the publisher

The publisher requires the rds.logical_replication parameter set to 1, which requires a database reboot:

aws rds modify-db-parameter-group \
--db-parameter-group-name publisher-params \
--parameters "ParameterName=rds.logical_replication,ParameterValue=1,ApplyMethod=pending-reboot"

aws rds reboot-db-instance --db-instance-identifier rds-publisher

This automatically sets wal_level = logical, so PostgreSQL can capture the necessary change data for replication.

Create replication user and publication on the publisher

Connect to the publisher instance and execute:

-- Create a replication user
CREATE USER repluser WITH REPLICATION LOGIN PASSWORD 'YourStrongPassword';

-- Grant SELECT on tables to replicate (required for initial COPY)
GRANT SELECT ON ALL TABLES IN SCHEMA public TO repluser;

-- Create table to replicate (example: healthcare appointments)
CREATE TABLE patient_appointments (
    appointment_id SERIAL PRIMARY KEY,
    patient_id INTEGER NOT NULL,
    patient_name VARCHAR(100), -- PHI: can be excluded from replication
    ssn VARCHAR(11), -- PHI: can be excluded from replication
    date_of_birth DATE, -- PHI: can be excluded from replication
    diagnosis_code VARCHAR(10), -- PHI: can be excluded from replication
    appointment_date DATE NOT NULL,
    doctor_id INTEGER NOT NULL,
    department VARCHAR(50),
    billing_amount DECIMAL(10,2),
    procedure_code VARCHAR(10),
    visit_type VARCHAR(20)
);

-- Create a publication for the table
CREATE PUBLICATION my_publication FOR TABLE patient_appointments;

-- Or for all tables in the schema:
-- CREATE PUBLICATION my_publication FOR ALL TABLES;

Keep in mind that the replication user must have SELECT permission on all published tables. Without it, the initial table synchronization (COPY phase) hangs indefinitely in state ‘d’ (data copy).

Create matching schema and subscription on the subscriber

Connect to the subscriber instance and execute:

-- Create the target table with IDENTICAL schema
-- The table structure must match the publisher exactly
CREATE TABLE patient_appointments (
    appointment_id SERIAL PRIMARY KEY,
    patient_id INTEGER NOT NULL,
    patient_name VARCHAR(100),
    ssn VARCHAR(11),
    date_of_birth DATE,
    diagnosis_code VARCHAR(10),
    appointment_date DATE NOT NULL,
    doctor_id INTEGER NOT NULL,
    department VARCHAR(50),
    billing_amount DECIMAL(10,2),
    procedure_code VARCHAR(10),
    visit_type VARCHAR(20)
);

-- Create the subscription
CREATE SUBSCRIPTION my_subscription
CONNECTION 'host=rds-publisher.xxx.us-west-2.rds.amazonaws.com port=5432 dbname=postgres user=repluser password=YourStrongPassword '
PUBLICATION my_publication;

PostgreSQL 15+ on RDS defaults to rds.force_ssl = 1, which means the database rejects any non-SSL connection. When CoIP is enabled, RDS on Outposts automatically uses the CoIP address for all connections from both inside and outside of the VPC. Traffic routes through the Local Gateway even for same-outpost communication. PostgreSQL’s pg_hba.conf treats these CoIP-routed connections as non-local, and with rds.force_ssl = 1, the server requires SSL. If the CREATE SUBSCRIPTION connection string omits sslmode=require, the subscriber’s connection attempt is rejected with a no pg_hba.conf entry for host error. To avoid this, always include sslmode=require in the subscription connection string:

CREATE SUBSCRIPTION my_subscription CONNECTION 'host=rds-publisher.xxx.us-west-2.rds.amazonaws.com port=5432 dbname=postgres user=repluser password=YourStrongPassword sslmode=require'
PUBLICATION my_publication;

The subscription will automatically:

  1. Create a replication slot on the publisher.
  2. Perform an initial table sync (COPY of existing data).
  3. Begin streaming ongoing changes in real-time.

Step 6: Monitor and verify replication

To monitor replication and verify that logical replication is functioning correctly between the publisher and subscriber instance, PostgreSQL provides several system catalog views and statistics views to monitor replication health. On the publisher instance, we query pg_stat_replication to confirm active write-ahead log (WAL) sender connections and track replication progress through sent and written log sequence number (LSN) positions. We also query pg_replication_slots to verify that the logical replication slot is active and its WAL retention status is healthy. On the subscriber instance, we query pg_subscription to confirm the subscription is enabled and connected to the correct upstream database. We query pg_subscription_rel to verify the synchronization state of each replicated table.

Check replication status on the publisher

-- View active replication connections
SELECT pid, usename, client_addr, state, sent_lsn, write_lsn
FROM pg_stat_replication;

-- View replication slots
SELECT slot_name, active, restart_lsn, confirmed_flush_lsn, wal_status
FROM pg_replication_slots;

Check subscription status on the subscriber

-- View subscription details
SELECT subname, subenabled, subconninfo FROM pg_subscription;

-- View subscription state
SELECT srsubstate FROM pg_subscription_rel;
-- States: 'i' = init, 'd' = data copy, 'r' = ready, 's' = synced, 'f' = finished table copy

Expected output after successful initial sync:

srsubstate
-----------
r

Test data replication

-- On publisher:
INSERT INTO patient_appointments (patient_id, appointment_date, doctor_id, department, billing_amount, visit_type)
VALUES (1001, '2026-07-15', 42, 'Cardiology', 250.00, 'Follow-up');

-- On subscriber (should appear within seconds):
SELECT count(*) FROM patient_appointments;
SELECT * FROM patient_appointments WHERE patient_id = 1001;

Troubleshooting common issues

Issue 1: RDS endpoint not reachable through CoIP

Symptom: TCP connection to CoIP address times out.

Diagnosis:

# From an EC2 instance on the same subnet
telnet rds-publisher.xxx.us-west-2.rds.amazonaws.com 5432

Check:

  • Route table has both cross-outpost and same-outpost hairpin routes.
  • Security groups allow port 5432 from CoIP ranges.
  • CoIP is enabled on the RDS instance (CustomerOwnedIpEnabled: true).

Issue 2: CoIP not assigned to RDS instance

Symptom: CustomerOwnedIpEnabled: false when describing the RDS instance.

Cause: RDS doesn’t inherit the subnet’s MapCustomerOwnedIpOnLaunch setting. Must be explicitly enabled.

Fix:

aws rds modify-db-instance \
--db-instance-identifier rds-xxx \
--enable-customer-owned-ip \
--apply-immediately

Issue 3: Initial sync stuck in state ‘d’

Symptom: pg_subscription_rel shows srsubstate = 'd' indefinitely.

Causes:

  • Replication user lacks SELECT permission on published tables.
  • Table schema mismatch between publisher and subscriber.
  • Network connectivity issue during COPY phase.

Fix:

-- On publisher: grant SELECT permission
GRANT SELECT ON ALL TABLES IN SCHEMA public TO repluser;

-- On subscriber: drop and recreate subscription
ALTER SUBSCRIPTION my_subscription DISABLE;
ALTER SUBSCRIPTION my_subscription SET (slot_name = NONE);
DROP SUBSCRIPTION my_subscription;

-- Recreate subscription
CREATE SUBSCRIPTION my_subscription CONNECTION '...' PUBLICATION my_publication;

Issue 4: “no pg_hba.conf entry for host” error

Symptom: Connection refused with pg_hba error when connecting from CoIP address.

Cause: rds.force_ssl = 1 (default in PostgreSQL 15+) requires SSL for connections from non-VPC addresses.

Fix: Make sure sslmode=require is included in the connection string:

CONNECTION 'host=... port=5432 dbname=... user=... password=... sslmode=require'

Issue 5: Subscription pointing to wrong publication

Symptom: Subscription connected but no data replicating.

Fix: Drop and recreate subscription with correct publication name:

ALTER SUBSCRIPTION my_subscription DISABLE;
ALTER SUBSCRIPTION my_subscription SET (slot_name = NONE);
DROP SUBSCRIPTION my_subscription;

-- Clean up orphaned slots on publisher if needed
-- SELECT pg_drop_replication_slot('my_subscription');

CREATE SUBSCRIPTION my_subscription
CONNECTION '...'
PUBLICATION correct_publication_name;

Data residency considerations

This architecture provides complete data residency by the following means:

  • No regional backups: Automated backups to Amazon S3 in the Region are disabled. All data remains on the Outposts.
  • On-premises replication: All replication traffic flows through Local Gateways and your on-premises network, never traversing the Region backbone.
  • Local recovery: If an Outpost fails, you can promote the subscriber to become the new primary, maintaining business continuity without regional dependencies.
  • Selective replication: With logical replication, you can exclude sensitive columns, such as protected health information (PHI) and personally identifiable information (PII), from replication by creating publications for specific columns or tables.

For customers who can accept local backups, RDS on Outposts supports local backup to S3 on Outposts for MySQL and PostgreSQL (not supported for Multi-AZ deployments or read replicas). This requires S3 on Outposts capacity.

Best practices and key takeaways

Throughout this implementation, we encountered several configuration details that are important for a successful deployment. The following best practices and key takeaways will help you avoid common pitfalls when setting up PostgreSQL logical replication between RDS instances on separate Outposts.

  • CoIP must be explicitly enabled on RDS: It isn’t inherited from subnet settings. Always include --enable-customer-owned-ip when creating RDS instances.
  • Both cross-outpost and hairpin routes are required: Without hairpin routes, same-subnet access to CoIP addresses fails because DNS resolves to CoIP instead of the private IP.
  • Replication user needs SELECT permission: Required for the initial COPY phase. Without it, the subscription hangs in state ‘d’.
  • Table schemas must be identical: The subscriber table must exactly match the publisher table structure, including column names, types, and constraints.
  • SSL is enforced by default: PostgreSQL 15+ defaults to rds.force_ssl = 1. Use sslmode=require for external connections.
  • Use Terraform or the CLI for deployment: AWS CloudFormation doesn’t support the EnableCustomerOwnedIp property. Use Terraform (customer_owned_ip_enabled = true) or the AWS CLI.
  • Monitor replication lag: Use the pg_stat_replication and pg_replication_slots views to monitor replication health and lag.
  • Plan for failover: Document procedures for promoting the subscriber to primary if Outpost A fails, including application connection string updates.

Comparison with Multi-AZ deployments

Multi-AZ deployments remain the recommended approach for high availability on Amazon RDS on AWS Outposts. They provide automatic failover with synchronous replication between a primary DB instance on one Outpost and a standby on a different Outpost. However, Multi-AZ requires two Outposts anchored to different Availability Zones, and local backups aren’t supported for Multi-AZ deployments. As AWS documentation notes, customers whose workloads must abide by data residency regulations should consult with regulators to determine whether Multi-AZ meets their requirements, because only Single-AZ instances support storing all automated backups and logs directly on the Outposts rack. This post presents an alternative approach that uses PostgreSQL logical replication between Outposts for organizations that need an additional layer of data resilience while keeping all data on-premises. It applies specifically where data residency requirements prevent the use of Regional backups and where the Multi-AZ infrastructure prerequisites (two Outposts across two Availability Zones) are not available. The two options have different characteristics:

Feature Logical Replication Multi-AZ on Outposts
Data Residency Complete (no regional backups) Automated backups to Region S3
Failover Manual promotion Automatic (1-2 minutes)
Replication Granularity Table-level or column-level Entire database instance
Cross-Region DR Supported Requires additional setup
Network Path CoIP through Local Gateway VPC internal (Multi-AZ)
Use Case Strict data residency, selective replication High availability with regional integration

For organizations that can accept automated backups to the Region, Multi-AZ deployments offer simpler management and automatic failover. For those with absolute data residency requirements, logical replication with CoIP provides complete control over data locality.

Clean up

When you’re done testing, remove the replication resources to avoid unnecessary costs and WAL accumulation.

On the target, drop the subscription first:

DROP SUBSCRIPTION my_subscription;

On the source, drop the publication:

DROP PUBLICATION my_publication;

Drop the test tables on both source and target:

DROP TABLE IF EXISTS patient_appointments;

If you created dedicated Amazon RDS instances for this walkthrough, delete them through the AWS Management Console or CLI to stop incurring charges. If you modified an existing parameter group, consider reverting rds.logical_replication to 0 if you no longer need logical replication, and reboot the instance.

Conclusion

In this post, we showed you how to configure PostgreSQL logical replication between Amazon RDS for PostgreSQL instances on separate AWS Outposts. This approach gives organizations with strict data residency requirements a way to keep all data on-premises. By using Customer-owned IP addresses and Local Gateways, you can establish secure, low-latency replication that keeps all data within your on-premises environment.

This architecture is particularly valuable for:

  • Healthcare providers managing PHI under the Health Insurance Portability and Accountability Act (HIPAA).
  • Financial institutions with data sovereignty requirements.
  • Government agencies with classified or sensitive data.
  • Organizations in regulated industries where data must remain on-premises.

The configuration requires careful attention to networking details, particularly the hairpin routes and explicit CoIP enablement. After it’s established, it provides a foundation for disaster recovery and high availability without compromising data residency.

For more information about Amazon RDS on AWS Outposts, visit the RDS on Outposts documentation and the AWS Outposts High Availability Design whitepaper.


About the authors

Belma Canik

Belma Canik

Belma is a Senior Database Specialist Technical Account Manager (STAM) at Amazon Web Services. She helps customers run optimized workloads on AWS and make the best out of their cloud journey. She also helps customers have robust Data Operations that also provide AI-ready data platforms through Data Operations Review at AWS.

Plan and measure EC2 flexibility with Amazon EC2 Capacity Manager

Post Syndicated from Yagya Vir Singh original https://aws.amazon.com/blogs/compute/plan-and-measure-ec2-flexibility-with-amazon-ec2-capacity-manager/

Global enterprises that run thousands of workloads across hundreds of accounts often configure them to use multiple instance types and Availability Zones. This flexibility gives Amazon Elastic Compute Cloud (Amazon EC2) more options to fulfill each request. At that scale, you need to decide which instance types and Availability Zones to include, and have visibility into how well those choices are working.

To gain these insights, Amazon EC2 Capacity Manager offers real-time supportability checks and capacity fulfillment metrics. Supportability checks help you find capacity when you plan a workload by scoring how well each instance type and Availability Zone can support a request. They do this without launching or reserving instances. Capacity fulfillment metrics show whether you configured your launch requests to use multiple instance types and Availability Zones, their fulfillment rate, and where that capacity came from. They measure your actual launches, not the accuracy of a check. Together, they cover both sides of a scale up: deciding where to run, and measuring what was delivered.

Both capabilities are included with AWS Unified Operations, the AWS Support plan for global enterprises that run workloads at large scale. You can query data from all commercial AWS Regions using the Region in which you enabled EC2 Capacity Manager.

Two panels. Plan before you scale up: real-time supportability checks return a High, Medium, or Low score for each option plus an aggregate. Measure what was delivered: capacity fulfillment metrics show a fulfillment rate and a breakdown by instance type and Availability Zone.

Figure 1: Supportability checks score options before you scale up. Fulfillment metrics measure how your actual launches were fulfilled.

This post follows the platform team at AnyCompany, a global enterprise that supports several large scale ups each year. Before each one, the team wants to know which instance types and Availability Zones will best support a workload. In this post, a combination means one instance type in one Availability Zone, such as r5.16xlarge in use1-az5. Capacity Manager measures capacity in vCPUs, and each step shows the AWS Management Console path and the AWS Command Line Interface (AWS CLI) command.

Before you begin

  • Turn on Capacity Manager at the organization level (from the management account or a delegated administrator) or for a single account. Both capabilities work at either level. See Enabling EC2 Capacity Manager.
  • Confirm your support plan. Your account must be covered by AWS Unified Operations.
  • (Optional) To see metrics by workload, activate a workload tag as a monitored tag key. Some tags, including aws:eks:cluster-name and aws:autoscaling:groupName, are always available and need no activation. This post uses aws:eks:cluster-name. For more information, see Managing monitored tag keys.

Step 1: See whether your workloads are getting the capacity they need

Capacity fulfillment metrics start at the request level. A request that can use several combinations is fulfilled if any one of them delivers, so a high request fulfillment rate means flexibility is working. Capacity Manager tracks four request-level metrics.

Metric Console label What it tells you
CapacityTotalRequestedVcpu Requested (vCPUs) Capacity requested. Retries within five minutes for the same configuration count once.
CapacityTotalFulfilledVcpu Fulfilled (vCPUs) Capacity delivered.
CapacityRequestFulfillmentRate Request fulfillment rate Fulfilled divided by requested, vCPU-weighted.
AvgInstAzCountPerRequestedVcpu Instance Type x AZ count (avg) Combinations each request is configured to use. A value above 1 means the request is flexible.

These metrics break down by account, Region, and tag. To review them on the console, complete the following steps.

  1. Open the Fulfillment tab.
  2. Choose a date range and a metric set (On-Demand or Spot).
  3. Filter by a dimension, such as Region, account, or tag, to see one row for each value.
  4. Sort by Requested (vCPUs) to see your largest workloads first, or by request fulfillment rate to find the ones with the most room to improve.
Capacity Manager Fulfillment tab listing workloads by the aws:eks:cluster-name tag, each row showing its request fulfillment rate and Instance Type x AZ count

Figure 2: The Fulfillment tab for an example account, filtered by the aws:eks:cluster-name tag. Each row shows one workload’s request fulfillment rate and Instance Type x AZ count.

At AnyCompany, the search-index workload requested 51,712 vCPUs and received 45,632, an 88.2 percent request fulfillment rate. Its requests could use an average of 21 combinations. Compare batch-etl, which can use only one combination and fulfills 72.8 percent. Step 2 shows where the search-index capacity came from and where it fell short.

To get the same data with the AWS CLI, call get-capacity-manager-metric-data. Spot metrics use a Spot prefix, such as SpotCapacityRequestFulfillmentRate.

aws ec2 get-capacity-manager-metric-data \
--metric-names capacity-request-fulfillment-rate avg-inst-az-count-per-requested-vcpu \
--start-time 2026-11-02T19:00:00Z --end-time 2026-11-03T19:00:00Z \
--period 86400 \
--filter-by 'DimensionCondition={Dimension=tag:aws:eks:cluster-name,Comparison=equals,Values=[search-index]}'

Step 2: See where your flexibility is paying off

The breakdown measures each combination on its own, so per-combination rates are usually lower than the request rate, which includes the benefit of diversification.

Breakdown for the search-index workload showing lower per-combination fulfillment rates against the 88.2 percent request rate

Figure 3: The search-index request is 88.2 percent fulfilled, while each combination is measured on its own.

In Figure 3, the three search-index combinations in use1-az5 deliver 68.6–82.8 percent of what is asked. Outside use1-az5, r5.16xlarge delivers 98.5–100 percent in use1-az4 and use1-az6, and r6a.16xlarge delivers 100 percent in use1-az4. Because the request can use any of these combinations, the other zones cover part of what use1-az5 can’t. So those zones are worth keeping, and use1-az5 is where to add options or reserve capacity.

On the console, choose View breakdown on any row of the Fulfillment tab. The breakdown lists each combination, and you can sort by fulfillment rate, fulfilled vCPUs, or usage. It adds instance type, instance family, and Availability Zone to the dimensions from Step 1.

Fulfillment breakdown for search-index with per-combination rates and a Check supportability link on each row

Figure 4: The breakdown for the search-index workload, with a Check supportability link on each row.

In Figure 4, search-index fulfills 88.2 percent of requested capacity, while its 28 combinations average 46.6 percent on their own. The difference is what flexibility adds: when some combinations come up short, others deliver. The three use1-az5 combinations delivered 26,880 vCPUs, more than half of the 45,632 total, so that zone has the most room to improve. The Check supportability link on each row opens a check for that combination.

Through the API, query InstAzFulfillmentRate. The request must include the availability-zone-id dimension and either instance-type or instance-family.

aws ec2 get-capacity-manager-metric-data \
--metric-names inst-az-fulfillment-rate \
--start-time 2026-11-02T19:00:00Z --end-time 2026-11-03T19:00:00Z \
--period 86400 \
--group-by availability-zone-id instance-type \
--filter-by 'DimensionCondition={Dimension=tag:aws:eks:cluster-name,Comparison=equals,Values=[search-index]}'

Step 3: Find where capacity is strongest before you scale up

Before a large scale up or migration, the platform team runs a supportability check. They provide the market type (On-Demand or Spot), the capacity amount, the Availability Zones, and the instance types to consider. The check returns a High, Medium, or Low score for each combination, plus an aggregate score for the set. The following example checks a scale up for search-index: 100 instances across its three Availability Zones, with its three current instance types and two candidates, r6i.16xlarge and r7i.16xlarge.

aws ec2 get-capacity-manager-supportability-scores \
--market-type "on-demand" \
--capacity-amount 100 \
--capacity-unit "instances" \
--capacity-availability-zone-ids '["use1-az4", "use1-az5", "use1-az6"]' \
--instance-types '["r5.16xlarge", "r6a.16xlarge", "r7a.16xlarge", "r6i.16xlarge", "r7i.16xlarge"]'
{
  "AggregateScore": "high",
  "CapacityManagerSupportabilityScores": [
    {"InstanceType": "r5.16xlarge", "AvailabilityZoneId": "use1-az4", "Score": "high"},
    {"InstanceType": "r6a.16xlarge", "AvailabilityZoneId": "use1-az4", "Score": "medium"},
    {"InstanceType": "r7i.16xlarge", "AvailabilityZoneId": "use1-az6", "Score": "medium"},
    {"InstanceType": "r7a.16xlarge", "AvailabilityZoneId": "use1-az4", "Score": "low"},
    ...
  ]
}

Instead of a list, you can also pass --instance-requirements-with-metadata, with the vCPU and memory ranges inside an InstanceRequirements object. Capacity Manager scores each matching instance type. For Spot, set the market type to spot.

--instance-requirements-with-metadata '{
  "InstanceRequirements": {
    "VCpuCount": {"Min": 64, "Max": 64},
    "MemoryMiB": {"Min": 524288}
  }
}'

The Supportability tab on the console runs the same check. Enter the amount, type, Availability Zones, and instance types or attributes, then choose Check supportability. One check can include up to 10 Availability Zones, from more than one Region, and up to 100 combinations. Figure 5 shows the search-index check on the console. The aggregate score is High, so together the set can support 100 instances. r5.16xlarge scores High in all three zones, and r7a.16xlarge scores High in use1-az6. r6a.16xlarge scores Medium in all three zones, and the new candidate r7i.16xlarge scores Medium in use1-az4 and use1-az6. The other six combinations, including every r6i.16xlarge combination, score Low.

Screenshot of the Capacity Manager Supportability tab. The left panel sets 100 On-Demand instances, Availability Zones use1-az4, use1-az5, and use1-az6, and instance types r5.16xlarge, r6a.16xlarge, r7a.16xlarge, r6i.16xlarge, and r7i.16xlarge. The right panel shows High aggregate supportability confidence, 4 of 15 combinations at High, 5 at Medium, and 6 at Low, with a confidence for each combination.

Figure 5: A check for 100 instances of five instance types across the three search-index Availability Zones, with High aggregate confidence.

High means a combination holds more than enough to serve your request. Medium means it can cover the request with little to spare, and Low means it holds less than you asked for. For details, see What the scores mean and What to do based on your score in the Amazon Elastic Compute Cloud User Guide.

Scores are guidance, not commitments. If you need assurance capacity will be there when you launch, use Capacity Reservations or future-dated Capacity Reservations.

Run the check when you plan a workload, not on every launch. You call it in the Region where Capacity Manager is turned on, so per-launch calls would add a cross-Region dependency and use up the hourly quota.

Step 4: Add the flexibility to your fleet

After a check shows which combinations can support a workload, add them to your Auto Scaling group, Amazon EC2 Fleet, or Karpenter configuration. You configure flexibility once, and every later launch benefits from it.

Flow: supportability check and fulfillment metrics, then identify the instance types and zones that support the workload, then add that flexibility to Auto Scaling group, EC2 Fleet, or Karpenter, then launch as usual.

Figure 6: Use the capabilities to plan flexibility, then configure it in your fleet.

Add flexibility

For an Auto Scaling group, a mixed instances policy lists instance types in priority order. This example keeps r5.16xlarge, r6a.16xlarge, and r7a.16xlarge, which each delivered 98.5–100 percent outside use1-az5 in Figure 4. It adds r7i.16xlarge, which the check in Figure 5 scored Medium in two zones, and leaves out r6i.16xlarge, which scored Low.

aws autoscaling update-auto-scaling-group \
--auto-scaling-group-name search-index-asg \
--mixed-instances-policy file://mixed-instances.json
# mixed-instances.json
{
  "LaunchTemplate": {
    "LaunchTemplateSpecification": {"LaunchTemplateName": "search-index-lt", "Version": "$Latest"},
    "Overrides": [{"InstanceType": "r5.16xlarge"}, {"InstanceType": "r6a.16xlarge"},
      {"InstanceType": "r7a.16xlarge"}, {"InstanceType": "r7i.16xlarge"}]
  },
  "InstancesDistribution": {"OnDemandAllocationStrategy": "prioritized"}
}

For workloads defined by vCPU and memory, attribute-based instance type selection draws on every type that fits. Afterward, watch AvgInstAzCountPerRequestedVcpu (the Instance Type x AZ count (avg) column) to confirm the change.

Reserve where you need certainty

In use1-az5, r5.16xlarge delivered 82.8 percent in Figure 4, even though the check in Figure 5 scores it High right now. If the team needs that type in that zone, it can reserve its steady-state baseline with an On-Demand Capacity Reservation and let the rest stay flexible.

aws ec2 create-capacity-reservation \
--instance-type r5.16xlarge --instance-platform Linux/UNIX \
--availability-zone-id use1-az5 --instance-count 10 \
--instance-match-criteria open --end-date-type unlimited

AWS bills you for Capacity Reservations whether or not you use them, so size them to your baseline. If you create one to try this example, cancel it when you no longer need it. Use the reservation ID from the create-capacity-reservation output.

aws ec2 cancel-capacity-reservation \
--capacity-reservation-id cr-1234567890abcdef0

Canceling an active reservation that has no commitment duration, like this one, has no charge. Any instances running in it keep running at On-Demand rates.

Conclusion

Real-time supportability checks and capacity fulfillment metrics in Amazon EC2 Capacity Manager make flexibility visible. You can find the best instance types and Availability Zones before you scale up, see where flexibility is paying off, and know where to add more.

To make this routine, review fulfillment rates each week and open the breakdown for any workload below target. Add a supportability check to change management, so capacity is validated before a change that adds or scales a workload goes live.

To get started, open the Fulfillment tab on the AWS console under Capacity Manager. To learn more, go to EC2 Capacity Manager on AWS Console.

 


About the authors

Yagya Vir Singh

Yagya Vir Singh

Yagya is a Senior Technical Account Manager at AWS based in Nashville, Tennessee. He helps enterprise customers plan and operate their workloads on AWS, with a focus on monitoring and observability, cloud governance, cloud financial management, and generative AI.

Brian Phillippi

Brian Phillippi

Brian is a Principal Product Manager – Technical for Amazon EC2, based in Arizona. He works on EC2 capacity products, including EC2 Capacity Manager, and helps large customers plan and secure the capacity they need. Before joining EC2 product management, he spent several years in AWS infrastructure and demand planning, forecasting and planning the capacity behind AWS Regions.

Beyond DKIM key rotation: understanding email replay attacks

Post Syndicated from Michael Araba original https://aws.amazon.com/blogs/security/beyond-dkim-key-rotation-understanding-email-replay-attacks/

As the email administrator for your organization’s domain, you receive a Domain-based Message Authentication, Reporting, and Conformance (DMARC) aggregate report showing email passing DomainKeys Identified Mail (DKIM) verification as though it came from your domain, sent from IP addresses you don’t recognize to recipients you didn’t contact. To clear the issue, you rotate your DKIM key, confirm the new selector is live, and verify that your DMARC policy is already set to p=reject. Days later, you see that the same unfamiliar traffic is still authenticating as you.

In this post, you learn why rotation alone can’t stop a DKIM replay attack, how to confirm that you’re looking at a replay rather than a legitimate relay or ordinary spoofing, and the DNS cleanup step that most rotation guidance leaves out. You also learn how you can use Amazon Simple Email Service (Amazon SES) to detect and respond to this activity faster.

How DKIM replay works

DKIM verifies one thing: that specific parts of a message didn’t change between the signer and the receiver. The signer signs the message with a private key, and receivers fetch the matching public key from your domain’s DNS to check the signature. A valid signature continues to validate wherever the message travels afterward. Mailing lists, .forward rules, and third-party relays all preserve it, which is the protocol working as designed.

A replay attack takes advantage of that durability. The attack works as follows:

  1. Your ecommerce platform sends a customer a legitimate response email, signed with your DKIM key.
  2. An attacker obtains one copy of that message. They might subscribe to your mailing list, compromise a mailbox, retrieve it from a list archive, or capture it through a forwarding rule.
  3. The attacker resends that message, unchanged, from their own servers to thousands of new recipients.
  4. Each receiving server fetches your public key from DNS and checks the signature. The signature passes, because the body wasn’t modified. Sender Policy Framework (SPF) fails, because the sending IP isn’t yours, but DMARC requires only one of SPF or DKIM to pass with alignment. Because DKIM passes, DMARC passes, and the message is delivered as authenticated email from your domain.

Understanding what an attacker can and can’t do matters, because the limits define the problem. An attacker can’t modify your message, add new content, or read your email. The signature is only valid while the message remains byte-for-byte identical.

What an attacker gains is your domain’s reputation, which they use to deliver your genuine message to an audience you didn’t intend. A receipt or invoice becomes a lure, and email that would normally be filtered is delivered because receivers trust your domain. This is abuse of reputation and reach, not content forgery, and the distinction matters when you explain the incident to your security team.

This also explains why an enforcing DMARC policy doesn’t stop an attack. DMARC passes when SPF or DKIM passes with alignment. During a replay, DKIM passes, so DMARC passes, and p=reject does not apply. A p=reject policy is designed to stop spoofing, where a forger has no valid signature and DKIM fails. It does nothing against a message that you signed. Determining which of the two was used is what tells you how to respond.

Is it replay, spoofing, or a legitimate relay?

Email from an unfamiliar IP address that passes DKIM has three common explanations, and each requires a different response. Confirm which you’re dealing with before you make any changes.

DMARC aggregate (rua) reports provide the source IP address, message volume, and SPF and DKIM results. This is enough to identify an anomaly, but not to prove a replay. A report row might read as follows:

source IP: 203.0.113.45 | count: 4,200 | SPF: fail | DKIM: pass (d=yourdomain.com)

You need to evaluate that row against the three possibilities:

  • The first possibility is a legitimate relay that you haven’t inventoried, such as a mailing list, a forwarder, or an email security gateway such as an inline cloud access security broker (CASB) that you route through. The signature validly survives the relay, and this is normal email flow. In the report, the source IP address usually resolves to a known provider, and the volume tracks your actual sending.
  • The second possibility is replay abuse, in which an attacker redistributes a message that you legitimately signed. In this case, the DKIM-passing volume comes from an IP address that has no business relationship with you, often well above the usual number of recipients that you send to, and frequently to recipient domains that you haven’t worked with.
  • The third possibility is ordinary spoofing, which is a forgery with no valid signature. That row shows DKIM: fail. DKIM that passes from an unknown IP address is therefore usually not ordinary spoofing.

Aggregate counts can’t prove a replay on their own. To confirm a replay, you need message-level evidence: a specific message that you sent matched to one that reached recipients you didn’t address. If you can’t make that match, treat the activity as an unconfirmed anomaly, and begin with the enforcement and monitoring steps that follow rather than removing keys.

Set up an enforcing DMARC policy first

Before you take any other action, confirm that you’re enforcing DMARC. Many domains that encounter this problem are still at p=none, which is monitoring only. This situation is common. Every DMARC deployment begins at p=none, and many organizations do not advance beyond it because nothing appears to be broken. At p=none, receivers take no action on failures, so no change you make affects delivery, and you can’t determine what enforcement would have caught.

The first step is therefore not key removal; it’s completing your sender inventory and reaching enforcement:

  1. Inventory every legitimate sender and relay for your domain, including your email service providers, mailing lists, and any security gateways that send on your behalf.
  2. Bring each sender into SPF and DKIM alignment so that legitimate email authenticates.
  3. Advance the policy one stage at a time, from p=none to p=quarantine to p=reject. Review your rua reports at each stage before you advance, typically for a week or two per stage, and longer if you have many senders to work through. The pct tag lets you ramp up gradually, for example by quarantining 25% of failing email before you increase the percentage.

After you’re enforcing DMARC, receivers that honor DMARC drop unauthenticated spoofing on your behalf. Only after this baseline is in place, and only after you have confirmed a replay of genuinely signed email, does the next step apply.

Remove the old key from DNS: routine hygiene after rotation

Most rotation guidance omits this step. Rotating a key and removing the old key are two separate actions.

Rotation means that your provider begins signing new email with a new key and selector. Removal means deleting the old public key from DNS, so that previously signed messages, including the copies that an attacker captured, can no longer be verified. If you rotate but leave the old public key published, every message ever signed with that key remains replayable for as long as the record exists. Removing the old key is what closes that window and is the step that’s commonly overlooked.

Removal affects only messages that are still in transit and were signed with the old key. After email is delivered, the receiver no longer needs your key, so we recommend that you leave the old key published long enough for in-flight email to arrive and then remove it. How much of this process you manage yourself depends on your DKIM method, which the following section describes. During a confirmed active replay, closing this window is essential.

How you manage keys depends on the DKIM method that you use with Amazon SES. With Bring Your Own DKIM (BYODKIM), you generate the key pair and publish the public key as a TXT record that you control. You can rotate and remove keys whenever you want, including removing an old key on demand during an incident. As the BYODKIM documentation states, “you can rotate the DKIM keys for your domains as often as you want.”

With Easy DKIM, a feature where Amazon SES generates and manages the keys for you, you publish CNAME records that point to AWS-hosted keys. Because your DNS points to AWS-hosted keys through those CNAME records, Amazon SES manages the underlying key material for you, so you don’t publish or remove individual keys yourself. Unlike BYODKIM, Easy DKIM doesn’t provide an on-demand option to delete a specific key immediately.

If you’re an Easy DKIM sender during a confirmed replay, your defenses are early detection (described in the following section), the duplicate-message throttling that receivers already apply, and, over the longer term, the emerging DKIM2 effort, an IETF initiative to update the DKIM specification with built-in replay resistance, which is being designed to give receivers better signals to identify and act on replayed messages at the protocol level.

How Amazon SES helps you detect and respond

Your first response is detection, not DNS changes. The goal is to identify anomalous DKIM-passing volume early, while you build the message-level evidence that distinguishes a replay from a legitimate relay.

Begin by enabling logging. Create an Amazon SES configuration set with an event destination (see Monitor email sending using Amazon SES event publishing) that streams per-message events (Send, Delivery, Bounce, and Complaint) to Amazon Data Firehose or Amazon EventBridge (see Add an event destination), tagged with the From domain.

Separately, confirm that you’re receiving rua reports for your domain. You enable these reports by adding a rua= address to your domain’s DMARC TXT record, for example rua=mailto:[email protected]. For the record syntax and a phased p=none to p=quarantine to p=reject rollout, see Complying with DMARC authentication protocol in Amazon SES. Reports arrive as daily XML digests, and most teams process them with a DMARC analyzer rather than reading the raw XML.

With both sources in place, you can correlate them. Compare the email that your account emitted—based on your Amazon SES delivery events—against what DMARC reports show that receivers accepted as your domain. When DMARC shows DKIM-passing volume from IP addresses outside your SPF record that exceeds the delivery events that Amazon SES recorded for that From domain in the same window, that gap is your signal. It’s also how you build the message-level case that distinguishes a replay from a legitimate relay.

We recommend three additional controls. Amazon SES sending authorization restricts which AWS principals can send using your verified identity. It doesn’t stop an external replay, but it addresses the insider and third-party vector that SPF, DKIM, and DMARC don’t cover on their own, so use identity-level policies to limit sending to specific AWS Identity and Access Management (IAM) principals.

Use Amazon Route 53 to set short DNS time-to-live values (TTLs) (for example, 300 seconds) on your DKIM records so that changes propagate quickly; and enable DNS query logging with an Amazon CloudWatch Logs metric filter that watches for queries to a selector that you’ve rotated away from.

A spike in lookups for an old selector is a useful early indicator of a replay attempt using a retired selector. For a BYODKIM domain during a confirmed replay, Route 53 is also where you remove the old selector’s TXT record. You can automate that removal by using an Amazon CloudWatch alarm that starts an AWS Lambda function to call the Route 53 API and retire an old selector’s record.

Scope the function’s IAM role to only the specific Route 53 hosted zone and record that it needs to change, so that it follows least privilege. Because an incorrect DNS change can have a wide effect, gate any automation behind confirmed-replay criteria and test it against a non-production domain first. For most teams, alarm-to-human is safer than alarm-to-automatic-removal.

When your domain is the one being abused

Most guidance assumes that you’re either the provider preventing abuse or the receiver detecting it. In a replay, your domain is the one whose reputation is being consumed, and you aren’t on the recipient list. As a result, you don’t have the message that everyone asks for when you report the issue.

When you file an abuse report with the provider that hosts the source IP address, they ask for full message headers. The email went to people you didn’t contact, so you don’t have the message headers. You then provide DMARC aggregate reports that show the source IP address and volume, and the provider asks for headers again. At this point, you’re unable to proceed.

To break this cycle, go to the recipients. Review your rua reports for the top recipient domains—which are often .gov and .edu domains—contact those recipients directly and request a sample message with full headers. In addition to providing the headers, that sample lets you confirm a replay rather than a legitimate relay. Build your abuse report around the IP address and the DMARC evidence rather than the message content. If the source IP address belongs to Amazon Web Services (AWS), report it through the AWS abuse process and include your DMARC reports and SPF record as evidence.

Incident response: ordered actions

If your reports show suspicious DKIM-passing email from unfamiliar IP addresses, complete the following procedure.

To respond to a suspected DKIM replay

  1. Confirm that it’s a replay. Match a message that you sent to one that reached recipients you didn’t address and rule out legitimate relays and gateways. Aggregate data alone isn’t proof.
  2. Reach an enforcing DMARC policy. Inventory your legitimate senders, align them, and advance from p=none to p=quarantine to p=reject. This stops spoofing.
  3. Harden SPF to -all rather than ~all, but only after your sender inventory is complete, since -all hard-fails any legitimate source you haven’t listed.
  4. Check the DKIM-Signature header of a fresh message to confirm that your provider signs only with the current selector.
  5. Report the source IP address to the hosting provider and include your DMARC evidence.
  6. Investigate the capture point, such as a compromised mailbox, a mailing list, a forwarding rule, or a newsletter subscription.
  7. Remove the old DKIM public key after in-flight email has cleared. For BYODKIM, delete the old selector’s TXT record. For Easy DKIM, rely on the managed Amazon SES rotation together with the monitoring and receiver-side defenses described earlier.

The following table summarizes what each action does and doesn’t do.

Action What it does What it doesn’t do
Confirm replay (message-level) Separates a replay from a legitimate relay or spoofing Can’t be done from aggregate DMARC data alone
DMARC enforcement (p=none to p=reject) Drops unauthenticated spoofing Does nothing against a replay because DKIM still passes
Key rotation Signs new email with a new key Doesn’t invalidate already signed messages
Remove old key (routine, post-rotation) Invalidates old-key signatures, including captured ones Doesn’t protect in-flight old-key email; on-demand removal available only with BYODKIM
Amazon SES sending authorization Restricts which principals send as your identity Doesn’t stop external replay of captured messages

Conclusion

DKIM replay succeeds because a valid signature keeps validating, and an enforcing DMARC policy doesn’t change that. To respond effectively, confirm that the activity is a replay with message-level evidence, reach an enforcing DMARC policy, and then remove the old key from DNS as routine post-rotation hygiene. Keep your primary defense on monitoring and DMARC enforcement rather than on emergency DNS changes. The durable fix for replay is receiver-side: duplicate-message counting today, and stronger protocol-level signals through DKIM2 in the future.

To get started, review your current DMARC policy and confirm that you’re receiving rua reports, enable Amazon SES event publishing, and document how you rotate and remove DKIM keys for each of your domains.

Learn more

If you have feedback about this post, submit comments in the Comments section below. If you have questions about this post, contact AWS Support.


Michael Araba

Michael Araba

Michael is a Technical Account Manager at AWS Enterprise Support, where he works with customers on email security, DNS architecture, and incident response.

Automating the full AWS DRS agent lifecycle: Install, heal, and offboard

Post Syndicated from Manisha Yadav original https://aws.amazon.com/blogs/architecture/automating-the-full-aws-drs-agent-lifecycle-install-heal-and-offboard/

If you protect a large Amazon Elastic Compute Cloud (Amazon EC2) fleet with AWS Elastic Disaster Recovery (AWS DRS), you manage the AWS Replication Agent across its whole lifecycle. You install it, keep it healthy and registered, and remove it cleanly when a server leaves disaster recovery (DR) scope. An enterprise IT customer with more than 300 Linux and Windows instances in DR scope ran into two of these problems while planning and validating a scheduled DR drill. Some agents had stalled after a network change, and some descoped servers were still replicating and adding to the bill. A server with a stalled agent can’t meet your recovery point objective (RPO), the maximum data loss you can accept after a disruption. The customer’s team checked replication status in the AWS DRS console before each drill, so a problem that started between drills could go unnoticed until the next one.

Working with that customer, we automated the install, heal, and offboard phases of the lifecycle. Most of the design choices in this post came from fixing those two problems. Each phase runs as an AWS Systems Manager (SSM) Automation document, started by Amazon EventBridge or on a schedule. A single EC2 tag decides which phase applies to an instance. Because the logic lives in the Automation documents, there are no AWS Lambda functions to maintain. When you take an instance out of scope, the automation records whether replication billing actually stopped.

You can find the SSM Automation documents, AWS Identity and Access Management (IAM) policies, and deployment scripts in the aws-samples/sample-drs-agent-lifecycle-automation GitHub repository.

Solution overview

You apply a DR tag to each EC2 instance, and its value (yes, pause, or no) tells the automation what to do. Three SSM Automation documents divide the work. Onboarding installs and repairs the agent and keeps registration healthy. Healing responds when replication stalls, and offboarding runs teardown when you descope an instance.

AWS Systems Manager State Manager runs onboarding on a 30-minute schedule against tagged instances. Amazon EventBridge triggers healing when AWS DRS reports a stalled server, and offboarding when the DR tag changes. The automation runs primarily in your source AWS Region and calls the DR Region only where AWS DRS requires it.

Prerequisites

To deploy this solution, you need:

  • An AWS account with administrative access.
  • Two AWS Regions configured: a source Region (where your EC2 instances run) and a DR Region (where AWS DRS replicates to).
  • AWS Elastic Disaster Recovery initialized in both the source Region (for failback) and the DR Region (for replication).
  • Network connectivity between Regions with TCP 443 (AWS DRS API and Amazon Simple Storage Service (Amazon S3)) and TCP 1500 (replication data) open. For private connectivity, use AWS Transit Gateway or VPC peering. Public endpoints are also supported.
  • (Optional) Amazon Virtual Private Cloud (Amazon VPC) endpoints for AWS DRS and Amazon S3 in the DR Region, if using private connectivity.
  • IAM permissions to deploy AWS CloudFormation stacks that create named IAM roles (CAPABILITY_NAMED_IAM), Amazon EventBridge rules, and a State Manager association, and to register SSM Automation documents. You also need iam:PassRole on DRS-Automation-Role, because the Amazon EventBridge rules and the State Manager association use that role.
  • AWS Command Line Interface (AWS CLI) installed and configured.
  • An instance profile on each source EC2 instance with the AWSElasticDisasterRecoveryAgentInstallationPolicy and AmazonSSMManagedInstanceCore managed policies, so that each instance is managed by Systems Manager. If your instances already have an instance profile, attach both policies to its role.
  • An existing Amazon Simple Notification Service (Amazon SNS) topic for notifications.
  • (Optional) An existing Amazon S3 bucket for audit reports.
  • Git, Bash 4 or later, and yq. The deployment scripts use yq to read config.yaml. macOS includes Bash 3.2, so install a newer version, for example with Homebrew.

You don’t need to create the automation’s IAM roles yourself. The CloudFormation templates create DRS-Automation-Role (which runs every lifecycle action) and DRS-EventBridge-Forward-Role (deployed with the DR Region stack), along with the Amazon EventBridge rules in both Regions and the State Manager association. Amazon CloudWatch metrics are published automatically.

One tag controls the entire lifecycle

After you deploy the solution, the value of the DR tag on each EC2 instance determines what the automation does:

DR value State What happens
yes Protected Agent is installed, failback tag applied, health maintained every 30 minutes
pause Quiesced Agent stays installed, stalled alerts suppressed, offboarding blocked
no or deleted Descoped Replication stopped, source server deleted, agent uninstalled
Anything else (for example, DR=maybe, DR=true) Misconfigured Fail-safe: automation skips the instance and logs a SKIPPED_MISCONFIGURED status. No install, no heal, no teardown, preventing an unrecognized value from triggering the wrong action

The tag value is normalized (trimmed and lowercased) before evaluation. A value like DR=Yes normalizes to DR=yes and matches correctly.

Three SSM Automation documents divide the lifecycle:

  • DRS-Agent-Onboard runs every 30 minutes through State Manager against tag:DR=yes instances. It installs or repairs the agent, applies the AWSDRS:AllowLaunchingIntoThisInstance tag, and validates AWS DRS registration. The tag tells AWS DRS to launch recovery onto the original instance during failback and acts as the drift-correction backstop. State Manager re-evaluates tagged instances every 30 minutes. Each run catches instances that lost the Replication Agent, newly tagged instances that aren’t onboarded yet, and earlier runs that failed silently.
  • DRS-Agent-Heal fires when Amazon EventBridge receives a replication-stalled event (cross-Region forwarded from the DR Region). The rule filters on the STALLED state, so recovery transitions don’t trigger unnecessary runs. It inspects the replication error to distinguish agent-side failures from infrastructure issues. For agent problems, it restarts the service first, then reinstalls in place only if the restart doesn’t fix it. For infrastructure-caused stalls, it escalates to operators through Amazon SNS.
  • DRS-Agent-Offboard fires when Amazon EventBridge detects a DR tag change or deletion. The offboarding document first verifies safeguards: a tag re-check, a terminated-instance guard, and an optional approval gate. After those pass, it records replication state and stops replication. It then disconnects the source server, deletes it, uninstalls the Replication Agent, and removes the tags.

How the components connect

The solution operates primarily in the source Region. Cross-Region calls happen only where AWS DRS requires them (registration checks and teardown API calls target the DR Region).

The following diagram shows how the automation components connect across the source and DR Regions. State Manager and Amazon EventBridge operate in the source Region; AWS DRS replication and the stall-forwarding rule run in the DR Region.

State Manager and EventBridge in the source Region connecting to AWS DRS replication in the DR Region

Figure 1: AWS DRS agent lifecycle automation architecture across the source and DR Regions

In the source Region, State Manager runs the onboarding document every 30 minutes, and Amazon EventBridge rules start the heal and offboarding documents when their events arrive. AWS Systems Manager Parameter Store holds the DR-event stand-down flag (/drs/control/event-in-progress). The DRS-Automation-Role IAM role runs every automation step. Amazon SNS delivers alerts, Amazon S3 stores per-event audit reports, and Amazon CloudWatch captures outcome metrics.

In the DR Region, AWS DRS manages source server registration and ongoing replication. AWS DRS emits replication events only in the DR Region, where the source servers are registered. An Amazon EventBridge forwarding rule filters replication-stalled events by the STALLED state. It then sends them to the source Region for healing. Staging subnets and Amazon Elastic Block Store (Amazon EBS) volumes, managed by AWS DRS, support the replication infrastructure.

For networking, AWS Transit Gateway (which connects VPCs across Regions) carries TCP 443 (AWS DRS API and S3) and TCP 1500 (replication data) between Regions. Amazon VPC endpoints for AWS DRS and S3 keep traffic on the AWS backbone without internet exposure. An Amazon Route 53 Private Hosted Zone (for DNS resolution within your VPC) resolves endpoint names to private IPs. This removes the need for --endpoint installer flags and protects failback compatibility.

Tag and rule examples

Run these commands after you deploy the solution. Tag an instance for protection:

aws ec2 create-tags --resources i-0abc123def456 --tags Key=DR,Value=yes

Descope an instance (triggers offboarding):

aws ec2 create-tags --resources i-0abc123def456 --tags Key=DR,Value=no

Or remove the tag entirely (also triggers offboarding):

aws ec2 delete-tags --resources i-0abc123def456 --tags Key=DR

The Amazon EventBridge rule that triggers offboarding matches this pattern:

{
  "source": ["aws.tag"],
  "detail-type": ["Tag Change on Resource"],
  "detail": {
    "changed-tag-keys": ["DR"],
    "service": ["ec2"],
    "resource-type": ["instance"]
  }
}

The Amazon EventBridge rule that triggers healing filters on the STALLED state so recovery transitions are ignored:

{
  "source": ["aws.drs"],
  "detail-type": ["DRS Source Server Data Replication Stalled Change"],
  "detail": {
    "state": ["STALLED"]
  }
}

Three layers protecting the RPO

The 15-minute RPO referenced throughout this post is a customer-configured target, not an AWS service guarantee. The architecture helps protect it with three complementary layers, each covering a different failure mode.

Layer 1: OS service auto-restart (seconds). The agent service uses systemd Restart=always on Linux and Windows Service Recovery. When the process stops, the OS brings it back automatically. On Linux, systemd restarts the service almost instantly. On Windows, the first recovery action fires after a configurable delay (30 seconds by default). This layer handles agent process crashes without any automation running.

Layer 2: Amazon EventBridge heal (minutes, not seconds). If the agent disappears or can’t maintain registration, AWS DRS detects the stall and emits a replication-stalled event in the DR Region. A forwarding rule filters on the STALLED state and sends it cross-Region to the source Region’s event bus. A matching receive rule triggers the heal document.

The heal document restarts the agent first (most stalls are transient). If restart fails, it re-runs the installer in place, preserving driver state to avoid a full-disk rescan. The restart path is the faster of the two, and total time depends on your environment and on AWS DRS stall-detection latency. The reinstall path takes longer because the agent must re-establish block-level tracking. Stall-detection latency also counts against the window, because lag must accumulate before the service marks a server as stalled.

Layer 3: State Manager backstop (up to 30 minutes). The onboarding document re-evaluates DR=yes instances every 30 minutes, catching drift and edge cases that faster layers miss. This exceeds the 15-minute RPO target, so it functions as a safety net rather than the primary defense.

Layer 2 requires the Amazon EventBridge data plane in both Regions to be operational, while Layers 1 and 3 run entirely in the source Region.

Onboarding: How ongoing convergence works

State Manager targets tag:DR=yes. Instances that already have the tag and instances you tag later are included automatically, without manual registration.

The onboarding document is idempotent. It skips healthy, registered instances. For instances that need work, it checks the stand-down flag and instance state, validates the operating system (Linux or Windows), and flags unsupported architectures as TAGGED_UNSUPPORTED. If AutoInstallEnabled is false, the run is audit-only.

For actual installs, a concurrency lock (flock on Linux, a named mutex on Windows) prevents races when multiple automation runs overlap on the same instance. This lock is built into the documents and needs no configuration. The State Manager association sets MaxConcurrency to 10 and MaxErrors to 100% in templates/state-manager-association.yaml. MaxConcurrency limits how many instances are processed at once. With MaxErrors at 100%, a failure on one instance doesn’t stop the run for the rest of the fleet. For larger fleets, raise MaxConcurrency gradually and watch for AWS DRS API throttling.

The failback tag (AWSDRS:AllowLaunchingIntoThisInstance) is applied only when the agent is confirmed present. Registration is assessed cross-Region, and if an instance stays in PENDING_REGISTRATION beyond a configurable threshold (default: 3 runs), the document escalates through Amazon SNS. Each execution writes an S3 audit record and emits a CloudWatch metric.

Healing agent issues within RPO

The heal document reads the source server ARN from the event and looks up the matching EC2 instance ID with drs:DescribeSourceServers in the DR Region. It then checks the stand-down flag and the tag state. Values other than yes suppress the heal with no action and no alert.

If restart doesn’t resolve the issue, the automation re-runs the installer in place as a fallback. It doesn’t uninstall first, so the existing replication driver stays intact where possible. Reinstallation can trigger a block-level rescan whose duration scales with total disk size. On large volumes, this might temporarily exceed the RPO window while the rescan completes.

Not every stall is an agent problem. Bandwidth throttling, undersized replication servers, or staging-area disk issues can also cause stalls. The automation escalates these non-agent stalls to your operators through Amazon SNS rather than attempting agent remediation. If the Replication Agent is still not functioning after reinstall, you receive a HEAL_FAILED notification with diagnostic details.

Only the AWS DRS agent service is restarted, not the EC2 instance itself. Your workloads keep running without interruption.

Offboarding with honest billing status

When you set DR=no or delete the tag, Amazon EventBridge fires a Tag Change on Resource event. The offboarding document applies several safeguards before teardown.

Terminated-instance guard: EC2 termination strips tags, generating the same event as a deliberate descope. The document sends an alert and stops. It doesn’t delete the source server, which contains the recovery point.

Tag re-check: If the tag has been restored, the document exits.

Approval gate: If AutoOffboardEnabled is false, the document sends a notification and stops.

State recording: Before modifying resources, it captures replication state, lag duration, and last snapshot time.

Recovery-instance check: Before completing teardown, the offboarding document checks for active recovery instances. If found, it flags them in the audit report and sends an SNS alert. Recovery instances incur separate EC2 billing that persists after source server deletion.

Adaptive retry: A maximum of 10 attempts with exponential backoff handles API throttling during bulk descope.

After teardown, the document checks what actually succeeded:

Outcome Meaning
COMPLETED Source server deleted, billing halted
BILLING_STOPPED_ORPHAN Replication stopped but delete didn’t succeed. Operator must manually delete.
FAILED stop_replication didn’t succeed. Billing might still be active. Operator review recommended.
NO_SERVER Instance was not registered as an AWS DRS source server. Clean no-op.

Because each outcome reflects what actually succeeded, you know whether replication billing has stopped or whether a source server needs manual follow-up.

Security: Least-privilege IAM roles

The solution creates two IAM roles and relies on one that you provide. SSM Automation and Amazon EventBridge assume the DRS-Automation-Role to run all lifecycle actions. Its permissions cover SSM, EC2 tags, cross-Region AWS DRS calls, SNS, S3, Parameter Store, and iam:PassRole on itself. The iam:PassRole permission only allows passing this role to Systems Manager. Run Command is limited to instances tagged DR=yes. In the DR Region, the DRS-EventBridge-Forward-Role can only put events on the source Region’s default event bus.

On each EC2 instance, the instance profile from the prerequisites lets the Replication Agent and SSM Agent reach their services. The automation stores no long-lived credentials. Each role receives temporary credentials when it’s assumed. When you use private connectivity, cross-Region traffic stays on Transit Gateway and VPC endpoints.

Deploy the solution

Start in audit mode, where the onboarding document reports what it would install without changing any instance and offboarding sends a notification instead of running teardown. Review the audit reports, then turn on installation for a small set of instances before you scale to the full fleet.

To configure config.yaml

Clone the repository and change to its directory:

git clone https://github.com/aws-samples/sample-drs-agent-lifecycle-automation.git
cd sample-drs-agent-lifecycle-automation

The deployment scripts read your settings from config.yaml in the root of the repository. Set the values in the following sample before you deploy. The file in the repository also sets document names and stack names. Keep those at their defaults, because the commands in this post use the default names.

# Region configuration
source_region: us-west-1        # Region where your EC2 instances run
dr_region: us-east-2            # Region where AWS DRS replicates to
account_id: "111122223333"      # Your AWS account ID

# Observability
sns_topic_name: drs-alerts      # Existing SNS topic for notifications
amzn-s3-demo-audit_bucket: ""           # Existing S3 bucket for audit reports; empty turns off audit reports
cloudwatch_namespace: DRSAutomation

# Automation toggles (start in audit mode)
auto_install_enabled: "false"   # false = report only; true = install and repair agents
auto_offboard_enabled: "false"  # false = notify only; true = tear down on descope

# Tag configuration
dr_tag_key: DR
dr_protected_value: "yes"
dr_descope_value: "no"

# Stand-down control and schedule
stand_down_parameter: /drs/control/event-in-progress
schedule_rate: "rate(30 minutes)"

The following table describes the values you’re most likely to change.

Parameter Required Description
source_region Yes Region that hosts your EC2 instances and the automation
dr_region Yes Region where AWS DRS replicates to
account_id Yes AWS account that hosts the automation
sns_topic_name Yes Name of an existing SNS topic in the source Region
amzn-s3-demo-audit_bucket No Existing S3 bucket for JSON audit reports. Leave empty to skip them
auto_install_enabled No Set to false to report only, or true to install and repair agents
auto_offboard_enabled No Set to false to notify only, or true to run teardown when you descope an instance
dr_tag_key No EC2 tag key that controls the lifecycle (default DR)

To deploy the solution

The following steps deploy each component separately so you can see what each one creates. Run the commands from the root of the cloned repository. Replace the example account ID (111122223333) and Regions (us-west-1 and us-east-2) with your own values.

To deploy everything in one command instead, run ./scripts/deploy.sh after you edit config.yaml, and then go to the confirmation commands after step 6. The script prints the status of every stack when it finishes. Without yq, it falls back to built-in defaults, including a placeholder account ID. Use either the script or the individual steps, not both.

  1. Deploy the IAM role stack. The template creates a role with an explicit name (DRS-Automation-Role), so CAPABILITY_NAMED_IAM is required.
    aws cloudformation deploy \
      --template-file templates/iam-roles.yaml \
      --stack-name drs-automation-iam \
      --capabilities CAPABILITY_NAMED_IAM \
      --region us-west-1 \
      --parameter-overrides \
        AccountId=111122223333 \
        SourceRegion=us-west-1 \
        DRRegion=us-east-2 \
        SNSTopicName=drs-alerts

  2. Register the three SSM Automation documents. The script reads source_region from config.yaml and creates the documents from the documents/ folder on the first run. On later runs, it publishes a new default version of any document that changed.
    ./scripts/register-documents.sh

  3. Deploy the Amazon EventBridge rules in the source Region. This stack creates the tag-change rule that starts offboarding and the rule that starts healing. It uses the existing DRS-Automation-Role, so it doesn’t need CAPABILITY_NAMED_IAM.
    aws cloudformation deploy \
      --template-file templates/eventbridge-source.yaml \
      --stack-name drs-eventbridge-source \
      --region us-west-1 \
      --parameter-overrides \
        AutomationRoleArn=arn:aws:iam::111122223333:role/DRS-Automation-Role \
        TargetRegion=us-east-2 \
        SNSTopicArn=arn:aws:sns:us-west-1:111122223333:drs-alerts \
        AutoOffboardEnabled=false

  4. Deploy the forwarding rule in the DR Region. This stack creates DRS-EventBridge-Forward-Role, which lets the rule send stalled events to the source Region’s event bus, so CAPABILITY_NAMED_IAM is required.
    aws cloudformation deploy \
      --template-file templates/eventbridge-dr.yaml \
      --stack-name drs-eventbridge-dr \
      --capabilities CAPABILITY_NAMED_IAM \
      --region us-east-2 \
      --parameter-overrides \
        SourceRegion=us-west-1 \
        AccountId=111122223333

  5. Create the stand-down flag in Parameter Store with a value of false. The script reads the parameter name and Region from config.yaml and doesn’t overwrite an existing value.
    ./scripts/init-parameters.sh

    If you prefer not to use the script, run the following command instead:

    aws ssm put-parameter --name /drs/control/event-in-progress \
      --value false --type String --region us-west-1

  6. Deploy the State Manager association in audit mode. The association runs once as soon as it’s created and then every 30 minutes.
    aws cloudformation deploy \
      --template-file templates/state-manager-association.yaml \
      --stack-name drs-state-manager \
      --region us-west-1 \
      --parameter-overrides \
        AutomationRoleArn=arn:aws:iam::111122223333:role/DRS-Automation-Role \
        TargetRegion=us-east-2 \
        AutoInstallEnabled=false \
        SNSTopicArn=arn:aws:sns:us-west-1:111122223333:drs-alerts

To confirm the deployment, run the following commands:

aws cloudformation describe-stacks --region us-west-1 \
  --query "Stacks[?starts_with(StackName, 'drs-')].[StackName,StackStatus]" --output table

aws cloudformation describe-stacks --stack-name drs-eventbridge-dr --region us-east-2 \
  --query "Stacks[0].StackStatus" --output text

aws events list-rules --name-prefix DRS- --region us-west-1 --query "Rules[].Name"

aws events list-rules --name-prefix DRS- --region us-east-2 --query "Rules[].Name"

aws ssm list-documents --filters Key=Owner,Values=Self --region us-west-1 \
  --query "DocumentIdentifiers[?starts_with(Name, 'DRS-Agent-')].Name"

aws ssm get-parameter --name /drs/control/event-in-progress --region us-west-1 \
  --query "Parameter.Value" --output text

A successful deployment shows the following results:

  • All four stacks show CREATE_COMPLETE, or UPDATE_COMPLETE after a re-run.
  • The source Region lists the DRS-Tag-Change-Offboard and DRS-Stalled-Heal-Receive rules.
  • The DR Region lists the DRS-Stalled-Forward rule.
  • The source Region lists the DRS-Agent-Onboard, DRS-Agent-Heal, and DRS-Agent-Offboard documents.
  • The stand-down flag returns false.
  • In the AWS Systems Manager console, State Manager lists DRS-Agent-Onboard-Association.

When you’re ready to turn on installation, set auto_install_enabled: "true" in config.yaml and deploy the State Manager stack again with AutoInstallEnabled=true:

aws cloudformation deploy \
  --template-file templates/state-manager-association.yaml \
  --stack-name drs-state-manager \
  --region us-west-1 \
  --parameter-overrides \
    AutomationRoleArn=arn:aws:iam::111122223333:role/DRS-Automation-Role \
    TargetRegion=us-east-2 \
    AutoInstallEnabled=true \
    SNSTopicArn=arn:aws:sns:us-west-1:111122223333:drs-alerts

To turn on teardown, set auto_offboard_enabled: "true" and deploy the source Region Amazon EventBridge stack again with AutoOffboardEnabled=true:

aws cloudformation deploy \
  --template-file templates/eventbridge-source.yaml \
  --stack-name drs-eventbridge-source \
  --region us-west-1 \
  --parameter-overrides \
    AutomationRoleArn=arn:aws:iam::111122223333:role/DRS-Automation-Role \
    TargetRegion=us-east-2 \
    SNSTopicArn=arn:aws:sns:us-west-1:111122223333:drs-alerts \
    AutoOffboardEnabled=true

Safety guards prevent unintended actions

The following table lists the safety guards across the three automation documents. The stand-down flag and the tag value allow-list apply to every document. The terminated-instance guard and honest billing status protect offboarding, and the remaining guards protect onboarding and healing.

Guard Prevents
DR-event stand-down Acting during failover/failback
Terminated-instance guard Deleting a recovery point on instance termination
Reverse-direction detect Overwriting an AWS DRS-managed failback agent
Concurrency lock Race conditions from overlapping runs
AutoInstallEnabled gate Unintended installs in audit-first deployments
OS pre-check Running installer on incompatible operating system
Honest billing status Reporting “billing stopped” when teardown partially completed
Tag value allow-list A typo triggering the wrong lifecycle action
Agent-presence-gated tagging Applying failback tag to an agent-less instance

To pause the automation during a declared DR event:

aws ssm put-parameter --name /drs/control/event-in-progress \
  --value true --type String --overwrite --region us-west-1

To resume after the event:

aws ssm put-parameter --name /drs/control/event-in-progress \
  --value false --type String --overwrite --region us-west-1

Monitoring and observability

The three SSM Automation documents produce telemetry across three channels, each tuned for a different audience.

Amazon SNS notifications go to the on-call team. Onboarding stays quiet unless an instance needs intervention. Heal notifies on both success and intervention-needed, because a successful auto-heal still tells you a real issue occurred and was resolved. Offboarding sends a notification at each stage: start, complete, orphan, incomplete, and approval-needed.

Amazon S3 audit reports serve compliance and troubleshooting. One JSON object per lifecycle event at s3://<amzn-s3-demo-audit_bucket>/drs/<account>/<instance>/<event>-<timestamp>.json captures instance ID, host name, platform, source server ID, status, and pre-teardown replication state. To control storage costs, add a lifecycle rule to the bucket (for example, expire objects under drs/ after 90 days).

Amazon CloudWatch metrics power fleet dashboards. An Outcome metric (1=handled, 0=needs attention) dimensioned by event type provides at-a-glance fleet health.

Clean up

Removing the automation doesn’t stop replication. Instances tagged DR=yes keep replicating until you stop replication through the AWS DRS console or API. To remove the automation infrastructure, run the following commands:

  1. Delete the State Manager association and the source Region Amazon EventBridge stacks.
    aws cloudformation delete-stack --stack-name drs-state-manager --region us-west-1
    aws cloudformation delete-stack --stack-name drs-eventbridge-source --region us-west-1

  2. Delete the Amazon EventBridge forwarding stack in the DR Region.
    aws cloudformation delete-stack --stack-name drs-eventbridge-dr --region us-east-2

  3. Delete the SSM Automation documents and the stand-down parameter.
    aws ssm delete-document --name DRS-Agent-Onboard --region us-west-1
    aws ssm delete-document --name DRS-Agent-Heal --region us-west-1
    aws ssm delete-document --name DRS-Agent-Offboard --region us-west-1
    aws ssm delete-parameter --name /drs/control/event-in-progress --region us-west-1

  4. After the other stacks are deleted, delete the IAM role stack.
    aws cloudformation delete-stack --stack-name drs-automation-iam --region us-west-1

  5. (Optional) If you created the SNS topic and S3 bucket only for this solution, delete them.

Conclusion

With a DR=yes tag in place, onboarding keeps the agent installed and registered, and healing restarts or reinstalls it when replication stalls. When you descope an instance, offboarding stops replication, removes the source server, and writes a JSON audit record to Amazon S3. That record tells you whether billing stopped or whether a partial teardown needs an operator’s follow-up. The whole lifecycle runs from one tag, without function code for your team to maintain.

The June 2023 post Deploying AWS Elastic Disaster Recovery at scale with AWS Systems Manager covered the initial install. This solution builds on it with ongoing health checks and offboarding.

Next steps

Start by cloning the repository and deploying it in audit mode in a test account. Tag a few instances with DR=yes and let onboarding run for a day. The audit reports in Amazon S3 show what the automation would install or repair on each instance, without changing anything. When the reports match what you expect, turn on installation for those instances. Then set DR=no on one of them to check the offboarding notification before you turn on teardown. If you have questions or improvements, open an issue in the GitHub repository.

Additional resources


About the authors

Manisha Yadav

Manisha Yadav

Manisha is a Delivery Consultant with AWS Professional Services in Gurugram, India. She helps enterprise customers move workloads to AWS and then keep them recoverable, mostly with AWS Elastic Disaster Recovery and AWS Systems Manager. The automation in this post started as her answer to managing Replication Agents one instance at a time.

Danish Shamim

Danish Shamim

Danish is a Senior Delivery Consultant at AWS Professional Services, where he focuses on disaster recovery and large migrations. He helps enterprise customers protect and move their workloads, and he builds the automation and runbooks that keep those solutions running in production.

Vimal Bhati

Vimal Bhati

Vimal helps AWS customers move workloads to AWS as a Delivery Consultant at AWS Professional Services. He works mostly on Infrastructure as Code with AWS CloudFormation, and he contributed the deployment tooling for this solution.

Migrate Google BigQuery to an open lakehouse on AWS with Apache Iceberg

Post Syndicated from Saman Irfan original https://aws.amazon.com/blogs/big-data/migrate-google-bigquery-to-an-open-lakehouse-on-aws-with-apache-iceberg/

Enterprises increasingly measure their readiness for AI by the readiness of their data, and that often means moving to an open lakehouse where data stays open and portable. The organizations moving fastest are the ones whose data gives them the flexibility to choose the engines and models that fit each workload. A tightly coupled, single-engine cloud data warehouse begins to hold them back. A generation of analytics tools was built inside vertically integrated systems, where the storage format, the catalog, and the query engine were fused into one vendor’s runtime. That design traded openness for convenience, and as generative AI, multi-engine analytics, and cross-team data sharing become the norm, the cost of that trade is rising. Increasingly, enterprises are choosing the opposite: an open, modular architecture where governance is defined once and enforced everywhere. This is the foundation for AI-ready data. It isn’t a new architecture to adopt, but an open lakehouse that keeps your data yours.

In this post, we explain why an open lakehouse architecture on AWS is the natural next step for organizations that are looking to future-proof their data architecture rather than re-architect onto another closed system. We then present the target architecture on AWS for migrating BigQuery workloads. Finally, we provide a step-by-step migration guide covering workload assessment, schema and query conversion, data movement strategies, and post-migration optimization to help you transition from BigQuery to an open lakehouse on AWS with confidence.

Why organizations are moving toward an open lakehouse

Open data, and the freedom to run engine of your choice. When data is held in a vendor’s proprietary format, it becomes difficult to use with other engines, tools, or AI models outside the vendor’s environment. Openness is what breaks it, but it comes in two degrees that are worth distinguishing. At the base is interoperability, which means many engines can reach the same data and it’s fast becoming table stakes now that even proprietary warehouses federate to open formats and query tables where they sit. Beyond that is portability: the freedom to take your data, your choice of engine, and your choice of AI model elsewhere without re-architecting. It’s the more durable property an open lakehouse is built to give you. Apache Iceberg is an open table format that removes these barriers: Iceberg compatible engines can read and write the same tables concurrently at petabyte scale while maintaining ACID compliance. On AWS, Apache Iceberg is a first-class citizen. Amazon Redshift, Amazon Athena, Amazon EMR, and AWS Glue all operate on one governed copy of the data in Amazon Simple Storage Service (Amazon S3). Because storage is decoupled from compute, you match the engine to the workload such as Athena for interactive SQL, Amazon Redshift for high-performance warehousing and business intelligence (BI), Amazon EMR for large-scale transformation, and Amazon SageMaker AI for machine learning (ML) without duplicating data or building extract, transform, and load (ETL) pipelines to shuffle it between systems. Interchangeability becomes a property of the architecture rather than something you engineer around.

Governance defined once, enforced across every engine. Openness doesn’t mean giving up control. On AWS, a single governance layer spans every engine that touches the data. The AWS Glue Data Catalog exposes an Apache Iceberg REST catalog endpoint that implements the Apache Iceberg REST Catalog open specification. AWS Lake Formation applies fine-grained access controls at the database, table, column, row, and cell level, defined once and enforced consistently whether a data engineer runs a Spark job on Amazon EMR, an analyst queries through Athena, or a data scientist trains a model in SageMaker AI. For discovery and sharing, you can use Amazon SageMaker Unified Studio to find, understand, and request access to governed data across the estate. Because the catalog follows an open specification rather than a single vendor’s runtime, governance travels with the data instead of locking it in.

A foundation that is AI-ready and model agnostic. The reason all this matters now is AI. Generative and agentic workloads are only as good as the data they can reach, and they perform best when data stays in open formats you fully control. But openness can’t stop at the data format. The intelligence layer matters too: the AI model, the semantic catalog, the enrichment layers, and the agents. An open foundation keeps both layers open. A single governed copy of open data sits in Amazon S3, and the same tables that serve BI and interactive analytics also feed Amazon SageMaker AI for model training and Amazon Bedrock and Amazon Bedrock AgentCore for generative and agentic work. You choose the foundation model (FM) that best fits your needs, with no extra copies to move or secure.

Target architecture: Open lakehouse on AWS

The following architecture diagram represents the recommended target state for organizations migrating BigQuery workloads to AWS and is especially well suited to customers wanting to build a unified, open data foundation. It’s built on Apache Iceberg as the foundational layer, with purpose-built consumption engines chosen per use case: Amazon Athena for interactive analytics, Amazon Redshift for BI reporting and dashboarding, and Amazon SageMaker AI for ML and agentic workloads, all on one governed copy of the data that stays open, interoperable, and portable.

The architecture follows the flow of data from left to right: source systems are ingested into an open lakehouse on Amazon S3, refined through medallion layers, and served to a diverse set of consumers such as BI tools, AI agents, and ML systems. It rests on four foundational pillars: open storage, purpose-built analytics, unified governance, and an integrated experience.

Open lakehouse target architecture on AWS: source systems flow through ingestion into an Amazon S3 Apache Iceberg lakehouse, refined through medallion layers, and served to BI, AI, and ML consumers under unified governance

Figure 1: Open lakehouse target architecture on AWS, from source ingestion through the Amazon S3 Apache Iceberg storage layer to analytics, BI, and ML consumers

Ingestion

This is where it all begins. You must bring data from a variety of sources such as databases, software as a service (SaaS) applications, streaming systems, on-premises systems, IoT devices into your data lake.

  • Bulk migration: For the initial BigQuery migration and other large batch transfers, use BigQuery’s EXPORT DATA statement to export tables to Google Cloud Storage (GCS) in Parquet format. Then use AWS DataSync to move Parquet files from Google Cloud Storage to Amazon S3. For more information, see Step 4.
  • Zero-ETL: For SaaS and operational sources such as Google Analytics, Salesforce, SAP, ServiceNow, and HubSpot, Zero-ETL integrations land data directly in the lakehouse without building or maintaining custom pipelines.
  • Change data capture (CDC): For operational data stores, Debezium connectors running on Amazon MSK Connect stream row-level changes into the lakehouse to keep it continuously current.
  • Streaming: For real-time sources, Amazon Kinesis Data Streams and Amazon Managed Streaming for Apache Kafka (Amazon MSK) provide streaming storage that feeds directly into the raw layer.

Storage layer: Amazon S3 with Apache Iceberg

Amazon S3 serves as the central storage layer, providing virtually unlimited scalability, 99.999999999% (11 nines) of data durability, and multiple storage classes, so you can match cost to the access pattern. Data is stored in Apache Iceberg table format, which brings ACID transactions, schema evolution, time travel, and partition evolution to your data lake. Amazon S3 Tables, a capability of Amazon S3, provides a fully managed Apache Iceberg experience with automatic compaction, snapshot management, and optimization removing the undifferentiated heavy lifting of table maintenance.

Data is progressively refined through a medallion architecture, with each layer stored as Apache Iceberg tables on S3:

  • Bronze/Raw: Ingested data lands here as-is, in Apache Iceberg format either on fully managed Amazon S3 Tables or as self-managed Apache Iceberg tables on Amazon S3.
  • Silver/Trusted: Silver layer serves as single source of truth. AWS Glue and Amazon EMR Spark jobs transform, clean, and conform the raw data into trusted, query-ready Apache Iceberg tables. Amazon Athena provides serverless, interactive analytics directly against this layer for interactive exploration and data discovery.
  • Gold/Serving: Amazon Redshift Serverless serves curated, business-ready data for dashboarding and reporting. Amazon Redshift is a first-class Apache Iceberg engine capable of managing the full Apache Iceberg table lifecycle (see Using Apache Iceberg tables with Amazon Redshift). Data can also be natively stored into Amazon Redshift Managed Storage for workloads that require increased performance for dashboarding and reporting use cases.

Governance: Unified across every engine

A unified foundation depends on governance that is defined once and enforced everywhere. The AWS Glue Data Catalog serves as the central metadata repository, and AWS Lake Formation provides fine-grained access control at the database, table, column, row, and cell level, enforced consistently whether data is accessed through Athena, Amazon Redshift, EMR, or SageMaker AI. AWS Glue Data Quality, a capability of AWS Glue, validates data as it moves through the layers and Amazon SageMaker Unified Studio provides a single integrated development environment for data discovery, SQL analytics, visual ETL, and artificial intelligence and machine learning (AI/ML) development all governed by the same Lake Formation permissions.

Consumers: One lakehouse, many workloads

Because data sits in open Apache Iceberg format under unified governance, a broad set of consumers can access a single source of truth without data movement:

  • BI tools: Amazon Quick Suite, Looker, Power BI, and Tableau connect for dashboarding and reporting.
  • AI agents: Agentic tooling such as MCP-based clients, Kiro, and Claude, along with Amazon Bedrock AgentCore, can reason over governed lakehouse data.
  • ML/SageMaker: Amazon SageMaker AI trains models and serves inference directly against lakehouse data.
  • Amazon SageMaker Unified Studio provides a single integrated development environment (IDE) for data exploration, SQL analytics, visual ETL, and AI/ML development. For organizations accustomed to a single-pane experience, Unified Studio addresses the concern of managing multiple services by providing one place to discover data, run queries, build dashboards, and develop ML models, all governed by the same Lake Formation permissions.

Data interoperability

The Apache Iceberg REST catalog endpoint of the AWS Glue Data Catalog, part of Amazon SageMaker Lakehouse, enables interoperability with third-party engines. Partners such as Snowflake and Databricks can read and write the same Apache Iceberg tables through that endpoint, so you choose the best tool for each job without duplicating data.

Cross-cutting layers: Orchestration, security, and observability

Two layers span the entire lakehouse rather than sitting at any single stage. An orchestration layer coordinates how data moves through the medallion layers: Amazon Managed Workflows for Apache Airflow (Amazon MWAA), AWS Step Functions, and Amazon EventBridge schedule and sequence ingestion, transformation, and load jobs across Bronze, Silver, and Gold.

A security and observability layer applies across every engine and layer. AWS Identity and Access Management (IAM) and AWS Key Management Service (AWS KMS) provide authentication, authorization, and encryption, while Amazon CloudWatch, AWS CloudTrail, and AWS Cost Explorer provide monitoring, audit logging, and cost visibility across the lakehouse.

Step-by-step migration guide

Migrating from BigQuery to an open lakehouse on AWS follows a structured, phased approach. Each phase builds confidence incrementally, so you can validate value before committing further.

Step 1: Assess and discover

Begin by evaluating your current BigQuery environment:

  • Inventory workload: Catalog all datasets, tables, views, stored procedures, scheduled queries, and downstream consumers (BI tools, applications, ML pipelines). Name the consumers that most often need a decision: Looker Studio, Connected Sheets, scheduled queries, and any production service reading BigQuery directly.
  • Classify query complexity: Analyze your SQL workloads to identify standard ANSI SQL (which migrates directly), BigQuery-specific syntax (such as STRUCT, ARRAY, UNNEST, and JavaScript UDFs), and procedural logic in stored procedures.
  • Map data dependencies: Document which tools and applications read from and write to BigQuery. Identify upstream data sources (streaming, batch, CDC) and downstream consumers (dashboards, reports, ML models).
  • Assess data volumes: Record active storage, long-term storage, data growth rates, and each dataset’s storage billing model (logical or physical) with its measured physical bytes. The compressed physical size can be a small fraction of the logical size and changes the storage cost estimate materially. This informs your S3 storage class strategy and cost modeling.
  • Evaluate governance: Document current access controls, row-level security policies, and audit requirements. Map these to Lake Formation capabilities.

The output of this phase is a migration readiness report with per-table effort scoring, query complexity classification, and a recommended migration pattern.

AWS account teams and partners have metadata-driven assessment accelerators. They read only BigQuery metadata (schemas, INFORMATION_SCHEMA job history, view definitions, and routine signatures), not raw data. From that metadata, they produce a cost comparison with the projected savings, per-table migration effort scoring, a complexity classification for every view, routine, and observed query, and a map of every tool reading from or writing to BigQuery with how each one reconnects on AWS.

Reach out to your AWS account team for an initial assessment of moving BigQuery workload to an open lakehouse on AWS.

Step 2: Design target architecture

Based on your assessment findings, design the target-state architecture:

  • Storage design: Define your S3 bucket structure with a multi-layer layout (raw, cleaned, and aggregated) aligned with data lifecycle best practices. For Apache Iceberg storage, we recommend Amazon S3 Tables for fully managed Apache Iceberg with automatic table maintenance and optimization and general-purpose S3 buckets with self-managed Apache Iceberg for cases that need granular control over table layout and compaction.
  • Partitioning strategy: Design Iceberg partition schemes aligned to actual query access patterns. Use Iceberg’s native partitioning transforms (bucketing, time slicing) for optimal query pruning.
  • Engine selection: Choose the engine from the workload you measured in Step 1. Amazon Athena fits estates that scan a moderate volume per day and tolerate a few seconds of latency whereas Amazon Redshift fits estates with sustained high concurrency, sub-second dashboard targets, or a daily scanned volume where warm compute is cheaper than paying per terabyte scanned. For large-scale data transformations, position Amazon EMR (including Amazon EMR Serverless) for complex, custom open source frameworks like Spark and Hive, or AWS Glue for serverless, integration-heavy ETL pipelines.
  • Governance model: Design your AWS Lake Formation permission model to enforce granular database, table, column, and row-level access policies. Verify the architecture plans for multi-tenant access patterns if applicable.
  • Ingestion architecture: Design data ingestion pipelines. Options include Zero-ETL integrations for operational data sources, Amazon Data Firehose for streaming data, and AWS Glue for batch ETL.

Step 3: Convert and validate (POC)

Pick a proof-of-concept slice, a handful of representative tables and the dashboards that read them, and convert BigQuery SQL, stored procedures, and data pipelines to their AWS equivalents.

  • Schema conversion: A BigQuery dataset becomes an AWS Glue database when Athena is the engine, or an Amazon Redshift schema when Amazon Redshift is. The BigQuery project maps to your AWS account with its top-level namespace being the AWS Glue Data Catalog for Athena or to an Amazon Redshift database.
  • SQL conversion: Convert BigQuery SQL dialect to ANSI SQL or engine-specific syntax. Common conversions include:
    • BigQuery STRUCT to Iceberg struct and ARRAY to Iceberg list, and BigQuery JSON maps to a string column queried with JSON function when Athena is the engine and data is stored in Apache Iceberg format. For Amazon Redshift, the recommendation is to build the gold layer which is optimized for reporting use cases where data is flattened out for high-performance queries. If you need to store nested data inside Amazon Redshift, use the SUPER data type.
    • On Amazon Redshift, the target depends on where the data lands: for data stored natively in Amazon Redshift, BigQuery JSON maps to the SUPER type (queried with PartiQL), and STRUCT/ARRAY are represented within SUPER.
    • BigQuery MERGE statements to Athena or Amazon Redshift MERGE INTO. Choose copy-on-write for tables updated in bulk and merge-on-read for frequently updated ones.
    • BigQuery JavaScript UDFs to a SQL expression where possible, otherwise an AWS Lambda-backed UDF on Athena (USING EXTERNAL FUNCTION) or Amazon Redshift.
    • BigQuery scheduled queries to Amazon EventBridge Scheduler running Athena queries, Amazon Redshift scheduled queries in Query Editor v2, or dbt for anything that is a model. Chains of schedules become AWS Step Functions.
  • Pipeline conversion: Rebuild ETL pipelines using AWS Glue, dbt (with dbt-athena or dbt-redshift adapters), or Apache Spark on EMR.
  • Validation: Run converted workloads against the slice. Compare row counts and checksums per table, query results for the dashboards on the slice, and run times against the agreed target, between BigQuery and AWS, to verify correctness.

BigQuery offers two pricing models for running queries. On-demand pricing is a scan-based model that charges $6.25 per terabyte of data scanned, while capacity-based pricing is a compute-based model that charges for the compute capacity used to run queries. Amazon Redshift is also compute-based, so if you currently run BigQuery on demand, its scan-based pricing is not directly comparable to Amazon Redshift’s compute-based pricing. Because the two models differ, benchmark a representative mix of queries on the target engine rather than a single query type.

A good practice is to run three tiers of queries and compare total cost and performance against your current BigQuery baseline: lightweight queries for simple lookups and aggregations, medium queries for moderate joins and transformations, and heavyweight queries for complex joins, window functions, and large scans. To build an accurate, workload-representative total cost of ownership (TCO), multiply each query tier by how often it runs, add storage costs, and produce a monthly figure you can compare directly to your current monthly BigQuery bill.

Step 4: Migrate data

Execute bulk data migration from BigQuery to Amazon S3 with Apache Iceberg:

  1. Use BigQuery’s EXPORT DATA statement to export tables to Google Cloud Storage (GCS) in Parquet format. For large datasets, partition exports by date or key ranges to parallelize the transfer.
  2. Use AWS DataSync to move Parquet files from Google Cloud Storage to Amazon S3. DataSync reads Google Cloud Storage directly through its S3-compatible XML API and writes the objects to your S3 bucket, with scheduling and incremental transfers built in. For most transfers you deploy a DataSync agent, either as a virtual machine in Google Cloud (closest to the source bucket) or as an Amazon Elastic Compute Cloud (Amazon EC2) instance in your virtual private cloud (VPC). DataSync Enhanced mode transfers between Google Cloud Storage and Amazon S3 run without an agent and offer higher performance and scalability.
  3. Create the Apache Iceberg tables in Athena or Amazon S3 Tables and load them from the exported Parquet with CREATE TABLE AS SELECT or INSERT INTO, or register the existing files with Spark’s add_files procedure on Amazon EMR or AWS Glue.
  4. During the transition period, implement incremental data synchronization to keep AWS data current while BigQuery remains active. Use BigQuery’s change history functions (APPENDS and CHANGES) for scheduled delta exports, change data capture (CDC) patterns, or Amazon AppFlow.
  5. Apply for Google’s data transfer fee waiver before the bulk export starts: Google waives egress fees for customers leaving Google Cloud who request it through support and complete the export within the agreed window.

Step 5: Cutover and go-live

Execute production cutover with minimal disruption:

  • Parallel run: Run workloads on both BigQuery and AWS simultaneously for a validation period. Compare outputs to confirm data consistency and query correctness.
  • Redirect consumers: Every consumer takes one of three paths:
    • Re-point: In this case, the tool stays the same, only its connection changes from BigQuery to Athena or Amazon Redshift (dbt, Fivetran, Tableau, Power BI, Looker). This is usually a JDBC/ODBC or connector update. Athena and Amazon Redshift both use the AWS Glue Iceberg REST catalog (IRC) endpoint to locate the physical Parquet files on Amazon S3. For more information, see Querying Apache Iceberg tables in Athena and Querying Apache Iceberg tables in Amazon Redshift.
    • Rebuild: In this case, the workload has no direct AWS connection and is re-implemented on an AWS counterpart (BigQuery scheduled queries → Amazon Athena Saved Queries orchestrated by AWS Step Functions, or managed through Amazon EventBridge to run an ETL pipeline on a schedule, Dataform → dbt (using the Athena or Amazon Redshift connector), BigQuery ML → SageMaker AI or Amazon Redshift ML), Google Analytics 4 and Connected Sheets → Amazon AppFlow native connectors ingest the data (Google → AWS) into Amazon S3 or Amazon Redshift on a schedule. From S3, an AWS Glue or Athena step lands it in Apache Iceberg.
    • Relay: In this case, the tool stays on Google Cloud for now and reads AWS data synced back to it (Connected Sheets, GA4 raw export).

Looker Studio, Connected Sheets, and scheduled queries are the consumers that most often need an explicit decision on which path to take.

  • Validate end-to-end: Confirm that all dashboards, reports, scheduled jobs, and ML pipelines produce correct results.
  • Decommission: After successful validation, decommission BigQuery resources and terminate cross-cloud data synchronization.

Step 6: Optimize and expand

Post-migration, optimize your lakehouse for performance and cost:

  • Query optimization: On general-purpose buckets with self-managed Apache Iceberg, tune file size targets, compaction frequency, and sort orders to the actual query patterns whereas Amazon S3 Tables handles compaction and snapshot expiry automatically. Enable Athena query result reuse and Amazon Redshift materialized views for frequently accessed datasets.
  • Cost optimization: Use S3 Intelligent-Tiering for infrequently accessed data, Athena workgroups and Amazon Redshift usage limits to control costs, and AWS Cost Explorer to monitor.
  • Enable new capabilities: With data in open Apache Iceberg format on Amazon S3, you can take on use cases that were previously difficult or impossible:
    • Train ML models directly on lakehouse data using SageMaker AI.
    • Build generative AI applications with Amazon Bedrock Knowledge Bases.
    • Implement real-time analytics with Amazon Data Firehose or Amazon Kinesis Data Streams writing Apache Iceberg tables to Amazon S3 Tables, or with Amazon Redshift streaming ingestion.
    • Share Apache Iceberg tables with partner engines such as Snowflake and Databricks through the Apache Iceberg REST catalog endpoint.

Customer success: Yggdrasil Gaming

Yggdrasil Gaming, a global iGaming studio, migrated from BigQuery to an Apache Iceberg-based lakehouse on AWS, working with GOStack, an AWS Partner. The company had been operating dual-cloud infrastructure across AWS and Google Cloud, creating significant operational overhead that limited their ability to implement advanced analytics and AI/ML initiatives.

The migration followed a phased approach: establishing the lakehouse foundation on S3 with Apache Iceberg and AWS Glue Data Catalog, implementing real-time data ingestion using Debezium for change data capture, migrating processing pipelines to AWS Lambda and Amazon EMR, modernizing the transformation layer with dbt and Amazon Athena, and enabling governance through AWS Lake Formation.

The results were significant:

  • 60 percent reduction in data processing costs.
  • 75 percent improvement in analytics latency (from 2 hours to 30 minutes).
  • Alleviated multi-cloud complexity by consolidating on AWS.
  • Enabled AI/ML use cases including player behavior modeling and fraud detection.

For the full story, read Building a modern lakehouse architecture: Yggdrasil Gaming’s journey from BigQuery to AWS on the AWS Big Data Blog.

Customer success: Global technology leader performed zero-downtime GCP exit at petabyte scale

A global technology leader in the network security space migrated from Google BigQuery to AWS to reduce costs and enable streaming ingestion for network security analytics. Working with Quantiphi, an AWS Partner, the company migrated 10 core customer-facing applications across 3 regions with zero business downtime for more than 1,000 customers.

Highlights of the migration:

  • Migrated over 1,000 BigQuery projects to Amazon Athena with Apache Iceberg on Amazon S3 (setting up over 5,000 Apache Iceberg tables).
  • Key drivers: cost reduction (30 percent savings in cloud operational costs), streaming ingestion for security analytics, and regulatory alignment.
  • Zero business downtime across a petabyte-scale migration spanning three AWS Regions, with minute-level data refresh meeting a 99.9 percent service level agreement (SLA).
  • A full GCP exit consolidating entirely on AWS through Quantiphi’s Qatapult modernization and migration platform.

For the full story, read Engineering Excellence: How a Tech Leader Migrated to AWS with Zero Business Downtime (Quantiphi case study).

Conclusion

Migrating from BigQuery to an open lakehouse architecture on AWS with Apache Iceberg is an architectural change and a strategic investment in data openness, multi-engine flexibility, and AI readiness. By storing data in open formats on Amazon S3, governing it centrally through AWS Lake Formation, and accessing it with purpose-built engines, you build a data foundation that adapts to your evolving needs without vendor lock-in.

The phased migration approach (assess, design, convert, migrate, cutover, and optimize) means you can validate value at each step and maintain business continuity throughout the transition. Organizations like Yggdrasil Gaming have demonstrated that this migration can be completed in months, not years, with measurable cost savings and performance improvements.

To get started, explore the following resources:

 


About the authors

Saman Irfan

Saman Irfan

Saman is a Senior GTM Specialist Solutions Architect at Amazon Web Services, based in Berlin, Germany. A Data & AI strategist with over 10 years of experience, she drives the technical GTM motion for Data, Analytics, and AI at AWS across EMEA, helping enterprise customers modernize their data architectures and navigate large-scale migrations to unlock the full potential of their data.

Siddhu Kolappan

Siddhu Kolappan

Siddhu is an Analytics GTM Lead at AWS, driving strategy and adoption for AWS’s data and analytics portfolio across EMEA. He has over 15 years of experience helping enterprises transform their data platforms and is currently focused on enabling customers build trusted data foundations for the agentic AI era.

Ryan Jadidi

Ryan Jadidi

Ryan is a Specialist Solutions Architect at Amazon Web Services, based in Sydney, Australia. With over fifteen years building and running data platforms across fintech, media and consulting, he works with startup and enterprise customers across Asia Pacific and Japan on the data foundations for AI.

Implement least-privilege access with designations in Amazon SageMaker Unified Studio

Post Syndicated from Priyanka Chaudhary original https://aws.amazon.com/blogs/big-data/implement-least-privilege-access-with-designations-in-amazon-sagemaker-unified-studio/

When teams move data and AI work to production in a shared environment, one question quickly becomes unavoidable: who can do what? Amazon SageMaker Unified Studio now answers it with designations, which are named permission sets that give administrators granular control over the actions each member can perform within a project. Administrators define a designation once, then assign it to project members. A member might sign in through an AWS Identity and Access Management (IAM) role, an IAM user, or an AWS IAM Identity Center user or group. In each case, designations help you give each persona only the access its responsibilities require. Effective access also depends on identity and resource policies, service control policies, permissions boundaries, group memberships, and how the customer configures permission sets and assignments. Designations integrate with AWS CloudFormation, and there is no additional charge to use them beyond Amazon SageMaker Unified Studio itself.

In this post, we walk through why differentiated permissions matter, how designations work, and how to create and assign your first designation using the console and the AWS Command Line Interface (AWS CLI).

The challenge: One project, many roles

A single SageMaker Unified Studio project often brings together data engineers, data stewards, analysts, and machine learning (ML) engineers, each needing very different access. Consider a financial services team running its Lakehouse and ML workflows in one project. Engineers build and schedule extract, transform, and load (ETL) pipelines, and a steward approves subscription requests to keep sensitive data governed. Analysts query curated tables for dashboards, and ML engineers iterate on models in notebooks. They all work on the same data, but their access needs differ. The steward shouldn’t alter production pipelines, analysts shouldn’t launch expensive compute, and the scheduled ETL service account shouldn’t share permissions with someone running exploratory experiments. In the past, common approaches included giving everyone the same broad project role or splitting the work across separate projects.

“We automated everything about how teams get set up in SageMaker Unified Studio. Projects are created, members are synced from our directory daily, and data sources are registered without anyone clicking a button. The one thing we couldn’t automate was controlling what each person could do once they were inside. Solution teams, data scientists, and business analysts all arrived with the same access. With designations, we now define each user type’s permissions as part of the same automation that creates the project, so the right access is enforced from day one.”

— Tim Kopacz, Cloud Platform Architect, Swiss Life Germany

Without a purpose-built mechanism to scope permissions within a project, organizations face operational risk. A user might accidentally delete a connection that disrupts a production workload or modify catalog settings outside their area of responsibility. Teams often work around this by creating a separate project for each persona to isolate access. That approach fragments data across project boundaries and adds provisioning and member-management overhead. As more teams onboard, these workarounds compound to slow adoption and increase the governance burden.

Designations address several common scenarios:

  • Controlling access to Notebooks: Grant members full access, read-only access, or no access to notebooks within a project, so compute-intensive interactive work is available only to the roles responsible for it. This helps control costs by limiting who can run notebook workloads.
  • Separating exploration from production: Grant exploratory users read and explore access while production service accounts run scheduled workflows, all within the same project.
  • Supporting governance workflows: Assign specific personas responsibility for data stewardship, subscription approval, and metadata management, which helps prevent other members from bypassing your review process.

How designations work

Designations separate policy definition from member assignment, which is what makes them reusable across projects.

  • Define once: A domain administrator creates a designation by selecting granular permissions. For example, publish an asset, create data products, or approve subscriptions. Permissions are grouped into categories such as Catalog, Project, Subscriptions, Connections, and Notebooks, and are backed by the Cedar policy language.
  • Assign per project: Within a project, the project owner assigns one designation to each member. When a member performs an action through the portal or the API, SageMaker Unified Studio evaluates the assigned designation. It permits or denies the action based on the permissions the designation contains. Additionally, a member can also inherit designations through single sign-on (SSO) group membership. If a user is directly assigned one designation and also belongs to a group assigned to another, the user receives the union of both permission sets. Because effective permissions are the union of direct and group-inherited designations, review SSO group assignments regularly so members don’t receive more access than intended.

Because a designation is defined once and assigned per project, you get consistent permissions across every project without recreating policies each time. Data engineers, stewards, analysts, and automated pipelines can work within the same project with different permissions, which keeps data unified in one place rather than fragmented across duplicate projects.

Designations complement your existing IAM controls rather than replace them, and they provide one consistent authorization layer across every user type that SageMaker Unified Studio supports. This matters because SageMaker Unified Studio supports IAM roles, IAM users, SSO users, and SSO groups. You can’t attach IAM policies directly to SSO users or groups within SageMaker Unified Studio. Designations apply uniformly to all of these principals, so every user type is governed by the same permission model within a project.

Authorization happens in two layers. First, IAM evaluates whether a principal can invoke an API at the AWS API boundary. If IAM allows the call, the designation then evaluates whether the assigned permission set grants the specific action within the project. You don’t need to modify your existing IAM policies to adopt designations, but we still recommend that you continue to scope IAM roles to least privilege.

For example, the AmazonSageMakerDomainExecution role might hold the IAM permission to perform an API action such as datazone:CreateListingChangeSet, the permission to publish a data asset. The next layer is the designation: the action succeeds only if the project member’s designation includes the Manage Asset Publishing permission to publish assets in that project. Both layers must allow the action, and an IAM Deny blocks the call regardless of what the designation permits.

The following diagram shows how a project member’s request to publish a data set passes through both authorization layers.

Request to publish a data set passing through two layers: IAM at the API boundary, then the project designation

Figure 1: How a request to publish a data set passes through the IAM and designation authorization layers

The request first reaches the AWS API boundary, where IAM (layer 1) evaluates whether the principal can call the API. If IAM denies the call, the action is blocked. If IAM allows it, SageMaker Unified Studio (layer 2) evaluates the member’s designation. The action succeeds only when the designation also allows it. Otherwise, the action is blocked.

Managed designations

Every domain includes two AWS managed designations at the project level so you can start immediately:

  • Owner – Full control over all project resources, including project member management.
  • Contributor – Full access except for project member management and project deletion. Because Contributor is broad, we recommend custom designations for personas that don’t need full access.

You can view the permissions for these managed designations, but you can’t edit or delete them. To create your own permission sets, you build custom designations for the personas your organization needs.

Create a designation on the console

Designations are created and managed from the domain management portal. Only users whose profile is a domain administrator can access it. The domain management portal is separate from the Amazon SageMaker Unified Studio portal where members do their day-to-day work. In this example, we create an ML Engineer designation for members who build workloads through notebooks.

  1. Sign in to the SageMaker Unified Studio domain management portal.
  2. In the navigation pane, choose Designations. The Managed tab shows the AWS managed Owner and Contributor designations.
  3. Choose Create designation.
  4. For Name, enter ML Engineer, and add a Description.
  5. In the visual permission selector, expand the Notebook category and select only the permissions this persona requires, for example, Read-only actions for reviewers, or All Actions only for members who author notebooks.
  6. Choose Create.
Create designation page in the domain management portal with the visual permission selector expanded

Figure 2: Creating a designation in the Amazon SageMaker Unified Studio domain management portal, using the visual permission selector to choose the actions to include

The ML Engineer designation is now available to every project in the domain. You can also create a designation by duplicating an existing one, which pre-selects a set of permissions for you to customize. Review the pre-selected permissions and remove any that the new persona doesn’t need.

Assign a designation to a project member

After creation, a designation is available to every project in the domain. You can assign it from two places, depending on your domain type.

From the SageMaker Unified Studio portal (Identity Center-based domains), assign the designation from a project where you are an owner, or if your designation has the Manage Membership permission. Grant Manage Membership only to trusted project members. A member who has the Manage Membership permission can assign designations with more permissions than their own.

  1. Navigate to the project in the SageMaker Unified Studio portal.
  2. Choose the Members tab.
  3. To update an existing member, choose a designation from the Designation column for that user. To add a new member, choose Add member and select the member.
  4. For Designation, choose ML Engineer.
SageMaker Unified Studio portal Members tab assigning the ML Engineer designation to a project member

Figure 3: Assigning a designation to a project member from the Amazon SageMaker Unified Studio portal (Identity Center-based domains) using the Role list on the Members tab

From the domain management portal (both IAM and Identity Center-based domains):

  1. Sign in to the domain management portal and choose Projects in the navigation pane.
  2. Select the project, then choose the Members tab.
  3. To update an existing member, choose a designation from the Project designation column. To add a new member, choose Add members and select the type and member.
  4. For Designation, choose the designation to assign, then choose Save.
Domain management portal Members tab assigning a designation using the Project designation column

Figure 4: Assigning a designation from the domain management portal (both IAM and Identity Center-based domains) using the Project designation column on the Members tab

When the assigned user logs in to the project, they are permitted to create notebooks. Every project member is assigned a designation. Members without a valid designation have no permissions in the project.

Automate the full lifecycle with the AWS CLI and infrastructure as code (IaC)

You can manage the entire lifecycle, from creating a designation to assigning it, programmatically. This is useful for codifying governance and rolling it out at scale.

Prerequisites:

The IAM principal that runs these commands needs permissions (scoped to the domain) such as datazone:CreateDesignation, datazone:UpdateDesignation, and datazone:DeleteDesignation to manage designations. They need datazone:CreateProjectMembership to assign the designation to project members. Grant only the actions you need.

Create a designation with the AWS CLI:

aws datazone create-designation \
  --domain-identifier "dzd_example1234" \
  --name "Data Engineer" \
  --description "Runs data source and asset creation tasks" \
  --policy 'permit(principal, action in [
      DataZone::Action::"CreateDataSource",
      DataZone::Action::"UpdateDataSource",
      DataZone::Action::"CreateAsset"
    ], resource);'

Assign the designation when you add a member to a project:

aws datazone create-project-membership \
  --domain-identifier "dzd_example1234" \
  --project-identifier "proj_example5678" \
  --member '{"userIdentifier": "u-abc123def456"}' \
  --designation "USER_CREATED_DESIGNATION" \
  --user-created-designation-id "123456789"

Because designations are supported as resources in AWS CloudFormation, you can define them in your templates, version them in source control, and deploy them across domains through your existing infrastructure-as-code pipelines. You can also manage assignments with the CreateProjectMembership and UpdateProjectMembership API operations, so the full workflow can be automated without using the console.

Update designations as your needs evolve

When you update a designation’s permissions, the change takes effect immediately for all members assigned that designation across all projects, with no reassignment required. This means you can start with the minimum permissions each persona needs, and add permissions as requirements or new capabilities emerge. Because updates apply immediately across all projects, review designation changes through your change-management process and use AWS CloudTrail to audit them.

Delete a designation

Designations include an Allow delete property that is turned off by default, so a designation can’t be deleted until you explicitly allow it. When you create or edit a designation, set Allow delete to make the designation eligible for deletion. Once allowed, you can delete the designation even if project members are still assigned to it.

When you delete a designation that is still in use, those members remain in the project, but their assigned designation becomes invalid. They have no permissions in the project until the project owner reassigns them a valid designation. Plan deletions accordingly so members are not left without access.

You can also set this property programmatically. The CreateDesignation API and AWS CLI accept a deletion protection parameter, so you can codify the setting in your infrastructure as code templates.

aws datazone create-designation \
  --domain-identifier "dzd_example1234" \
  --name "Data Engineer" \
  --deletion-protection \
  --description "Can manage data sources and create assets" \
  --policy 'permit(principal, action in [DataZone::Action::"CreateDataSource", DataZone::Action::"UpdateDataSource", DataZone::Action::"CreateAsset"], resource);'

To create the designation without delete protection, use the --no-deletion-protection flag.

Conclusion

Designations give administrators control over who can do what within each Amazon SageMaker Unified Studio project. Define named permission sets once and assign them per project to help enforce separation of duties across data engineers, stewards, analysts, ML engineers, and automated pipelines. You don’t need to duplicate projects or build a permission system outside SageMaker Unified Studio. Designations require no additional infrastructure or cost, integrate with AWS CloudFormation, and layer cleanly on top of your existing IAM controls.

To get started, open the domain management portal for your Amazon SageMaker Unified Studio domain, choose Designations, and create your first permission set. To learn more, see the Amazon SageMaker Unified Studio documentation.


About the authors

Priyanka Chaudhary

Priyanka Chaudhary

Priyanka is a Senior Solutions Architect at AWS specializing in data lake and analytics services. She helps customers design scalable, secure cloud solutions and guides strategic customers through their cloud journey. Outside of work, she enjoys time with friends and family, movies, and travel.

David Victoria

David Victoria

David is a Senior Technical Product Manager with Amazon SageMaker at AWS. He focuses on administration and governance capabilities needed for customers to support their analytics systems. He is passionate about helping customers realize the most value from their data in a secure, governed manner. Outside of work, he enjoys hiking, traveling, and spending time with family.

How BMW Group accelerated connected vehicle workloads on AWS Graviton

Post Syndicated from Tomislav Lozančić original https://aws.amazon.com/blogs/architecture/how-bmw-group-accelerated-connected-vehicle-workloads-on-aws-graviton/

This post is co-written with Tomislav Lozancic, Hrvoje Lukavski from BMW Group, and Hugo Cardoso from CTW.

BMW Connected Company, a division within BMW Group, develops and operates premium digital services for BMW’s connected vehicle fleet of more than 24.5 million vehicles worldwide. Critical TechWorks (CTW), established in September 2018, focuses exclusively on developing software for BMW Group’s future connected vehicle fleet.

In 2024, BMW Connected Company began migrating its Connected Vehicle platform, known internally as Orbit, from x86 to Arm-based compute as part of a broader modernization strategy to advance BMW Group’s economic and environmental goals.

In this post, we share the methodology behind the migration, tooling and patterns that helped application teams move to AWS Graviton at their own pace, without service disruption.

Laying the groundwork

Before applications could adopt a new processor architecture, the platform needed a more flexible provisioning layer. The team adopted Karpenter, replacing fixed Managed Node Groups with dynamic, policy-driven node pools that could provision the right instance type automatically. You can read more about this migration in Transforming the BMW Connected Vehicle Backend with Karpenter.

With the compute foundation in place, the next challenge was supporting multi-architecture builds. Building, testing, and deploying the same application for two architectures without slowing teams down required extending the continuous integration and continuous delivery (CI/CD) platform. Build runners needed to target both x86 and Arm while producing dual build pipelines, as described in the following section.

Introducing Graviton-based CI/CD runners

The Connected Vehicle CI/CD platform facilitates over 130,000 builds and deployments daily through self-hosted GitHub Actions runners. You can read more about the evolution of this platform in An Engine of Efficiency: BMW Group’s CI/CD Modernization Journey with AWS.

To surface potential issues early and minimize disruption, the team first validated Graviton on a set of self-contained services. Dedicated Graviton runners operated alongside the existing x86 fleet during this phase. The price-performance advantage of Graviton was confirmed immediately, with lower cost per build minute and faster build times, justifying the business case for wider adoption.

Building on these initial results, the team designed the wider platform rollout around two complementary tracks.

  1. Dual build pipelines: Building and shipping both CPU architectures in parallel, giving applications a non-disruptive path to transition independently.
  2. Container hosting: Extending support into the centralized container platform, so applications could be hosted on Graviton.

The following figure shows the six-stage journey to adopt AWS Graviton. The stages are grouped into two phases: a build-foundation phase and a workload-migration phase.

Six-stage AWS Graviton adoption journey grouped into a build-foundation phase and a workload-migration phase

Figure 1: AWS Graviton adoption journey across the Connected Vehicle platform

Supporting adoption

Getting teams onto Graviton depended less on policy than on removing friction. The following practices lowered the barrier to entry, beginning with the build pipeline.

1 – Multi-architecture builds

As outlined earlier, every container image needed to support both architectures before workloads could run on Arm-based instances. To achieve this, two complementary build approaches were used: emulated builds using Docker Buildx with QEMU, and native builds on Graviton-based runners.

Both approaches produce multi-arch images by publishing a manifest list (OCI image index) alongside the architecture-specific images. When a container runtime pulls a tagged image, it reads the manifest and automatically selects the correct variant for the node’s architecture. This means a single image tag serves both arm64 and amd64 nodes transparently.

2 – Mirroring base images

Public images are mirrored into a private Amazon Elastic Container Registry (Amazon ECR) to achieve availability, immutability, and CVE compliance. To preserve multi-arch support during mirroring, the team uses Google’s Crane. Crane transfers images directly between registries and filters the manifest to only the required platforms, which avoids wasted storage from unused architectures.

Besides public images, custom application workloads also needed support for both architectures. Most Dockerfiles required no changes, because their base images and dependencies already supported multiple architectures. In some cases, an alternative or dependency with Arm support needed to be found. For the builds, two approaches were considered:

  1. Native build per architecture: Each architecture is built and pushed separately on a runner with the matching architecture, followed by a final job that creates and pushes the image index. In GitHub Actions, this means one runner per architecture plus one index-creation step. This approach is more reliable but requires a dedicated runner for each target architecture.
  2. Emulated builds from a single machine: Using Docker Buildx with QEMU, all architectures can be built and pushed from a single x86 or Arm runner. This streamlines the pipeline but introduces emulation overhead, often making builds significantly slower for compute-intensive workloads.

The team opted to support both approaches. For platform-scale adoption, Graviton runners were preferred because they ran arm64 builds natively. This avoided the QEMU emulation overhead and surfaced architecture-specific issues earlier in the pipeline. With GitHub Actions reusable workflows, teams can select the right fit for each use case. Dockerfiles use the QEMU path to avoid spinning up additional runners. Those with significant complexity, large dependency downloads, or long build times use native builds, where the overhead becomes problematic.

Graviton runner labels mirror the existing x86 labels, so adoption involves swapping x64 for arm64 in a single label.

3 – Defining Graviton NodePools

The following diagram shows the mixed-architecture Amazon Elastic Kubernetes Service (Amazon EKS) cluster, including how on-premises systems connect and how the Graviton and x86 node pools coexist.

Mixed-architecture Amazon EKS cluster showing on-premises connectivity and coexisting Graviton and x86 node pools

Figure 2: Mixed-architecture Amazon EKS cluster with dedicated Graviton node pools and opt-in scheduling

Dedicated Graviton node pools let teams adopt without friction across each environment. The pools are shared and general-purpose by default, not tailored to individual workloads. This keeps the underlying infrastructure model consistent and predictable while still allowing teams to migrate on their own schedule. Karpenter’s automatic scaling foundation supports efficient resource allocation and rapid provisioning for both Arm and x86 workloads.

To avoid accidental scheduling, the Graviton node pools use taints. Workloads must explicitly opt in using a node selector and matching toleration. This supports controlled rollout and predictable behavior:

nodeSelector:
  bmw.de/stateless-arm: supported

tolerations:
- key: "bmw.de/stateless-arm"
  operator: "Equal"
  value: "supported"
  effect: "NoSchedule"

With this in place, only Arm-ready workloads are scheduled on Graviton nodes, while other workloads continue to run on amd64 nodes without disruption.

4 – Phased rollout

Before supporting broad adoption for application workloads, the team first validated the migration path by running core platform services (Karpenter, Prometheus, and operational tooling). This platform-first approach identified compatibility gaps early, refined node pool configuration, and established rollback patterns. Teams had a proven reference model before they were encouraged to opt in.

A key design principle was minimizing rollback risk by keeping both architectures viable simultaneously. Dual-arch images and a dedicated NodePool mean every application can run on both CPU architectures. If issues arise, workloads fall back to x86 without image rebuilds or pipeline changes.

Kubernetes node affinity rules prefer Arm without forcing it, so workloads naturally gravitate toward Graviton instances:

{{/*
Workload node affinity
*/}}
{{- define "common.nodeaffinity" -}}
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
  preference:
    matchExpressions:
    - key: kubernetes.io/arch
      operator: In
      values:
      - arm64
{{- end -}}

{{/*
Workload node tolerations
*/}}
{{- define "common.tolerations" -}}
- key: bmw.de/arm
  operator: Equal
  value: supported
  effect: NoSchedule
{{- end -}}

If a full rollback is needed, the GitOps-based ArgoCD deployment model makes recovery straightforward by reverting to the previous version in Git and letting ArgoCD reconcile the cluster state.

Results

The migration delivered approximately $600,000 in annualized infrastructure savings and measurable performance improvements. After moving the NGINX-based ingress controller from x86 to Graviton, key results included:

  • CPU consumption decreased by approximately 57 percent, while maintaining the same ingress profile.
  • Price-performance improved by around 40 percent, as lower CPU consumption per request increased throughput capacity per dollar spent.
  • Scaling efficiency improved at peak loads, with workloads requiring less compute capacity to handle equivalent traffic volumes.

Today, more than 65 percent of the platform runs on Graviton across production environments, a shift that supports BMW Group’s sustainability goals by reducing compute energy demand.

Lessons learned

Migrating a platform of this scale surfaced practical lessons that shaped the team’s approach and would benefit others considering a similar transition:

  • Opt-in scheduling: Use taints, selectors, and tolerations to make sure that only ready workloads migrate.
  • Multi-architecture images: One manifest-based tag for both architectures keeps releases straightforward and rollback instant.
  • Platform-first adoption: Validate on shared infrastructure first to reduce risk before teams opt in.
  • Optimization of emulated builds: This approach is useful for straightforward pipelines. Compute-heavy builds should use native or cross-compilation paths instead, which avoids unnecessary QEMU overhead and long build times.

Conclusion

BMW Group migrated its Connected Vehicle platform to AWS Graviton, achieving over $600,000 in annual cost savings and a 40 percent improvement in price-performance. The more than 130,000 daily builds and deployments that power more than 24.5 million connected vehicles continued without disruption.

The migration succeeded because of deliberate, incremental choices: taint-based node isolation, multi-arch container builds, and Karpenter’s ability to provision the right instance at the right time. Each layer reduced risk while keeping the rollout optional for individual teams. What started as a platform experiment is now the default compute path.

To get started with Graviton-based workloads on Amazon EKS, see the AWS Graviton Getting Started guide. To learn how BMW Group adopted Karpenter at scale, read Transforming the BMW Connected Vehicle Backend with Karpenter.


About the authors

Tomislav Lozančić

Tomislav Lozančić

Tomislav is Lead Cloud Solution Architect at BMW Group, responsible for the Connected Vehicle platform overseeing more than 400 Amazon EKS clusters supporting 24.5+ million connected vehicles. With a degree in Computer Science, he specializes in cloud-native technologies and Kubernetes at scale. Tomislav focuses on optimizing cloud infrastructure for performance, cost efficiency, and reliability, playing a key role in BMW Group’s cloud strategy and mission-critical services.

Brendan Jones

Brendan Jones

Brendan is a Senior Solutions Architect at AWS for BMW Group. He is passionate about helping automotive customers build innovative, highly scalable, and resilient cloud architectures which maximize business impact for their customers.

Akshay Gupta

Akshay Gupta

Akshay is a Cloud Infrastructure Architect at AWS with over 14 years of experience in designing and implementing scalable, secure, and cost-efficient cloud solutions. He leads cloud transformation initiatives across multiple industries, helping customers modernize their infrastructure and optimize cloud operations. Akshay specializes in DevOps automation, FinOps strategies, and infrastructure-as-code frameworks to drive efficiency and innovation. In his free time, Akshay enjoys contributing to the cloud and DevOps community through technical writing, open-source projects, and mentoring professionals in their cloud journey.

Hrvoje Lukavski

Hrvoje Lukavski

Hrvoje is Lead Product Manager at BMW Group, driving the development of the Connected Vehicle Platform and ensuring its quality and future-readiness. A FinOps Champion and Chaos Engineering pioneer, he advises executive leadership on cloud strategy and believes the best engineering disappears into the product.

Hugo Cardoso

Hugo Cardoso

Hugo is a DevOps Engineer at Critical TechWorks. With a background in software engineering, he applies a developer’s mindset to platform and infrastructure challenges by connecting systems and extending them with software when needed. He focuses on breaking down complex problems while maintaining strong security and tenancy boundaries, delivering solutions that keep platforms simple and intuitive for both their users and maintainers.

Automate toll-free number registrations at scale with AWS End User Messaging

Post Syndicated from Rommel Sunga original https://aws.amazon.com/blogs/messaging-and-targeting/automate-toll-free-number-registrations-at-scale-with-aws-end-user-messaging/

When you manage SMS communications for dozens or hundreds of business units, registering each toll-free number (TFN) manually becomes a bottleneck. For independent software vendors (ISVs), software as a service (SaaS) providers, or large organizations with many teams, the repetitive console work, multi-day carrier reviews, and rejection handling add up fast.

With AWS End User Messaging SMS, you can send messages using TFNs, 10DLC numbers, or short codes in the US, each with its own registration process. For more information, see Toll-Free Number registration requirements. In a previous post, we showed how to programmatically register a single TFN using a Bash script, and how to register multiple TFNs in bulk with Python. Those scripts automate the seven API calls required for each registration.

This post goes further. We walk through a serverless architecture that handles the full TFN registration lifecycle: submission, carrier review, and resolution (including rejections). It uses AWS Step Functions for orchestration, Amazon EventBridge for async status callbacks, and a human-in-the-loop pattern for handling rejections. The same architecture applies to International Toll-Free Numbers (ITFNs) by adjusting the RegistrationType and IsoCountryCode.

Prerequisites

Before deploying this solution, you need:

  • Technical prerequisites: AWS Command Line Interface (AWS CLI) v2, AWS Serverless Application Model (AWS SAM) CLI, Python 3.12+, and an AWS account with AWS End User Messaging SMS enabled. See the repository README for full setup instructions.
  • An email address to receive SNS notifications for registration status updates.
  • The registration data for each TFN you want to register:
    • Company information.
    • Contact information.
    • Messaging use case and details.
    • Opt-in workflow description.
    • Opt-in workflow image (PNG, JPEG, or PDF, max 500 KB) showing how end users consent to receiving SMS messages. (You can retrieve the full list of required fields with DescribeRegistrationFieldDefinitions.)
    • Message samples.

Note: For a complete step-by-step guide, see: How to register for a US toll-free number with AWS End User Messaging

The problem: Manual registration at scale

When you register a TFN through the AWS End User Messaging SMS console, you fill out a multi-step form for each number:

  1. Company Info – Company name, website, address, business type, tax ID.
  2. Contact Info – First name, last name, support email, support phone number.
  3. Messaging Use Case – Monthly volume, use case category, use case details, opt-in type, opt-in workflow description, opt-in image upload.
  4. Message Samples – 1–3 example SMS message bodies.
  5. Review and Submit.

For a single registration, this takes a few minutes. For 50, 100, or 500 registrations across tenants, it becomes a full-time job. After you submit a registration, each registration enters a carrier review period of 1–15 business days. If rejected, you need to identify the issue, fix the fields on the console, and resubmit. Tracking all of this across many registrations is where the real operational burden lives.

The following architecture automates submission by using an API and tracks the full lifecycle with event-driven callbacks.

Architecture overview

You deploy the solution as an AWS SAM template. It consists of the following components:

Component Purpose
Amazon API Gateway RESTful endpoints for submission, file upload, and resume
AWS Lambda (six functions) Business logic for intake, presigned URL generation, registration, event routing, notification, and human-in-the-loop resume
AWS Step Functions Orchestrates the multi-day workflow with pause and resume capability
Amazon EventBridge Captures async registration status change events from the carrier
Amazon DynamoDB Tracks registration state across the full lifecycle
Amazon Simple Notification Service (SNS) Sends operator notifications on status changes
Amazon Simple Storage Service (Amazon S3) Stages opt-in workflow attachments with presigned upload URLs
Serverless architecture for toll-free number registration, showing the flow from API submission through carrier review to resolution

Figure 1: Architecture from submission through resolution, showing the synchronous submission flow, async Amazon EventBridge callbacks, and human-in-the-loop rejection handling

The flow

The workflow has three phases: submission, carrier review, and resolution.

Registration lifecycle timeline across submission, carrier review, and resolution phases

Figure 2: Registration lifecycle showing the three phases. No AWS Lambda functions run in Phase 2. The rejected path includes human-in-the-loop remediation before completion

Phase 1: Submission (within seconds)

  1. The application calls POST /api/submit with the registration data (company info, contact info, use case, message samples, and an Amazon S3 reference to the opt-in screenshot).
  2. The Intake Lambda validates the payload, writes the full request to Amazon DynamoDB, and starts a Step Functions execution.
  3. Step Functions orchestrates the seven AWS End User Messaging SMS (EUM SMS) API calls in order:
  4. An SNS notification confirms submission. The workflow then pauses using the Step Functions .waitForTaskToken integration pattern.

Phase 2: Carrier review (1–15 business days)

During this phase, no Lambda functions run. Step Functions holds a task token in DynamoDB and waits. The toll-free number’s monthly fee still applies.

When the carrier completes its review, AWS End User Messaging emits a Registration Status Change event to Amazon EventBridge. An Amazon EventBridge rule routes this event to the Event Router Lambda. The Lambda queries a DynamoDB Global Secondary Index (GSI) to find the matching request by registrationId and retrieves the stored task token. It then calls SendTaskSuccess (if approved) or SendTaskFailure (if rejected) to resume the Step Functions execution.

Phase 3: Resolution

If approved: The workflow advances to the completion state, the operator is notified, and the TFN is ready for sending messages.

If rejected: The workflow enters a WaitForHumanIntervention state:

  1. The operator receives an SNS email with the specific rejection reasons (for example, messagingUseCase.optInImage: MISSING_REQUIRED_FIELD).
  2. The operator fixes the registration on the AWS End User Messaging SMS console.
  3. The operator calls POST /api/resume/{requestId} to unblock the workflow.
  4. The workflow completes successfully.

Scaling to many registrations

For organizations managing registrations across many customers or teams:

  • Batch submission: Call /api/submit for each registration. Each spawns an independent Step Functions execution that tracks its own lifecycle.
  • State tracking: DynamoDB holds all registration state. Query by requestId (internal ID) or registrationId (EUM SMS ID, through the GSI) to build dashboards or status pages.
  • Rate limiting: The EUM SMS API has service quotas for RequestPhoneNumber and PutRegistrationFieldValue. If submitting hundreds of registrations in a burst, add a Step Functions Wait state or use Amazon Simple Queue Service (SQS) with a rate-limited consumer to stay within limits.
  • International Toll-Free Numbers (ITFNs): The same architecture extends to international toll-free numbers. Change the RegistrationType and IsoCountryCode, and adjust the field mappings for the target country’s registration form. You can use DescribeRegistrationFieldDefinitions to retrieve the required fields for any registration type.

Production hardening: This solution ships as a sample. The AllowedOrigin parameter is required and rejects the wildcard CORS origin (’*’). Set it to your application’s domain when you deploy. The IAM policies in the template are scoped to resource ARNs in your account and Region, except SendTaskSuccess and SendTaskFailure, which don’t support resource-level permissions.

Deployment

The solution is available as an open source AWS SAM template:

Repository: sample-sms-tfn-registration-automation on GitHub

Deploy with:

sam build
sam deploy --guided \
  --parameter-overrides \
    [email protected] \
    AllowedOrigin=https://app.example.com \
    DryRun=false

Use the DryRun parameter to test the full workflow, from submission through resolution, without creating real registrations or incurring phone number charges. When set to true, the Registration Lambda returns synthetic IDs while DynamoDB writes, Step Functions execution, and SNS notifications all run for real.

Clean up

Because toll-free number lease fees begin the moment you request a number, tear down resources you no longer need. To remove the stack and stop incurring charges:

sam delete --stack-name <your-stack-name> --region <your-region>

# Delete the registration and phone number separately:
aws pinpoint-sms-voice-v2 delete-registration --registration-id <REG_ID>
aws pinpoint-sms-voice-v2 release-phone-number --phone-number-id <PHONE_ID>

Cost considerations

Registrations spend most of their time waiting for carrier review. The architecture reflects that:

Resource Cost driver
Toll-free phone number Monthly fee begins as soon as RequestPhoneNumber is called, regardless of registration status. See AWS End User Messaging Pricing.
Step Functions Standard workflow charges per state transition. No charges while paused at .waitForTaskToken.
DynamoDB On-demand billing. Each registration record is small (~2 KB). Negligible at hundreds of registrations.
Lambda Only runs during submission (~seconds) and when Amazon EventBridge delivers a status change. No polling.
S3 Storage for opt-in screenshots. 90-day lifecycle auto-deletion.
SNS You pay per email sent. A few cents for a typical batch.

The .waitForTaskToken pattern keeps the workflow idle during the 1–15 business day carrier review: no Lambda functions run and nothing polls. DynamoDB storage and the TFN monthly fee still apply.

Important: You begin incurring costs for TFNs as soon as you request the phone number, even if your registration has not yet been submitted or approved. Factor this into your batch strategy.

Monitoring and observability

To track registrations in flight:

  • Step Functions console – Each registration has its own execution. View the current state (waiting for approval, rejected, complete) and full execution history.
  • DynamoDB queries – Query the table by requestId (hash key) to get the full record for a specific registration, including its current status, registrationId, and phoneNumber. Use the registration-id-index GSI to look up a request by the EUM SMS registrationId. Scan with a filter on status to find all registrations in a given state (for example, REGISTRATION_SUBMITTED or REGISTRATION_REQUIRES_UPDATES).
  • Amazon CloudWatch Logs – Each Lambda function has its own log group. The Event Router logs structured fields like regId, status, and type for every Amazon EventBridge event it receives. Use CloudWatch Logs Insights to query across log groups by registrationId to trace how an event was processed.
  • SNS notifications – Operators receive email for submissions, approvals, rejections (with specific field-level rejection reasons from the carrier), and timeouts. No polling or console checks required.
Step Functions state machine for one registration, showing the happy path and the rejection catch branch

Figure 3: A single registration execution on the Step Functions console. Green states are the happy path. The rejection path (Catch #1) pauses for human intervention before completing

For organizations running hundreds of registrations, you can add a CloudWatch dashboard that tracks metrics like: registrations submitted per day, average approval time, rejection rate, and registrations currently awaiting review.

Conclusion

In this post, we showed how to automate the full TFN registration lifecycle using AWS Step Functions, Amazon EventBridge, and a human-in-the-loop rejection pattern. The architecture reduces each registration to a single API call and runs the seven EUM SMS API calls behind the scenes. It then waits for the carrier without running Lambda functions, and notifies you when something needs attention.

 


About the authors

Rommel Sunga

Rommel Sunga

Rommel is a Senior Solutions Architect at AWS, based in Singapore. He specializes in AWS End User Messaging and Amazon Simple Email Service, helping customers build scalable and reliable communication solutions. With expertise in cloud-based messaging architectures, he works with organizations to improve their customer engagement.

Brett Ezell

Brett Ezell

Brett is a Solutions Architect for AWS End User Messaging and Amazon Simple Email Service. Outside of work, Brett spends his time collecting vinyl, comic books, catching live shows, and training at the gym.

Tyler Holmes

Tyler Holmes

Tyler is a Senior Specialist Solutions Architect. He has a wealth of experience in the communications space as a consultant, an SA, a practitioner, and leader at all levels from Startup to Fortune 500. He has spent over 14 years in sales, marketing, and service operations, working for agencies, consulting firms, and brands, building teams and increasing revenue.

Publishing LMS courses with a Kiro SCORM pipeline

Post Syndicated from Omar Gonzalez original https://aws.amazon.com/blogs/architecture/publishing-lms-courses-with-a-kiro-scorm-pipeline/

Publishing a Shareable Content Object Reference Model (SCORM) course to a learning management system (LMS) is rarely as straightforward as writing the course. The LMS needs SCORM packages to adhere to a standard format for successful import and launch. Producing one by hand means restructuring slides into modules and hand-editing SCORM manifests. The packaging mechanics pull authors away from developing the course content itself.

This post shows how to use Kiro, an agentic AI with an integrated development environment (IDE) and a CLI, to automate the conversion of raw training material into compliant SCORM packages. Testing with a small course included in the sample produced a SCORM package in a few minutes.

Solution overview

Kiro structures course content and guides the author through the publishing workflow. Figure 1 shows the end-to-end pipeline from raw material to upload-ready package.

Diagram of the two-layer Kiro SCORM pipeline: source material flows into Kiro, which structures the content and runs the build to produce an upload-ready SCORM package for the LMS.

Figure 1: Kiro course publishing pipeline overview

An author places raw training material (slide decks, playbooks, and written docs) in a course folder, and Kiro runs two pipeline stages. Course Development includes planning and authoring lessons. Course Build includes linting the lessons, building the SCORM package, and validating the SCORM package. The output is a SCORM 1.2 .zip ready to upload to your LMS. You drive the whole pipeline from a Kiro conversation. Start by loading your material into a course folder, ask Kiro to build the course, and it hands back the upload-ready .zip file.

The Kiro building blocks this pipeline uses

Kiro is an agentic AI available as an IDE and a CLI (kiro-cli). This pipeline is built from three Kiro primitives:

  • Steering – Markdown files that give Kiro persistent context (.kiro/steering/) about your workspace. Steering loads automatically. always files load in every session, fileMatch files load when you touch files they govern, and manual files load on demand.
  • Specs – Specifications (.kiro/specs/) that formalize the development process for features or bug fixes.
  • Hooks – Automated triggers (.kiro/hooks/) that run prompts or shell commands when events occur in the IDE, such as creating a file, saving a file, or ending a session.

The LMS content publishing pipeline

The following sections walk through the steps a course goes through and the parts that carry them out.

How a course gets built

For a new course, Kiro works through six steps with you in conversation:

  1. Add source material to courses/<name>/ (slide decks, Word, PDFs, playbooks, Markdown docs).
  2. Plan – Kiro reads the source and creates the course.yaml manifest.
  3. Author – Kiro converts the source into a lesson.md per module.
  4. Lint – Check the authored lessons for structural problems such as missing frontmatter, broken image paths, or a knowledge check with no correct answer.
  5. Build – Render the lessons into one index.html and compress the SCORM package.
  6. Validate – Confirm the .zip file is a well-formed SCORM 1.2 package before it goes to the LMS.

The course manifest

Each course starts as a single manifest, course.yaml, that declares its modules, source material, and SCORM build configuration. Kiro creates the manifest at step 2, after you upload your source content and ask it to build a course.

course:
  title: "Build a SCORM Course from Markdown"
  short_name: "sample-course"
  short_description: "A three-module example course."
  default_language: en-US
  total_duration_minutes: 30

modules:
  - id: module-1
    title: "The Authoring Pipeline"
    target_duration_minutes: 10
  # ...

scorm:
  version: "1.2"
  output: scorm/dist/sample-course.zip
  passing_score: 100
  min_page_dwell_seconds: 5

A few notable settings you can customize:

  • min_page_dwell_seconds sets the minimum time a learner must spend on a page before the Next button unlocks.
  • target_duration_minutes sets each module’s expected completion time, shown to learners on the course home page.
  • total_duration_minutes sets the course-level expected completion time, shown to learners and reported to the LMS.
  • passing_score sets the score a learner must reach for the LMS to mark the course complete (written into the SCORM manifest as the mastery score).

Steering is where the pipeline’s knowledge lives

Steering is where this pipeline keeps everything Kiro needs to know. Steering files hold three kinds of knowledge:

  • Lessons learned – The SCORM knowledge accumulated from actually building and running courses.
  • Authoring conventions – How to break source material into modules and lessons, plus writing-style guidance so each lesson.md reads as polished, learner-facing content.
  • The repository contract – The uniform courses/<name>/ layout every course follows, so the same shared scripts work for every course.

Each steering file loads in one of the three modes from earlier: always, fileMatch, or manual. Every session picks up only the guidance it needs. The steering files in the sample repository are listed in the following example, grouped by their inclusion mode:

.kiro/steering/

# always - loaded in every session
product.md          what the pipeline is for
pipeline.md         the six-step build sequence and commands
structure.md        repo layout and the courses/<name>/ contract
tech.md             toolchain and required versions

# fileMatch - loaded when matching files are edited
scorm-invariants.md   invariants for scorm/src, build output, and course.yaml

# manual - deep guides, pulled in on demand
course-planning.md          plan modules and course.yaml
source-intake.md            convert raw source into lessons
lesson-authoring.md         write each lesson.md
scorm-authoring.md          build and package the SCORM zip
assessment-authoring.md     author an optional course assessment

Hooks automate the repetitive steps

Hooks run automatically as Kiro or the author works, so common checks don’t depend on someone remembering a command:

  • Lint on save – Editing any lesson.md or course.yaml re-lints that course immediately.
  • Validate on package creation – A new SCORM .zip file appearing under scorm/dist/ is validated the moment it’s built.
  • Steering reflection – After each turn, Kiro proposes updates to the steering guidance to capture best practices worth keeping for future course development.

Spec-driven development of new features

The repo includes a feature spec kept as a reference for how the pipeline was built. The page-dwell-gating spec added the feature that enforces a minimum time a learner must spend on a page before proceeding. After you understand the pipeline, you can extend it the same way, using the spec-driven development process in Kiro.

Walkthrough: Building a course

The following walkthrough takes you from an empty repo to an upload-ready SCORM package.

Prerequisites

  • Access to Kiro (IDE or kiro-cli).
  • Node.js 20+, Python 3.11+, and the zip command, with permission to install package dependencies (Kiro installs the project’s npm and pip packages during setup).
  • Raw training material to convert (slide decks, playbooks, and written docs).

Open the repo

Open the repository in Kiro. The workspace steering under .kiro/steering/ loads automatically, so Kiro already knows the pipeline commands, the repo layout, and the SCORM constraints.

Build the sample course

Building a course is a single prompt. In the Kiro chat panel, enter:

“Build the sample course.”

Kiro runs lint, build, and validate, and replies with the path to the upload-ready .zip file: courses/sample-course/scorm/dist/sample-course.zip.

Turn your own material into a course

For a new course, add your source material to courses/<name>/ and enter the following prompt:

“Plan and build a course from <name>.”

Kiro walks through planning course.yaml, authoring one lesson.md per module, and packaging.

Verify before you publish

You’ve already seen Kiro lint, build, and validate the package, and an included test checks the course’s progress tracking. The last check is a visual one. Serve the built course locally over HTTP to see it for yourself before uploading:

cd scorm/build    # the unzipped build output (same content as the .zip file)
python3 -m http.server 8080 # then open http://localhost:8080 in a browser

This serves the full built course locally. Figure 2 shows the course home page as a learner would see it.

Screenshot of the sample course home page showing the course title, description, and three module cards.

Figure 2: Local preview of the sample course home page

The home page displays the course title, a short description, and a card for each module showing its number, estimated duration, a brief summary, and a link to start.

The final step is to upload the .zip file to your SCORM 1.2-compliant LMS to confirm end to end. Learning management systems vary. If you run into an issue, ask Kiro to help you troubleshoot it and learn the specifics of your LMS.

Cleaning up

There are no cloud resources to tear down because the pipeline runs entirely as local scripts and a Kiro session against your own repository. If you built the bundled sample course only to try the pipeline, you can safely delete courses/sample-course/scorm/dist/ and courses/sample-course/scorm/build/. Both are build output.

Conclusion

In this post, you saw how to turn raw training material into LMS-ready SCORM packages with Kiro. Authors stay focused on the course while Kiro handles the packaging. Because the pipeline lives in the repo as Steering, Specs, and Hooks, that expertise is version-controlled and reusable across every course and team.

To get started, clone the aws-samples repository, drop your source material into a course folder, and ask Kiro to build the course. To learn more, explore Kiro.

 


About the author

Omar Gonzalez

Omar Gonzalez

Omar is a Senior Solutions Architect at Amazon Web Services in Southern California with more than 20 years of experience in IT. He specializes in enterprise platforms, AI, and cloud transformation for financial services customers. Outside of work, he enjoys hiking and spending quality time with his family.

Building document-investigation agents with Amazon Bedrock AgentCore and Amazon S3 Files

Post Syndicated from Ben Freiberg original https://aws.amazon.com/blogs/compute/building-document-investigation-agents-with-amazon-bedrock-agentcore-and-amazon-s3-files/

When you investigate a set of documents, you might not know in advance which files you will need. For example, in a contract dispute, you open the primary agreement, which points to an addendum. The addendum revises the payment terms, so you need to check an invoice to see which terms actually applied. The set of required files grows as you read. An AI agent can work like a human analyst. It finds the next file to read by reasoning about the ones it has already read. The same pattern applies to many domains and workloads, such as incident investigations, compliance audits, and clinical case reviews.

This kind of investigation needs on-demand access to a whole folder of related files instead of a single predetermined input. The usual approach connects stages with object downloads and uploads. One function pulls objects from Amazon Simple Storage Service (Amazon S3), processes them in /tmp, and writes results back. The next stage repeats the cycle. Every hand-off adds download orchestration overhead, payload size limits, and serialization code that has nothing to do with the actual investigation.

Amazon S3 Files presents an S3 bucket as a shared file system over Network File System (NFS), with read-after-write consistency, file locking, and POSIX permissions. An AWS Lambda function can mount it directly. Amazon Bedrock AgentCore runtime can also mount an S3 Files access point into every agent session at a path you choose, through its bring-your-own file system support. This post shows how to use the two together. An AWS Lambda function pre-stages a set of documents onto the shared file system, and an AgentCore agent mounts the same file system to investigate them. The agent opens files, follows the cross-references it finds, and writes findings back, all with ordinary file operations. Neither side needs S3 API calls to hand documents to the other.

Solution overview

The design has two stages that share one Amazon S3 Files file system.

AWS Lambda pre-stage function and Bedrock AgentCore agent sharing one Amazon S3 Files file system backed by an S3 bucket

Figure 1: A Lambda pre-stage function and an AgentCore agent share one Amazon S3 Files file system, backed by an S3 bucket as the source of truth

Stage 1 is a Lambda function. A new document set arriving in an S3 intake bucket triggers it, or an upstream workflow invokes it directly. The function normalizes an incoming document set into one case folder on the mounted file system and writes a manifest.json that lists the documents. It uses standard file writes, with no object upload code.

Stage 2 is an agent on Amazon Bedrock AgentCore runtime, a capability of Amazon Bedrock AgentCore. It mounts the same file system through bring-your-own file system. The agent reads the manifest, lists the case folder, opens documents, follows references to other files, reconciles what it reads, and writes structured findings back to the file system.

An S3 bucket backs the file system and remains the source of truth. Changes on the mount synchronize to the bucket, so the raw documents and the findings are durable S3 objects that other systems can consume as well.

Walking through the design

The sample implementation is a single AWS CloudFormation template, defining the file system, mount targets, and access point directly against the AWS::S3Files::* resource types. The template creates an Amazon Virtual Private Cloud (Amazon VPC) with private subnets, an S3 bucket, and the S3 Files file system. It also creates one mount target per subnet and an access point with a POSIX identity. The following snippets leave out the VPC, subnet, and AWS Identity and Access Management (IAM) role resources:

# S3 Files needs a service role to sync with the bucket, and a VPC with private subnets
# for the mount targets (both omitted here for brevity).
FileSystem:
  Type: AWS::S3Files::FileSystem
  Properties:
    Bucket: !GetAtt CaseBucket.Arn
    RoleArn: !GetAtt FileSystemServiceRole.Arn
    AcceptBucketWarning: true

MountTargetA:
  Type: AWS::S3Files::MountTarget
  Properties:
    FileSystemId: !GetAtt FileSystem.FileSystemId
    SubnetId: !Ref PrivateSubnetA
    SecurityGroups: [!Ref FileSystemSecurityGroup]

MountTargetB:
  Type: AWS::S3Files::MountTarget
  Properties:
    FileSystemId: !GetAtt FileSystem.FileSystemId
    SubnetId: !Ref PrivateSubnetB
    SecurityGroups: [!Ref FileSystemSecurityGroup]

AccessPoint:
  Type: AWS::S3Files::AccessPoint
  Properties:
    FileSystemId: !GetAtt FileSystem.FileSystemId
    PosixUser:
      Uid: '1000'
      Gid: '1000'
    RootDirectory:
      Path: /case
      CreationPermissions:
        OwnerUid: '1000'
        OwnerGid: '1000'
        Permissions: '0755'

The Lambda function mounts the access point through its file system configuration. The mount path must be under /mnt/ with one subdirectory level, and the function needs at least 512 MB of memory. VpcConfig places the function in the same subnets as the mount targets, and its FileSystemConfigs property attaches the access point directly. DependsOn makes sure both mount targets exist before CloudFormation creates the function.

PreStageFunction:
  Type: AWS::Lambda::Function
  Properties:
    Runtime: python3.13
    Handler: index.handler
    MemorySize: 512
    Role: !GetAtt PreStageFunctionRole.Arn
    VpcConfig:
      SubnetIds: [!Ref PrivateSubnetA, !Ref PrivateSubnetB]
      SecurityGroupIds: [!Ref FileSystemSecurityGroup]
    FileSystemConfigs:
      - Arn: !GetAtt AccessPoint.AccessPointArn
        LocalMountPath: /mnt/case
    Code:
      S3Bucket: !Ref DeploymentBucket
      S3Key: pre-stage.zip
  DependsOn:
    - MountTargetA
    - MountTargetB

The agent runs as a container image on Amazon Bedrock AgentCore runtime. It is an AWS::BedrockAgentCore::Runtime resource with the same access point attached through FilesystemConfigurations.S3FilesAccessPoint. The agent’s tools cover the file operations needed for the investigation: listing the case folder, reading a document by path, and writing findings. The model decides which tool to call at each step.

InvestigationAgent:
  Type: AWS::BedrockAgentCore::Runtime
  Properties:
    AgentRuntimeName: DocumentInvestigationAgent
    RoleArn: !GetAtt AgentRuntimeRole.Arn
    AgentRuntimeArtifact:
      ContainerConfiguration:
        ContainerUri: !Sub '${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/investigation-agent:latest'
    NetworkConfiguration:
      NetworkMode: VPC
      NetworkModeConfig:
        VpcConfig:
          Subnets: [!Ref PrivateSubnetA, !Ref PrivateSubnetB]
          SecurityGroups: [!Ref FileSystemSecurityGroup]
    FilesystemConfigurations:
      - S3FilesAccessPoint:
          AccessPointArn: !GetAtt AccessPoint.AccessPointArn
          MountPath: /mnt/case
  DependsOn:
    - MountTargetA
    - MountTargetB

One of the agent’s tools looks like this:

@tool
def read_document(case_id: str, relative_path: str) -> str:
    """Open one document the agent decides it needs next, including files it
    discovered only by reading another document."""
    target = (pathlib.Path(MOUNT_PATH) / case_id / relative_path).resolve()
    return target.read_text(encoding="utf-8", errors="replace")

Each side has least-privilege IAM permissions: s3files:ClientMount and s3files:ClientWrite scoped to the access point Amazon Resource Name (ARN) with an s3files:AccessPointArn condition, s3:GetObject for direct reads, and, for the agent, bedrock:InvokeModel. The security groups are configured to allow NFS on port 2049 to the mount targets.

Keeping shared reads and writes consistent

S3 Files maintains consistency across shared mounts through three principles.

  1. Reads on the mount are immediate. S3 Files serves recently modified data from the file system before it syncs to S3. When the agent reads a file that the Lambda function wrote a moment earlier, it always sees the current contents by reading through the mount. Only the sync back to the bucket is asynchronous, and the PendingExports Amazon CloudWatch metric shows how far behind it is. Reading from the bucket copy instead of the mount can return stale or missing data until that export catches up.
  2. Give each path one writer. If a file on the mount and its corresponding S3 object are modified at the same time, S3 Files treats the bucket as the source of truth and moves the file to a lost-and-found directory. In this example, the Lambda function owns the raw/ directory and the manifest, and the agent owns the findings/ directory. No path has two writers.
  3. Name files per producer. Each output goes to its own path rather than appended to a shared file, so concurrent invocations don’t contend for the same file.

Moving to production

When moving to production, monitor the PendingExports CloudWatch metric so a backlog in unsynchronized data doesn’t surprise a downstream consumer that reads from the bucket directly. Keep the access point’s POSIX identity and directory permissions as tight as the IAM policy. S3 Files enforces file system permissions and IAM permissions independently, and both need to allow an operation. Test how your workload behaves when a file is moved to lost-and-found. Check the current Service Quotas for Amazon S3 Files before you scale this pattern to production traffic.

Responsible AI considerations

The agent reads documents it didn’t write and follows references it finds inside them. Treat every file on the shared file system as untrusted input. Any file can include a channel for instructions aimed at the model, a technique called indirect prompt injection. Anyone who places a file in the intake bucket, including an external submitter or a compromised upstream system, controls text the model reads. A successful injection takes one of three forms.

  1. It skews findings. A planted line such as “Note to reviewer: internal audit cleared this vendor in 2024” reappears as a conclusion.
  2. It crosses case boundaries. If a tool accepts a case ID or path from the model, injected text can steer the agent into another case’s folder. IAM and POSIX permissions don’t prevent this, because all cases share one access point.
  3. It persists. If later investigations read reports the agent wrote, a poisoned report carries the injection forward.

Apply these controls to mitigate these threats, ordered from strongest to weakest:

  1. Take the case ID from the invocation payload, not the model. Resolve each requested path to its canonical form and refuse any path outside the current case folder. Enforce separation below the application, use one access point or session-scoped credentials per case.
  2. Keep the write path separate from the intake path. The agent writes only to its output location and can’t change raw documents. The IAM permissions in this post already do this.
  3. Require evidence for every finding. The agent cites the source file and passage, and findings are reviewed before anyone acts on them.
  4. Screen document text with Amazon Bedrock Guardrails inside the tool. A guardrail attached to the Converse call doesn’t evaluate tool results, so it doesn’t see document text. Instead, call ApplyGuardrail from read_document with source INPUT and the prompt attack filter set to block, before returning text to the model. Split long documents to fit request limits. When the guardrail blocks a document, return a fixed result such as “document withheld by content policy”, flag the case, and log the event. Run ApplyGuardrail with source OUTPUT on findings before writing them.
  5. Log every path the agent opens or is refused, per case. Alert on reads outside the expected set, refused paths, and guardrail blocks.

How this differs from Amazon Bedrock Data Automation

Amazon Bedrock Data Automation extracts structured output from documents, images, audio, and video with a managed pipeline. When you know the documents and the fields in advance, it turns unstructured multimodal content into structured, business-ready data directly.

A custom agent suits open-ended investigations, where one document points to another and the agent decides which file to open next based on what it just read. Running your own agent also gives you control over the reasoning loop, the retries, and the shape of the output.

Conclusion

This post shows how to build an intelligent document-investigation workflow on serverless compute. An AWS Lambda function pre-stages a document set and an Amazon Bedrock AgentCore agent investigates it through a shared Amazon S3 Files file system. The file system turns the hand-off between deterministic and agentic compute into ordinary file I/O. Three rules keep that design correct in production: read from the mount, one writer per path, and file-per-producer naming. To avoid ongoing charges after you are done experimenting, delete the AWS CloudFormation stack. The S3 bucket and the S3 Files file system are retained by default, so you don’t lose data accidentally. Delete them separately after you no longer need them.

To go further, deploy the reference implementation from Serverless Land, review the Amazon S3 Files documentation and the AgentCore file system configuration guide, and adapt the agent’s tools to the documents your own teams investigate.

 


About the authors

Ben Freiberg

Ben Freiberg

Ben is a Senior Solutions Architect at AWS.

Nithin Chandran Rajashankar

Nithin Chandran Rajashankar

Nithin is a Technical Account Manager at AWS.

How Anchorage Digital and Turnkey verify code with AWS Nitro Enclaves

Post Syndicated from Alexander Qin original https://aws.amazon.com/blogs/architecture/how-anchorage-digital-and-turnkey-verify-code-with-aws-nitro-enclaves/

This post is co-written with Prasanna Gautam from Anchorage Digital and Arnaud Brousseau from Turnkey.

In crypto, trust is a liability. A wallet often delegates computation to a third party to parse a transaction, evaluate a policy, or manage key material. Each time, it raises a question: how do you know the service ran the code it claims to have run? Contractual trust, network perimeter security, and reputation don’t provide a cryptographic answer. Verifiable infrastructure built on AWS Nitro Enclaves does.

Anchorage Digital is home to America’s first federally regulated digital asset bank. Turnkey is a verifiable key management infrastructure provider whose platform secures millions of wallets. Together, they built that cryptographic answer on top of AWS Nitro Enclaves. Their collaboration produced VisualSign, an open source, cross-chain transaction parser that runs inside Nitro Enclaves and returns results with attestation-backed proof of what software produced them. The immediate application protects users from phishing attacks by making complex transactions human-readable, and the architecture is a blueprint for verifiable infrastructure that extends well beyond blockchain.

This post describes the architecture and design decisions behind the system, including why AWS Nitro Enclaves are foundational to the trust model. It also shows how the same pattern applies to any high-trust workflow, not only Web3 or crypto.

The problem: Blind signing and phishing in Web3

Cryptocurrency phishing attacks exploit a basic limitation of wallets: they can’t natively parse the full semantics of complex transactions. Blind signing is the approval of a transaction whose full effects the wallet can’t display, and it’s what attackers depend on. A user interacting with a decentralized finance (DeFi) protocol might receive a request to sign a transaction, but the wallet displays only a raw hex payload or a partial summary. Attackers craft malicious transactions that look benign at the surface level but contain hidden operations: token approvals to attacker-controlled addresses, unexpected contract interactions, or fund transfers disguised within multi-step operations.

The industry has attempted to make transactions readable with simulation services and transaction decoders. These approaches typically require the wallet to trust a third-party service without a way to verify which code the service ran or that the result came back unmodified.

A transaction parser needs two guarantees:

  • Correctness: The parsing logic is the exact, unmodified code that was audited and approved.
  • Integrity: The parsed output hasn’t been tampered with between the parser and the wallet.

The solution: VisualSign on Turnkey Verifiable Cloud, powered by AWS Nitro Enclaves

VisualSign Parser is an open source Rust codebase developed by Anchorage Digital. It converts raw blockchain transactions into structured, human-readable JSON. As of this writing, it supports Ethereum and its layer 2 networks (L2s), including Arbitrum, Optimism, Base, and Polygon. It also supports Solana, Sui, and Tron, with additional chains in development.

Consider a Uniswap Universal Router transaction: the uniswap-v2swap.input fixture in the visualsign-parser test suite is 2,470 characters of raw hex. VisualSign turns it into a clear hierarchical breakdown: a Permit2 approval, a V2 swap with specific token amounts and minimum outputs, a fee payment, and a WETH unwrap. Each step lists explicit addresses, amounts, and expiration dates.

Turnkey Verifiable Cloud

The collaboration between Anchorage Digital and Turnkey began after Turnkey published its whitepaper in January 2025. The whitepaper details how Turnkey uses AWS Nitro Enclaves to secure wallets and private keys at scale. Turnkey has detailed this implementation and AWS architecture in the post Building secure, verifiable blockchain key management on AWS Nitro Enclaves at Turnkey.

Anchorage Digital now uses Turnkey Verifiable Cloud (TVC) to outsource computation verifiably. Rather than running transaction parsing logic in its own infrastructure, Anchorage Digital deployed VisualSign Parser within Turnkey-deployed AWS Nitro Enclaves.

Anchorage Digital and Turnkey documented the full verification workflow in the Wallet Integration Guide so that other wallet providers can use VisualSign Parser in the same way.

With VisualSign Parser, crypto wallets, typically limited in their ability to run complex logic, can verifiably parse complex cryptocurrency transactions. This helps prevent phishing attacks that rely on users approving signatures for malicious transactions without understanding them.

Beyond VisualSign, TVC supports running any high-trust workflow inside verifiable, isolated infrastructure, including policy evaluation, compliance checks, and transaction classification. You can now build with cryptographic proof instead of relying on trust for your most sensitive operations.

Architecture: How VisualSign runs inside AWS Nitro Enclaves

The following diagram shows how a parsing request travels from the wallet client to the enclave and back, and where attestation enters the flow.

Flow diagram: a wallet client sends a raw transaction to a Nitro Enclave, which returns parsed JSON and a signed attestation.

Figure 1: End-to-end VisualSign flow

The flow works as follows:

  1. A wallet client sends a raw transaction payload to the Turnkey API.
  2. The Turnkey API forwards the request to any AWS Nitro Enclave-supported EC2 instance running QuorumOS. QuorumOS is the open source operating system Turnkey uses to run enclaves, and the VisualSign Parser binary.
  3. The parser converts the raw transaction into structured, human-readable output.
  4. The enclave signs the response with an ephemeral P-256 key and requests an AWS Nitro attestation document from the Nitro Hypervisor.
  5. The wallet client verifies the attestation and signature, confirming the response came from the expected software running in a genuine Nitro Enclave.

Anchorage Digital doesn’t need to trust Turnkey: ephemeral key signatures and AWS Nitro attestation documents prove that enclaves are provisioned with the correct software. The attestation document contains Platform Configuration Registers (PCRs), which are cryptographic measurements of the enclave image, kernel, and application. The wallet client compares PCRs and other attestation fields against the values derived from reproducible builds. Reproducible builds compile the same source code into byte-identical binaries every time, regardless of who runs the build or where. Because the binary is always identical, its PCR measurements are deterministic, so the trusted values stay constant across rebuilds and verification policies never need to be updated. Reproducibility allows any party to independently verify that the enclave runs the expected QuorumOS version, as well as the exact, unmodified VisualSign Parser code.

Why AWS Nitro Enclaves

AWS Nitro Enclaves provide several properties that are essential to this architecture:

  • Hardware and software-based isolation – Nitro Enclaves use the same Nitro Hypervisor technology that provides CPU and memory isolation for Amazon Elastic Compute Cloud (Amazon EC2) instances to isolate the vCPUs and memory for an enclave from a parent instance. The Nitro Hypervisor ensures that the parent instance has no access to the isolated vCPUs and memory of the enclave.
  • No persistent storage or external networking – Enclaves communicate only with the parent instance over a local vsock channel. No persistent storage, no network interface, no DNS, no metadata service.
  • Cryptographic attestation – Attestation is a feature unique to AWS Nitro Enclaves. The Nitro Hypervisor produces signed attestation documents that include PCR measurements of the enclave image, signed by the AWS Nitro Attestation PKI, proving the enclave’s identity to external services.
  • No operator access – No single AWS, Turnkey, or Anchorage Digital operator can access customer data within the enclave.

These properties make Nitro Enclaves well-suited for workloads where the computation itself must be verifiable: not only the inputs and outputs, but the guarantee that a specific, known program produced the result.

Advancing Go tooling for Nitro Enclave verification

Attestation verification in production is difficult to implement correctly. The attestation document specification is a CBOR Object Signing and Encryption (COSE) Sign1 structure whose payload carries an X.509 certificate chain, PCR measurements, and optional fields such as UserData and PublicKey. Correct verification means parsing CBOR, validating the COSE signature, and walking the certificate chain back to the AWS Nitro root certificate. It also means checking expiration and comparing PCR values, with clear error handling at each step.

awsnitroverifier: A purpose-built Go library for Nitro attestation

Anchorage Digital built and open-sourced awsnitroverifier, a Go library for validating AWS Nitro Enclave attestation documents with complete chain-of-trust verification. The team designed it to address the gaps they encountered while building the VisualSign verification workflow. The library implements built-in PCR validation and separates parsing errors from validation failures, instead of leaving that to the caller. Handling it in the library makes the cause of an error easier to determine. The library’s clear two-tier error handling separates malformed input (err != nil) from validation failures (result.Valid == false). Each validation failure includes a specific, actionable error message (PCR mismatch, expired certificate, chain verification failure) rather than a generic “verification failed.”

The library also gives direct access to the UserData, PublicKey, and Nonce fields, the root certificate fingerprint, and chain trust status to validate the Nitro Enclave used for the request. The library comes with tests covering the verification package. Developers also have the option to write continuous integration and continuous delivery (CI/CD) tests without having to spin up a Nitro Enclave by using offline/test mode. There’s also an included migration guide for users currently using nitrite, a widely used open-source AWS Nitro Enclave Go attestation library.

visualsign-turnkeyclient: End-to-end verification built on awsnitroverifier

On top of awsnitroverifier, Anchorage Digital built and open sourced visualsign-turnkeyclient, a Go client that performs end-to-end verification of transactions parsed in Nitro Enclaves. The awsnitroverifier library handles the low-level attestation document validation. The Turnkey client adds the application-level workflow.

You can adopt the verification levels in increasing order of complexity:

  1. Basic P-256 signature verification – Confirms that a key inside Turnkey’s infrastructure signed the response.
  2. Boot attestation with PCR validation – Uses awsnitroverifier to verify the enclave image and configuration match expected values.
  3. Complete manifest verification – Full QuorumOS manifest validation including the specific application binary hash.

This layered approach separates a focused attestation library from an application-level client, so you can build non-blockchain use cases on Nitro Enclaves by excluding blockchain-specific dependencies.

Beyond blockchain: Verifiable compute for any high-trust workflow

VisualSign is a blockchain product, but the infrastructure underneath it isn’t. Turnkey and Anchorage Digital built that architecture on reproducible builds, enclave-hosted computation, and attestation-gated access. It’s a general-purpose pattern for any scenario where you need cryptographic proof that a specific program processed specific data. Without the blockchain specifics, the architecture provides a verifiable compute building block.

The core pattern: Attestation-gated access

The key insight from Anchorage Digital’s implementation is that an enclave attestation document can serve as an access credential. Rather than granting access to sensitive resources based on network identity, API keys, or operator trust, the system grants access based on proof of what code is running. The principle is keeping the computation away from the sensitive data and using attestation to bridge the gap.

At Anchorage Digital, hardware security modules (HSMs) protect sensitive key materials. The VisualSign Parser runs in a separate Nitro Enclave, and the HSM grants access based on verified attestation, not based on trusting the network, the operator, or the host machine. The Anchorage Digital engineering team’s philosophy is that this pattern applies to any application that handles sensitive information or uses third-party libraries. The goal is to keep the computation away from the sensitive data. The following diagram shows the generalized form of the pattern. An untrusted input enters an enclave-hosted application, the enclave returns a signed bundle with an attestation document, and the HSM releases key material only after that attestation is verified.

Diagram: an enclave processes an input bundle and returns an attestation that unlocks HSM key material for that software.

Figure 2: Generalized attestation-gated access pattern

Several non-crypto workflows fit this pattern. A financial institution can run sanctions screening or anti-money laundering (AML) checks inside a Nitro Enclave built from a reproducible image. This produces cryptographically verifiable proof that the exact audited screening software processed the data. A healthcare provider can run a diagnostic model against patient data inside an enclave that has no persistent storage and no network access. Attestation confirms that the approved model binary ran, and the model operator has no path to copy the data out. Any application that relies on third-party libraries for sensitive operations can run that code inside an enclave with a pinned, reproducible build.

This architecture also supports an organizational separation of concerns that maps naturally to team expertise. As Anchorage Digital describes it: “someone with expertise in evaluating blockchain transactions uses first-party blockchain tools to vet them, while the team managing sensitive data focuses on verifying correct validation and applying policies.”

In practice, Anchorage Digital splits ownership of the system between two teams:

  • Blockchain domain experts build and maintain the VisualSign Parser using first-party blockchain tools and deep protocol knowledge. They understand Ethereum opcodes, Solana instruction formats, and DeFi protocol semantics.
  • Security and infrastructure teams focus on attestation verification, HSM policy enforcement, and access control around sensitive key materials. They understand PCR validation, certificate chains, and key management.

Neither team needs to operate in the other’s domain. The blockchain team doesn’t need access to key materials. The security team doesn’t need to understand Solana’s account model. The Nitro Enclave attestation serves as the machine-verifiable contract between the two teams that says, “This exact code ran, and here’s the proof.”

Conclusion

In this post, we showed how Anchorage Digital and Turnkey use AWS Nitro Enclaves to run the open source VisualSign Parser inside an attested enclave, so wallets can cryptographically verify which code parsed a transaction. The collaboration between Turnkey and Anchorage Digital started with a specific problem: making crypto transactions human-readable with cryptographic proof of correctness. What they built applies well beyond blockchain. The architecture pattern of reproducible builds, enclave-hosted computation, and attestation-gated access can serve as a general-purpose building block for verifiable compute.

To get started building verifiable applications, the VisualSign ecosystem and its underlying verification tooling are fully open source. Follow the documentation to spin up AWS Nitro Enclaves for your sensitive workloads that require cryptographic attestation. Use the awsnitroverifier Go library to implement AWS Nitro Enclave attestation validation. For blockchain applications, the VisualSign Turnkey Client and Wallet Integration Guide handle end-to-end attestation validation and wallet integration.

If you want to run your own workloads on Turnkey Verifiable Cloud, TVC is in private beta as of this writing. To get a more in-depth understanding of how Turnkey architected this solution, refer to the post Building secure, verifiable blockchain key management on AWS Nitro Enclaves at Turnkey.


About the authors

Alexander Qin

Alexander Qin

Alexander is a Startups Solutions Architect at Amazon Web Services (AWS). For over 5 years, Alexander has helped startups architect and build solutions on AWS. He is 6x AWS Certified and a member of the Games Technical Field Community (TFC). Alexander is passionate about thought leadership, having written 4 official AWS blog posts and spoken at AWS Summits, workshops, and build days.

Prasanna Gautam

Prasanna Gautam

Prasanna is a Research Lead at Anchorage Digital, where he focuses on designing secure, highly scalable infrastructure and verifiable compute solutions for institutional digital assets. As an early engineering hire, he built robust staking and governance systems across numerous networks and served on the Diem Technical Steering Committee that helped shape the core architecture for the Aptos and Sui blockchains. Drawing on over a decade of experience in high-performance distributed systems, including leading security at Mist Systems prior to Anchorage Digital, Prasanna now specializes in leveraging Trusted Execution Environments (TEEs) and reproducible builds to secure critical workflows and eliminate blind trust in complex blockchain transactions.

Arnaud Brousseau

Arnaud Brousseau

Arnaud is a founding and principal engineer at Turnkey, where he architected the company’s backend systems and authored the Turnkey Whitepaper. He also worked closely with Anchorage to bootstrap Turnkey Verifiable Cloud as a product and platform. Prior to Turnkey, Arnaud was a senior staff engineer at Coinbase, where he helped build and operate Coinbase Custody and led its evolution into a full-service prime brokerage spanning trading, staking, governance, and more.

Tracing failures across Terraform state boundaries with AWS DevOps Agent

Post Syndicated from Shruti Saxena original https://aws.amazon.com/blogs/devops/tracing-failures-across-terraform-state-boundaries-with-aws-devops-agent/

AWS DevOps Agent helps trace failures that cross Terraform state boundaries, which may be hard to diagnose because the team that sees the symptoms often doesn’t own the stack that caused them. This post shows how AWS DevOps Agent helps find the root cause of these cross-stack failures. First, some context on why those boundaries can introduce challenges.

Many teams split infrastructure into independent Terraform stacks. It gives teams clear ownership boundaries and smaller scope. An infrastructure team owns networking, and an application team owns compute and data. Each stack has its own state file, and neither team has to coordinate with the other for routine changes.

That separation also creates a visibility gap. When one team changes a stack and their change silently breaks an application in another stack, the team that sees the symptoms rarely owns the stack that the first team modified. They check their own AWS Lambda logs, AWS Identity and Access Management (IAM) policies, and database metrics. Everything looks fine, because their stack shows no issues.

Here’s the scenario this post walks through. An infrastructure team tightens a security group egress rule during a routine security audit. The infrastructure team’s change accidentally severs an application team’s Lambda from Amazon DynamoDB. The application team sees timeouts, and the alarms point everywhere except the real problem.

AWS DevOps Agent helps address that challenge. It correlates the alarms, maps the dependency chain across the state boundary, and inspects AWS CloudTrail to help narrow down the networking change as the likely root cause. You can then take its mitigation spec into Kiro to generate and apply the fix.

You can find all the Terraform and Lambda code referenced here in the accompanying GitHub repository. This is demonstration code that intentionally injects a failure and exposes an unauthenticated API for teaching purposes. It isn’t production-ready. See the repository’s SECURITY.md for the known gaps and hardening steps.

Solution architecture

The following diagram shows the full request path and how AWS DevOps Agent connects to it.

Cross-stack failure architecture: Client to Amazon API Gateway to Lambda to security group to DynamoDB VPC endpoint to DynamoDB, with Amazon CloudWatch alarms feeding Amazon Simple Notification Service (Amazon SNS) and a webhook bridge Lambda that forwards signed events to the AWS DevOps Agent Space, which diagnoses the root cause back in the networking stack.

Figure 1: Request path and monitoring flow that connects the sample application to AWS DevOps Agent

As the diagram shows, a client request enters through Amazon API Gateway, which invokes the Lambda function running inside the VPC. The function reaches Amazon DynamoDB over TCP/443 through a Gateway VPC endpoint, and a security group egress rule in the networking stack controls that path. On the monitoring side, Amazon CloudWatch alarms publish to an Amazon SNS topic. A webhook bridge Lambda function forwards each signed alarm to the AWS DevOps Agent Space, and the agent traces the failure back to the networking stack that owns the changed rule.

The architecture is a small serverless application split across three independent Terraform stacks, each with its own state file, plus an optional AWS DevOps Agent stack. The stack boundaries mirror real team ownership: an infrastructure team owns networking, while an application team owns compute and data.

stacks/
  networking/        # Stack 1, infrastructure team: VPC, private subnets,
                     #          Lambda security group, DynamoDB Gateway
                     #          VPC endpoint, VPC Flow Logs
  compute/           # Stack 2, app team: Lambda (in VPC) + HTTP API Gateway
  data-monitoring/   # Stack 3, app team: DynamoDB table, SNS topic,
                     #          CloudWatch alarms
devops-agent/        # DevOps Agent Space, IAM trust role, webhook bridge Lambda

When everything is healthy, a request flows like this:

Client → API Gateway → Lambda (in VPC) → Security Group → DynamoDB VPC endpoint → DynamoDB

The compute stack doesn’t hardcode anything from the other two. It reads the networking stack’s outputs through terraform_remote_state. Those outputs are the Amazon Virtual Private Cloud (Amazon VPC), the subnet IDs, and the security group ID. It reads the data stack’s outputs (table name and ARN) the same way.

That’s the dependency chain that matters. The application’s runtime health depends on a security group that lives in a stack it only reads from, never writes to.

The key detail is that Lambda reaches DynamoDB through a Gateway VPC endpoint, not over the public internet. There’s no NAT gateway. The only path from the Lambda function to DynamoDB is TCP/443 out to the DynamoDB service prefix list. A single security group egress rule in the networking stack gates that path. Break that rule, and every DynamoDB call from the Lambda function hangs until it times out.

The data-monitoring stack defines two CloudWatch alarms. One is the real signal, the other is a deliberate red herring:

  • Lambda error-rate alarm fires on the Errors metric. It’s the genuine symptom of trouble.
  • DynamoDB read-capacity-drop alarm fires when ConsumedReadCapacityUnits falls to near zero. While this appears to implicate DynamoDB, the table is healthy. Capacity drops only because the network layer blocks requests before they ever reach DynamoDB.

Both alarms publish to an Amazon SNS topic. The webhook bridge Lambda function in the AWS DevOps Agent stack subscribes to that topic, signs each alarm payload, and then forwards the payload to AWS DevOps Agent.

Prerequisites

Before you deploy this sample, make sure that you have the following in place:

  • An AWS account with access to the US East (N. Virginia) AWS Region (us-east-1), where you use AWS DevOps Agent. AWS DevOps Agent is available in 11 AWS Regions, and regardless of which supported Region you create your Agent Space in, it can monitor and investigate resources in AWS accounts located in any AWS Region. This walkthrough uses the US East (N. Virginia) Region (us-east-1) for the demo.
  • Permissions to create the resources used across the stacks: Amazon VPC networking (VPC, subnets, security groups, VPC endpoints, VPC Flow Logs), AWS Lambda, Amazon API Gateway, Amazon DynamoDB, Amazon SNS, Amazon CloudWatch alarms, AWS Systems Manager Parameter Store parameters, and the IAM roles and policies these resources require.
  • Permissions to provision AWS DevOps Agent resources through AWS Cloud Control API, including creating the Agent Space and account association and passing the agent’s IAM role.
  • Terraform 1.6.0 or later, with the hashicorp/aws (5.50.0 or later) and hashicorp/awscc (1.66.0 or later) providers. The AWS provider manages standard resources, and the AWS Cloud Control API provider manages the AWS DevOps Agent resources.
  • The AWS Command Line Interface (AWS CLI) configured with credentials for the target account.
  • An Amazon Simple Storage Service (Amazon S3) bucket for Terraform remote state and an Amazon DynamoDB table for state locking because the compute stack reads the other stacks’ state through terraform_remote_state.

Setting up AWS DevOps Agent with Terraform

AWS DevOps Agent resources aren’t in the standard hashicorp/aws provider. You provision the Agent Space and account association through the hashicorp/awscc provider (the AWS Cloud Control API provider). A few things trip people up here, so they’re worth calling out.

Use the US East (N. Virginia) Region

AWS DevOps Agent is available in 11 AWS Regions, and no matter which supported Region hosts your Agent Space, the agent can monitor and investigate resources in AWS accounts located in any AWS Region. This walkthrough creates the Agent Space in us-east-1 for the demo.

Get the IAM trust policy right

Two details matter. The service principal is aidevops.amazonaws.com (not devops-agent.amazonaws.com), and the aws:SourceArn condition must be the exact Agent Space ARN. A wildcard ARN causes a 400 error.

resource "awscc_devopsagent_agent_space" "this" {
  name = var.agent_space_name

  tags = [
    { key = "Project",   value = var.project },
    { key = "ManagedBy", value = "terraform" },
  ]
}

data "aws_iam_policy_document" "agent_assume_role" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["aidevops.amazonaws.com"]
    }

    condition {
      test     = "StringEquals"
      variable = "aws:SourceAccount"
      values   = [local.account_id]
    }

    condition {
      test     = "ArnEquals"
      variable = "aws:SourceArn"
      values   = [awscc_devopsagent_agent_space.this.arn]
    }
  }
}

Attach the AWS managed policy AIDevOpsAgentAccessPolicy to the role, plus a small inline policy allowing iam:CreateServiceLinkedRole for AWS Resource Explorer. The agent creates that service-linked role on its first run.

Then associate your account with the Agent Space in monitor mode:

resource "awscc_devopsagent_association" "account" {
  agent_space_id = awscc_devopsagent_agent_space.this.agent_space_id
  service_id     = "aws"

  configuration = {
    aws = {
      account_id         = local.account_id
      account_type       = "monitor"
      assumable_role_arn = aws_iam_role.devops_agent.arn
    }
  }
}

On the first apply, the association can fail with “Invalid AWS Security Token Service (AWS STS) role configuration.” That’s a race between the IAM trust policy propagating and the association forming. Re-run the same apply and it succeeds.

Bridge the alarms to the agent

CloudWatch alarms have no native action that targets AWS DevOps Agent, so you need a small bridge Lambda between SNS and the agent’s webhook. The bridge:

  1. Receives the CloudWatch alarm through SNS.
  2. Fetches the webhook URL and HMAC signing secret from AWS Systems Manager Parameter Store (stored as SecureString).
  3. Builds an event payload from the alarm data.
  4. Signs it with HMAC-SHA256 over timestamp:payload, Base64-encoded.
  5. POSTs it with the x-amzn-event-signature and x-amzn-event-timestamp headers.

The signing method is specific. The agent rejects the request with a 403 if the signature isn’t HMAC-SHA256 over timestamp:payload in exactly that form:

def _sign_payload(secret, timestamp, payload_str):
    message = f"{timestamp}:{payload_str}"
    sig = hmac.new(
        secret.encode("utf-8"),
        message.encode("utf-8"),
        hashlib.sha256,
    ).digest()
    return base64.b64encode(sig).decode("utf-8")

To generate the webhook, open your Agent Space on the console, go to Capabilities, choose Webhook, and then choose Generate. When prompted for the authentication method, choose HMAC so the agent validates each request against the HMAC-SHA256 signature the bridge Lambda produces. This returns a webhook URL and a signing secret. This walkthrough generates the webhook on the console because that is the only option today. Unlike the Agent Space, IAM roles, and associations, which this post provisions with Terraform, the generic webhook is not exposed as a Terraform resource. The generated URL and secret are only available from the Capabilities tab. This mirrors the official AWS DevOps Agent Terraform sample, which provisions everything in code but leaves console-only steps, such as connecting Datadog, as manual actions. Once you generate the webhook, store its URL and secret in the Parameter Store parameters that Terraform created with placeholder values. The bridge Lambda then picks them up from there.

Deploying the three interdependent Terraform stacks

Deploy order follows the dependency chain. Networking comes first (everything depends on the VPC and security group), then data-monitoring (the table has to exist before compute references it), then compute (it reads both other stacks’ remote state).

# 1. Networking: VPC, subnets, security group, DynamoDB VPC endpoint
terraform -chdir=stacks/networking apply -var-file=terraform.tfvars

# 2. Data and monitoring: table, SNS topic, alarms
terraform -chdir=stacks/data-monitoring apply

# 3. Compute: Lambda + API Gateway (reads the other two stacks' state)
terraform -chdir=stacks/compute apply -var "tf_state_bucket=${BUCKET}"

Terraform takes a minute or two to create the VPC-attached Lambda because of elastic network interface (ENI) setup. If an apply times out mid-run, re-run the same command, and Terraform picks up where it left off.

After compute finishes, retrieve the API URL and confirm the expected behavior before introducing the failure:

API_URL=$(terraform -chdir=stacks/compute output -raw api_endpoint)

curl -sS -X POST "$API_URL/items" \
  -H "Content-Type: application/json" \
  -d '{"id":"item-001","name":"Test Item","price":"29.99"}'

curl -sS "$API_URL/items/item-001"

A POST returns {"message": "Item created"} and the GET returns the item. The full path now works, from the API request through to the DynamoDB write and read: API Gateway invokes the Lambda function, the Lambda function writes and reads DynamoDB through the VPC endpoint, confirming both stacks work as expected.

Note: price is a string ("29.99"), not a number. DynamoDB through boto3 rejects Python floats.

Finally, deploy the AWS DevOps Agent stack and complete the manual webhook step described earlier. After the account association is active, the Agent Space page starts showing “Relationships mapped” climbing as the agent discovers your resources and builds topology. This discovery phase populates the agent space memory that AWS DevOps Agent relies on to understand your environment and correlate signals during an investigation. Wait until discovery has completed and the agent has finalized its agent space memory before you proceed. That way, the agent has the full context it needs for the incident investigations that follow. You can tell discovery is finished from the Agent Space page on the console. The “Relationships mapped” count stops climbing and holds steady over a few minutes. The account association shows an Active status rather than Syncing or In progress. The resource and topology view reflects the resources you deployed (the VPC, Lambda functions, DynamoDB table, and their relationships). For a brand-new account of this size, the initial build typically settles within roughly 10 to 15 minutes.

Introducing the cross-stack failure

Here’s the change that starts the failure. The whole failure hinges on one security group egress rule in the networking stack, toggled by a single variable:

resource "aws_security_group_rule" "lambda_egress" {
  type              = "egress"
  from_port         = 443
  to_port           = 443
  protocol          = "tcp"
  security_group_id = aws_security_group.lambda.id

  # restrict_egress = false → allow TCP/443 to the DynamoDB VPC endpoint prefix list (working)
  # restrict_egress = true  → restrict TCP/443 to the VPC CIDR only (broken)
  prefix_list_ids = var.restrict_egress ? null : [aws_vpc_endpoint.dynamodb.prefix_list_id]
  cidr_blocks     = var.restrict_egress ? [var.vpc_cidr] : null

  description = var.restrict_egress ? "Restricted egress - security audit compliance" : "Allow Lambda to reach DynamoDB via VPC endpoint"
}

Flipping restrict_egress to true is the kind of change that looks entirely reasonable in a pull request. “Scope Lambda egress to the VPC CIDR instead of a broad prefix list” reads like tightening, not breaking. The description even says “security audit compliance.” A reviewer on the infrastructure team would likely approve it without a second thought. From inside the networking stack, there’s no visible connection to the application’s DynamoDB traffic.

Inject it with the provided script:

./scripts/introduce-failure.sh   # re-applies networking with restrict_egress=true

The moment you change the rule, the path from the Lambda function to the DynamoDB VPC endpoint disappears. The Lambda function is still perfectly invocable, because API Gateway ingress is untouched. Requests come in and the handler runs, but every DynamoDB call then sits there until the 30-second Lambda timeout. Drive a little traffic and confirm:

curl -sS --max-time 35 "$API_URL/items/item-001"
# => {"error": "Connect timeout on endpoint URL: https://dynamodb.us-east-1.amazonaws.com/"}

Within a minute or two, the alarms fire. And this is where a human investigation goes sideways:

  • The Lambda logs show Task timed out after 30.00 seconds. No stack trace, no AccessDenied, nothing that points at networking.
  • The DynamoDB console shows a perfectly healthy table. No throttling, no errors, capacity near zero.
  • IAM is correct. The Lambda role has the right DynamoDB permissions. This isn’t a permissions problem.
  • The DynamoDB capacity alarm actively misleads you toward the database.

An engineer troubleshooting this naturally checks Lambda configuration, then IAM, then DynamoDB. Only after exhausting all of those might they think to look at VPC networking. That networking lives in a stack another team owns, one they didn’t know had changed. That’s the visibility gap the state boundary creates.

AWS DevOps Agent investigation

Both alarms publish to SNS, the bridge Lambda signs and forwards them, and AWS DevOps Agent opens an investigation triggered by the event channel. From there it works the problem in parallel rather than the linear path a human would take.

The investigation fans out into subagents. They look at Lambda metrics, DynamoDB metrics, Lambda logs, CloudTrail, and VPC networking simultaneously. That parallelism is what collapses sequential log-diving into a much shorter effort. While one line of inquiry reads the Lambda timeout logs, another is already correlating the two alarms as a single event. A third is pulling recent CloudTrail events for the resources in the topology.

What the agent establishes:

  1. A single correlated event – The two alarms started within the same window and are related, not independent failures.
  2. The dependency chain – API Gateway to Lambda to VPC to security group to DynamoDB VPC endpoint to DynamoDB. Critically, it walks this chain across the Terraform state boundary. It sees AWS resources and their relationships, not stack ownership. The boundary that hides the cause from the app team is invisible to the agent, which works in its favor.
  3. A network timeout, not a permissions or database fault – The Lambda logs show connection timeouts to the DynamoDB endpoint. The DynamoDB metrics confirm the table is idle rather than erroring.
  4. The trigger – CloudTrail shows a recent egress change on the Lambda security group, with the principal and timestamp. Correlating that timestamp against the onset of Lambda timeouts (roughly a minute later) establishes causation.
  5. A red herring that is really a symptom – The DynamoDB capacity drop reflects a downstream effect (requests never arrive), not a DynamoDB fault.

It lands on a root cause along these lines:

A security group egress modification blocked the network path from Lambda to DynamoDB. The change replaced the egress rule that allowed TCP/443 to the DynamoDB VPC endpoint prefix list with a rule scoped to the VPC CIDR only. As a result, Lambda functions in the private subnets can no longer establish connections to DynamoDB through the Gateway VPC endpoint. Both alarms are symptoms of this one network-layer change, and the DynamoDB table itself is healthy.

With VPC Flow Logs active in the networking stack, the agent gains additional network-layer evidence to confirm that the network drops the traffic rather than letting it reach DynamoDB and fail there.

Generating the mitigation specification with Kiro

AWS DevOps Agent doesn’t stop at diagnosis. Alongside the root cause, it produces a mitigation plan, which is structured, agent-ready guidance describing what needs to change. In the web app, the Mitigation plan tab has a Copy spec button that hands you a spec you can drop straight into Kiro.

The spec captures the intent without prescribing exact syntax:

Mitigation: Restore the security group egress rule that allows Lambda to reach DynamoDB through the VPC endpoint.

  • Security group: the Lambda security group in the networking stack.
  • Rule: egress, TCP, port 443.
  • Destination: the DynamoDB VPC endpoint prefix list.
  • Effect: restore the Lambda to DynamoDB network path that the recent change removed.

This is the handoff point between detection and remediation. The agent understood what is wrong and what the fix needs to accomplish. Kiro turns that into the actual Terraform change in your repository.

Implementing the remediation with Kiro

Open the spec in Kiro. Because the fix is a matter of intent expressed against real code, Kiro reads the networking stack, locates the toggled egress rule, and produces the corrected Terraform. In this repo the clean fix is to flip restrict_egress back to false, which restores the prefix-list rule:

- prefix_list_ids = var.restrict_egress ? null : [aws_vpc_endpoint.dynamodb.prefix_list_id]
- cidr_blocks     = var.restrict_egress ? [var.vpc_cidr] : null
+ # restrict_egress = false: Lambda egress to the DynamoDB VPC endpoint prefix list restored
+ prefix_list_ids = [aws_vpc_endpoint.dynamodb.prefix_list_id]
+ cidr_blocks     = null

For a repo that defines the restrictive rule directly (not behind a toggle), Kiro generates the equivalent change. It swaps the cidr_blocks destination back to the DynamoDB prefix_list_ids:

  resource "aws_security_group_rule" "lambda_egress" {
    type              = "egress"
    from_port         = 443
    to_port           = 443
    protocol          = "tcp"
    security_group_id = aws_security_group.lambda.id
-   cidr_blocks       = [var.vpc_cidr]
-   description       = "Restricted egress - security audit compliance"
+   prefix_list_ids   = [aws_vpc_endpoint.dynamodb.prefix_list_id]
+   description       = "Allow Lambda to reach DynamoDB via VPC endpoint"
  }

The engineer stays in control. You review the diff, run terraform plan to confirm it only touches the one egress rule, and then apply:

terraform -chdir=stacks/networking plan   # confirm the change is scoped to the egress rule
terraform -chdir=stacks/networking apply

The loop is now closed. AWS DevOps Agent detected and diagnosed, Kiro generated the fix, and a human approved it before anything changed. While iterating on the demo, you can shortcut this with ./scripts/restore.sh, which re-applies with restrict_egress=false.

Validating recovery

As soon as you restore the egress rule, the path from the Lambda function to the DynamoDB VPC endpoint comes back. Confirm with the same request that was timing out:

curl -sS "$API_URL/items/item-001"
# => {"id": "item-001", "name": "Test Item", "price": "29.99"}

The request returns immediately instead of hanging for 30 seconds. On the monitoring side, once real traffic reaches DynamoDB again, consumed read capacity climbs back above the threshold and the DynamoDB alarm returns to OK. The Lambda error-rate alarm clears as timeouts stop. The bridge Lambda forwards the OK-state transitions to AWS DevOps Agent, which reflects the issue resolving.

Assessment and key takeaways

Being honest about what the agent does well and where it has limits matters more than a clean demo.

What it got right:

  • The agent identified networking as the root cause instead of chasing DynamoDB or IAM, which is exactly where the human investigation stalls.
  • It traced the VPC endpoint connectivity path accurately and correlated the two alarms into a single event.
  • The DynamoDB red herring didn’t fool it, because it explained the capacity drop as a downstream symptom.
  • CloudTrail analysis surfaced the security group change, and the agent tied its timestamp to the onset of failures.
  • Most importantly, it turned a cross-team investigation that spans multiple stacks into a single, correlated result.

Where to keep expectations realistic:

  • The agent reasons about AWS resources and their relationships, not Terraform stacks. That’s a strength here, because it sees past the state boundary. It does mean the agent won’t tell you “this was in the networking stack.” Instead, it names the security group and the change, and you map that back to the stack.
  • When a security group change is made by running terraform apply, CloudTrail records the IAM role that ran the apply, not the individual engineer. Attribution stops at the automation identity unless you have additional context.
  • The quality of the network-layer diagnosis depends on signals being available. With VPC Flow Logs active in this demo, the agent gains concrete evidence. Without them, it has less to work with and may flag the gap.

The broader takeaway is that Terraform state separation is the right call for team ownership, but it manufactures operational visibility gaps. The team that owns the symptoms often doesn’t own the cause and can’t see the change that triggered it. AWS DevOps Agent helps address that gap by understanding resource topology regardless of how you provisioned the resources or which state file describes them.

Clean up

To avoid ongoing charges, destroy the resources when you’re done. Because the compute stack depends on the networking and data-monitoring stacks, tear the stacks down in the reverse of the deploy order. Destroy AWS DevOps Agent first, then compute, then data-monitoring, and networking last.

# 1. AWS DevOps Agent stack (Agent Space, trust role, webhook bridge Lambda)
ALARMS_SNS_ARN=$(terraform -chdir=stacks/data-monitoring output -raw alarms_sns_topic_arn)
terraform -chdir=devops-agent destroy -var "alarms_sns_topic_arn=${ALARMS_SNS_ARN}"

# 2. Compute stack (Lambda + API Gateway)
terraform -chdir=stacks/compute destroy -var "tf_state_bucket=${TF_STATE_BUCKET}"

# 3. Data and monitoring stack (DynamoDB table, SNS topic, alarms)
terraform -chdir=stacks/data-monitoring destroy

# 4. Networking stack (VPC, subnets, security group, DynamoDB VPC endpoint)
terraform -chdir=stacks/networking destroy -var-file=terraform.tfvars

Deleting the VPC-attached Lambda function can take a minute or two while AWS releases its elastic network interfaces. Allow the networking stack destroy to finish before you remove the remote state backend. If you created a dedicated Amazon S3 bucket and Amazon DynamoDB lock table for Terraform state, delete those last, after you destroy all four stacks.

Conclusion

Independent Terraform stacks give you clean ownership boundaries and small scope, and you should keep using them. The tradeoff is that failures don’t respect those boundaries. A routine, reasonable-looking security group change in the infrastructure team’s stack can time out an application team’s Lambda, and the symptoms will point everywhere except the actual cause.

AWS DevOps Agent narrows that gap. It correlates alarms, maps the dependency chain across state boundaries, and reads CloudTrail to find the triggering change. Pairing it with Kiro closes the full loop from detect to remediate. The agent produces a mitigation spec, Kiro turns it into a reviewed Terraform diff, and an engineer applies the fix with confidence. The human still makes the call, and the cross-team log-diving is no longer the bottleneck.

Next steps

To try this end to end, deploy the sample from the accompanying GitHub repository, inject the failure, and watch the agent trace the cause across the state boundary. To learn more, see the following resources:

 


About the author

Shruti Saxena

Shruti Saxena

Shruti is an Associate Delivery Consultant at AWS Professional Services, specializing in DevOps, infrastructure as code, and automated development workflows. She works on cloud modernization and Amazon Elastic Kubernetes Service (Amazon EKS) environments for regulated environments, helping customers build compliant, scalable infrastructure using Terraform and automated development tools. She is passionate about pushing the boundaries of what automated tools can do for software delivery and about helping others do the same.

Enable cross-Region fine-grained data access with AWS Lake Formation and Trusted Identity Propagation

Post Syndicated from Maneesh Sharma original https://aws.amazon.com/blogs/big-data/enable-cross-region-fine-grained-data-access-with-aws-lake-formation-and-trusted-identity-propagation/

Organizations that store analytics data on AWS often need two things at once. The first is fine-grained access control that limits each person to only the data they are authorized to see. The second is auditability down to the individual user level so that every query is traceable to the person who ran it rather than to a shared AWS Identity and Access Management (IAM) role.

AWS IAM Identity Center Trusted Identity Propagation (TIP) meets both requirements. A user signs in once, and that identity propagates from the client application through the query engine to Lake Formation and the storage layer, so that every request is authorized as the actual user and every query is logged in AWS CloudTrail.

AWS now extends TIP across Regions: IAM Identity Center replicates its identity objects such as users, groups, and session information to every AWS Region that you choose, so services across the stack can recognize the same user identity even when they span different Regions.

This is Part 1 of a three-part series. This post covers the foundation and querying from Amazon SageMaker Unified Studio (Amazon Athena and Amazon Redshift). Part 2 covers Amazon Quick, and Part 3 covers Amazon EMR.

Overview

In this post, the producer shares data from the Asia Pacific (Sydney) Region (ap-southeast-2) and the consumer queries it from the South America (São Paulo) Region (sa-east-1) as themselves. We cover two cases that follow the same pattern. In Case 1, you share Amazon Simple Storage Service (Amazon S3) data governed by Lake Formation, queried from Amazon SageMaker Unified Studio (Athena). In Case 2, you share Amazon Redshift data through a datashare registered to the AWS Glue Data Catalog, queried from Amazon Redshift Serverless.

Cross-Region, cross-account architecture: a Sydney producer governs data with Lake Formation and a São Paulo consumer queries it

Figure 1: Cross-Region and cross-account architecture

Starting on the left of the diagram, your identity provider syncs users and groups into AWS IAM Identity Center through the System for Cross-domain Identity Management (SCIM) protocol. Running as an organization instance in the management account, IAM Identity Center replicates those identities to every Region in use. In the center, the producer account in Sydney uses AWS Lake Formation to centrally govern both Amazon S3 and Amazon Redshift data. When the producer shares a table or database, that share flows to the consumer account in São Paulo, shown on the right, where a resource link makes the shared data queryable as if it were local. On the right, the data analyst signs in to Amazon SageMaker Unified Studio with corporate credentials. With Trusted Identity Propagation enabled, the analyst’s identity travels with every request to Lake Formation. Lake Formation authorizes each query based on who the user is rather than which IAM role they assumed.

The example uses three accounts across three Regions. The producer account runs Amazon S3, AWS Glue Data Catalog, Lake Formation, and Amazon Redshift Serverless in ap-southeast-2 (Sydney). The consumer account queries from sa-east-1 (São Paulo) using SageMaker Unified Studio or Amazon Redshift. IAM Identity Center runs in us-east-1 (Virginia) in the management account.

Cross-account sharing through Lake Formation can span AWS Organizations, but the TIP integration adds a tighter requirement. For the user’s identity to propagate, the producer and consumer accounts must be in the same organization and share the same organization instance of IAM Identity Center. You can repeat the consumer steps to add more accounts or Regions. With this launch, you can query that shared data from a different Region while Lake Formation authorizes every request as the actual user.

How the pieces fit together

Four capabilities underpin this architecture. IAM Identity Center provides a single identity plane across Regions, so the same users and groups are available in every account. Each service in the stack supports Trusted Identity Propagation, so the user’s identity flows from the client application to Lake Formation without falling back to IAM roles. Corporate sign-in through IAM Identity Center makes that identity available across Regions and accounts within the organization. Finally, AWS Resource Access Manager (AWS RAM) shares and resource links bridge the Region gap so the consumer can query data cataloged in the producer’s Region.

Prerequisites

Before you begin, make sure that you have the following in place:

  • The producer account and consumer account are in the same organization.
  • An organization instance of IAM Identity Center, with users and groups provisioned.
  • IAM Identity Center replicated to every Region where you operate. See Deploy AWS applications and access AWS accounts across multiple Regions with IAM Identity Center.
  • You have permissions to administer AWS Lake Formation, AWS Glue, AWS RAM, Amazon Simple Storage Service (Amazon S3), Amazon SageMaker Unified Studio, and Amazon Redshift in the relevant accounts.
  • In the producer Lake Formation Data catalog settings, the Use only IAM access control options should be cleared.
  • The AWS services used in this post are integrated with AWS IAM Identity Center in both the producer and consumer accounts, across the Regions used in this walkthrough. See Trusted identity propagation use cases for more information.

Case 1: Share S3 data (Lake Formation)

In this case, the S3 data and its catalog stay in the producer’s account and Region the whole time, with Lake Formation governing them in Sydney (ap-southeast-2). What crosses Regions is the query itself. The consumer, in a different account, connects from São Paulo (sa-east-1) and queries that same data through Amazon Athena (through SageMaker Unified Studio). This is a federated query pattern: the data stays in place, and only the query crosses Regions. That split (catalog and data in one Region, client and query in another) is what makes this genuinely cross-Region, not just cross-account.

Part 1: Producer account (Sydney, ap-southeast-2)

Region note: the walkthrough uses ap-southeast-2 (producer) and sa-east-1 (consumer and query). Substitute your own Regions and account IDs where you see placeholders. All cross-account Lake Formation grants must be made in the producer Region. The only exception is the resource link and its Describe grant, which are created in the consumer’s query Region.

Step 1.1: Create the sample data file

Create a file named customers.csv with the following content.

customer_id,first_name,last_name,email,city,state,signup_date
1,Martha,Rivera,[email protected],Seattle,WA,2024-01-15
2,Jane,Doe,[email protected],Portland,OR,2024-02-20
3,Mateo,Jackson,[email protected],Denver,CO,2024-03-10
4,Saanvi,Sarkar,[email protected],Austin,TX,2024-04-05
5,Mary,Major,[email protected],Chicago,IL,2024-05-12

Step 1.2: Create an S3 bucket and upload the data

  1. Open the Amazon S3 console in the producer account (ap-southeast-2).
  2. Choose Create bucket, enter a unique name (for example, amzn-s3-demo-sydney-lakehouse-bucket), and choose Create bucket.
  3. Open the bucket, create a folder named customers, and upload customers.csv into it.

Step 1.3: Create the Lake Formation registration role (with sts:SetContext)

Create an IAM role that Lake Formation assumes to read the S3 bucket. This role is used when registering the data lake location and for vending credentials, including for TIP. Don’t use the Lake Formation service-linked role. It doesn’t include sts:SetContext.

  1. Open the IAM console, choose Roles, then Create role.
  2. For Trusted entity type, choose Custom trust policy and paste the following trust policy.
  3. Choose Next, skip attaching managed policies, and choose Next.
  4. Name the role (for example, LakeFormation-DataLocation-role) and choose Create role.
  5. Open the role, choose Add permissions, then Create inline policy, and paste the following permissions policy.
  6. Name the inline policy (for example, LF-S3Access) and save it.

Trust policy:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": { "Service": "lakeformation.amazonaws.com" },
      "Action": [
        "sts:AssumeRole",
        "sts:SetContext"
      ]
    }
  ]
}

Permissions policy (replace the bucket name):

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:ListBucket",
        "s3:GetBucketLocation"
      ],
      "Resource": [
        "arn:aws:s3:::<your-s3-bucket>",
        "arn:aws:s3:::<your-s3-bucket>/*"
      ]
    }
  ]
}

Note: The sts:SetContext action in the trust policy is required for TIP. The Lake Formation service-linked role doesn’t include it, which is why a custom role is required here.

Step 1.4: Register the S3 location in Lake Formation

  1. Open the Lake Formation console in ap-southeast-2.
  2. Under Administration, choose Data lake locations, then Register location.
  3. For Amazon S3 path, enter your bucket path (for example, s3://amzn-s3-demo-sydney-lakehouse-bucket).
  4. For IAM role, choose the LakeFormation-DataLocation-role created in Step 1.3.
  5. For Permission mode, choose Lake Formation, then choose Register location.

Step 1.5: Create the AWS Glue database

  1. In the Lake Formation console, under Data catalog, choose Databases.
  2. Choose Create database.
  3. Enter the database name, for this post sydney_lakehouse, and choose Create database.

Step 1.6: Create the table manually

  1. Open the AWS Glue console in ap-southeast-2.
  2. Under Data Catalog, choose Tables, Add table, then Add table manually.
  3. For Table name, enter customers.
  4. For Database, choose sydney_lakehouse.
  5. For the data store, choose Amazon S3 and set the location to s3://amzn-s3-demo-sydney-lakehouse-bucket/customers/.
  6. For the data format, choose CSV with comma (,) as the delimiter.
  7. Define the schema using the following columns.
  8. Add the table property skip.header.line.count with value 1 to skip the CSV header row.
  9. Review and choose Create.
Column name Data type
customer_id string
first_name string
last_name string
email string
city string
state string
signup_date string

When you create the table as CSV on the console, define all columns as string. The CSV serializer/deserializer used for this table doesn’t parse typed columns such as date or bigint reliably. Cast values in your queries as needed (for example, CAST(signup_date AS date)).

Step 1.7: Grant DATABASE permission to the external (consumer) account

  1. In the Lake Formation console, choose Permissions, Data lake permissions, then Grant.
  2. Under Principals, choose External accounts and enter the consumer account ID (<CONSUMER_ACCOUNT_ID>).
  3. Choose Named Data Catalog resources. For Databases, choose your database, in this post sydney_lakehouse.
  4. For Database permissions, select Describe, and select Describe under Grantable permissions.
  5. Choose Grant.

Step 1.8: Grant TABLE permission to the external (consumer) account

  1. Choose Permissions, Data lake permissions, then Grant.
  2. Under Principals, choose External accounts and enter <CONSUMER_ACCOUNT_ID>.
  3. Choose Named Data Catalog resources. For Databases choose sydney_lakehouse. For Tables choose customers.
  4. For Table permissions, select Select and Describe, and select the same under Grantable permissions.
  5. Choose Grant.

Step 1.9: Grant DATABASE permission to the IAM Identity Center group/user

  1. Choose Permissions, Data lake permissions, then Grant.
  2. Under Principals, choose IAM Identity Center and select the group or user (for example, admin).
  3. Choose Named Data Catalog resources. For Databases choose your database, in this post sydney_lakehouse.
  4. For Database permissions, select Describe.
  5. Choose Grant.

Step 1.10: Grant TABLE permission to the IAM Identity Center group/user

  1. Choose Permissions, Data lake permissions, then Grant.
  2. Under Principals, choose IAM Identity Center and select the group or user (for example, admin).
  3. Choose Named Data Catalog resources. For Databases choose your database and table, in this post sydney_lakehouse and for Tables choose customers.
  4. For Table permissions, select Select and Describe.
  5. Choose Grant.

Note: Because the accounts share one organization instance of IAM Identity Center, the producer can grant the IAM Identity Center group directly on the underlying table. The consumer only grants Describe on the local resource link (Step 2.3).

At this point, the producer side is complete. You have the necessary Lake Formation permissions for both the consumer account and your users. To verify, open Data lake permissions in the Lake Formation console and confirm you see four grants covering the database and table.

Part 2: Consumer account

In this part, you switch to the consumer account to accept the AWS RAM share, create a resource link that points back to the shared database, and grant your users access to it. After these steps, the consumer account is ready to query the producer’s data from a different Region.

Step 2.1: Accept the AWS RAM share (Sydney Region)

  1. Open the AWS RAM console and switch to ap-southeast-2 (Sydney).
  2. Under Shared with me, choose Resource shares.
  3. Select the resource share from the producer account and choose Accept resource share.
  1. Open the Lake Formation console and switch to sa-east-1.
  2. Under Data catalog, choose Databases, then Create resource link.
  3. For Resource link name, enter a name, for example rl_sydney_s3_datalake.
  4. For Shared database’s Region, choose Asia Pacific (Sydney) ap-southeast-2.
  5. For Shared database, choose your database. In this post the database is called sydney_lakehouse.
  6. Shared database’s catalog ID populates automatically (the producer account).
  7. Choose Create.
  1. In the Lake Formation console (sa-east-1), choose Permissions, Data lake permissions, then Grant.
  2. Under Principals, choose IAM Identity Center and select the group or user (for example, admin).
  3. Choose Named Data Catalog resources. For Databases choose rl_sydney_s3_datalake.
  4. For Resource link permissions, select Describe.
  5. Choose Grant.

Part 3: SageMaker Unified Studio (sa-east-1)

With the producer and consumer accounts configured, you now set up SageMaker Unified Studio in the query Region and run the cross-Region query.

Step 3.1: Create the SageMaker Unified Studio domain

Open the Amazon SageMaker console in sa-east-1, choose Unified Studio, then Create domain. See the SageMaker Unified Studio administration guide for the quick setup. Because your IAM Identity Center integration is already complete, you can specify an IAM Identity Center user in the domain configuration settings.

SageMaker Unified Studio domain configuration with an IAM Identity Center user added

Figure 2: Adding an SSO user in the Amazon SageMaker Unified Studio domain

To enable Trusted Identity Propagation (TIP) in SageMaker Unified Studio, refer to the following sections in this post:

  1. Enable TIP in SageMaker Unified Studio.
  2. Enable user access for SageMaker Unified Studio domain.
  3. Add project members.
  4. Create a SQL analytics project in Unified Studio. For this post, we use the project name my_project_idc.

Step 3.2: Enable TIP

  1. Sign in to the SageMaker Unified Studio portal by choosing Sign in with SSO as an IAM Identity Center user.
IAM Identity Center sign-in page for the SageMaker Unified Studio portal

Figure 3: Sign in with SSO in Amazon SageMaker Unified Studio

  1. Open the project (my_project_idc) and go to the Query Editor.
  2. Under Catalogs, choose AwsDataCatalog.
  3. Choose rl_sydney_s3_datalake, then tables, and then select customers.
  4. Choose the three dots next to the customers table and choose Preview data.
Query Editor previewing the customers table through the rl_sydney_s3_datalake resource link

Figure 4: Access Amazon S3 data in Amazon SageMaker Unified Studio

The query auto-runs using the Athena compute, as shown in the following screenshot:

SELECT * FROM "AwsDataCatalog"."rl_sydney_s3_datalake"."customers" LIMIT 10;
Query results returning customer rows from the Sydney S3 bucket through the resource link

Figure 5: Preview Amazon S3 data in Amazon SageMaker Unified Studio

The customer data from the producer S3 bucket in Sydney is returned, governed by Lake Formation and accessed through the resource link in sa-east-1, with the user identity propagated through TIP.

Case 2: Share Amazon Redshift data using AWS Glue federated catalog

This case follows the same centralized-catalog pattern as Case 1, but the consumer queries the data with Amazon Redshift Serverless instead of Athena. The analyst runs these queries from SageMaker Unified Studio using its Amazon Redshift query experience.

Part 1: Producer account (Sydney, ap-southeast-2)

Make sure that your Amazon Redshift consumer cluster is configured with IAM Identity Center in both ap-southeast-2 and sa-east-1 Regions. If you need to review the procedure, see Integrate Identity Provider (IdP) with Amazon Redshift Query Editor V2 using AWS IAM Identity Center for seamless Single Sign-On.

Step 1.1: Register a serverless namespace to the AWS Glue Data Catalog

  1. Open the Amazon Redshift console.
  2. On the navigation menu, choose Redshift Serverless. The Namespaces / Workgroups section shows your namespaces and workgroups for the current Region.
  3. Choose the name of the namespace you want to register.
  4. From Actions, choose Register to AWS Glue Data Catalog.
Amazon Redshift Serverless namespace selected to register with the AWS Glue Data Catalog

Figure 6: Amazon Redshift Serverless namespace to register with the AWS Glue Data Catalog

  1. Choose Do not register with Amazon Redshift federated permissions.
  2. Select Use AWS Lake Formation permissions.
  3. For Destination account ID, enter the producer account ID (<PRODUCER_ACCOUNT_ID>), the account that will hold the catalog.
  4. Choose Register. You are taken to the AWS Lake Formation console.
Registering the Amazon Redshift Serverless namespace with the AWS Glue Data Catalog

Figure 7: Register the Amazon Redshift Serverless namespace with the AWS Glue Data Catalog

  1. Follow the catalog creation process in Lake Formation. See Creating Amazon Redshift federated catalogs and follow these steps:
    1. Review a namespace invitation and accept it.
    2. Create a federated catalog.

Step 1.2: Grant DATABASE permission to the external (consumer) account

  1. In the Lake Formation console (ap-southeast-2), choose Permissions, Data lake permissions, then Grant.
  2. Under Principals, choose External accounts and enter <CONSUMER_ACCOUNT_ID>.
  3. Choose Named Data Catalog resources. For Catalogs/Databases, choose the federated database (for example, ns-redshift/dev, schema sales_schema).
  4. For Database permissions, select Describe (and Describe under Grantable permissions).
  5. Choose Grant.

Step 1.3: Grant TABLE permission to the external (consumer) account

  1. Choose Permissions, Data lake permissions, then Grant.
  2. Under Principals, choose External accounts and enter <CONSUMER_ACCOUNT_ID>.
  3. Choose Named Data Catalog resources and select the shared table (for example, store_sales).
  4. For Table permissions, select Select and Describe (and the same under Grantable permissions).
  5. Choose Grant.

Step 1.4: Grant DATABASE permission to the IAM Identity Center group/user

  1. Choose Permissions, Data lake permissions, then Grant.
  2. Under Principals, choose IAM Identity Center and select the group or user.
  3. Choose Named Data Catalog resources and select the federated database (schema).
  4. For Database permissions, select Describe.
  5. Choose Grant.

Step 1.5: Grant TABLE permission to the IAM Identity Center group/user

  1. Choose Permissions, Data lake permissions, then Grant.
  2. Under Principals, choose IAM Identity Center and select the group or user.
  3. Choose Named Data Catalog resources and select the shared table (for example, store_sales).
  4. For Table permissions, select Select and Describe.
  5. Choose Grant.

Part 2: Consumer account

At this point, the producer side for Case 2 is complete. To verify, open Data lake permissions in the Lake Formation console and confirm you see four grants covering the federated database and shared table.

In this part, you switch to the consumer account to accept the AWS RAM share, configure the IAM role for cross-Region Amazon Redshift access, and set up TIP so that users can query the producer’s Amazon Redshift data from a different Region.

Step 2.1: Accept the AWS RAM share (Sydney Region)

  1. Open the AWS RAM console and switch to ap-southeast-2.
  2. Under Shared with me, choose Resource shares.
  3. Select the resource share from the producer account and choose Accept resource share.

Step 2.2: Update the trust relationship for the IAM role

Navigate to the IAM role (IAMIDCRedshiftRole) created in this post.

Make sure the trust policy includes Amazon Redshift and Amazon Redshift Serverless for sts:AssumeRole and sts:SetContext, as shown in the following policy:

Trust policy:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": { "Service": "redshift.amazonaws.com" },
      "Action": ["sts:AssumeRole", "sts:SetContext"]
    },
    {
      "Effect": "Allow",
      "Principal": { "Service": "redshift-serverless.amazonaws.com" },
      "Action": ["sts:AssumeRole", "sts:SetContext"]
    }
  ]
}

Step 2.3: Add a new policy to the IAM role

Navigate to the IAM role (IAMIDCRedshiftRole) created in this post and add the following policy for AWS Glue and Lake Formation.

Permissions policy 1 (AWS Glue read and Lake Formation data access):

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "GlueGetTable",
      "Effect": "Allow",
      "Action": [
        "glue:GetTable",
        "glue:GetTables",
        "glue:GetDatabase",
        "glue:GetDatabases",
        "glue:GetPartition",
        "glue:GetPartitions"
      ],
      "Resource": "*"
    },
    {
      "Sid": "LakeFormationDataAccess",
      "Effect": "Allow",
      "Action": [
        "lakeformation:GetDataAccess",
        "lakeformation:GetTemporaryGlueTableCredentials"
      ],
      "Resource": "*"
    }
  ]
}

Note: These actions require "Resource": "*" because they are used for catalog-level discovery, which does not support resource-scoped permissions. Lake Formation column-level and row-level grants still enforce least-privilege access to the underlying data.

Step 2.4: Set up cross-Region Trusted Identity Propagation (TIP) access in Amazon Redshift

  1. Sign in to the Amazon Redshift console using the admin role.
  2. Navigate to Query Editor V2.
  3. Choose the options menu (three dots) next to the cluster and choose Create connection.
  4. Connect as the admin user and run the following command to create an external schema that points to your cross-Region AWS Glue Data Catalog:

Syntax:

CREATE EXTERNAL SCHEMA <schema_name>
FROM DATA CATALOG
DATABASE '<glue_db_name>'
REGION '<producer region where glue Data Catalog lives>'
CATALOG_ID '<catalog_name>';

Example:

CREATE EXTERNAL SCHEMA redshift_cross_region_tip
FROM DATA CATALOG
DATABASE 'sales_schema'
REGION 'ap-southeast-2'
CATALOG_ID '123456789012:ns-producer-redshift-syd/dev';

You can get this catalog_name information from the producer account Lake Formation in the Sydney Region. Navigate to Databases on the left and choose the respective Amazon Redshift catalog ID from the drop-down.

Lake Formation Databases list showing the Amazon Redshift federated catalog ID

Figure 8: AWS Lake Formation showing the catalog ID of the Amazon Redshift namespace registered with the AWS Glue Data Catalog

  1. Grant the IAM Identity Center group access to the external schema.
GRANT USAGE ON schema redshift_cross_region_tip TO ROLE "AWSIDC:admin";

Step 2.5: Query the data from Amazon Redshift Query Editor V2

  1. In Query Editor V2, choose the options menu (three dots) next to the cluster and choose Edit connection, and for authentication, select IAM Identity Center.
  2. A pop-up redirects you to your IdP login page. Enter your IdP credentials.
  3. After successful authentication, you are redirected to Query Editor V2 as an IAM Identity Center user.
  4. Run the following query.
SELECT * FROM "dev"."redshift_cross_region_tip"."store_sales";
Amazon Redshift Query Editor V2 returning cross-Region store_sales rows

Figure 9: Query cross-Region, cross-account data using Amazon Redshift Query Editor V2

Step 2.6: Query the data from SageMaker Unified Studio

For a consumer cluster to use in SageMaker Unified Studio, you need to tag the Amazon Redshift cluster or workgroup with one of the following options. Choose one before proceeding.

Option 1 – Add a tag to allow only a specific SageMaker Unified Studio project to access it: AmazonDataZoneProject=<projectID>

Option 2 – Add a tag to allow all SageMaker Unified Studio projects in this account to access it: for-use-with-all-datazone-projects=true

After the Amazon Redshift cluster or workgroup has a tag, continue with the following steps.

  1. Sign in to the SageMaker Unified Studio portal by choosing Sign in with SSO as an IAM Identity Center user.
  2. In SageMaker Unified Studio, navigate to Query Editor and expand Connections.
  3. Choose redshift_cross_region_tip, then tables, and then select store_sales.
  4. Choose the three dots next to the store_sales table and choose Preview data. The query auto-runs using the Amazon Redshift compute, as shown in the following screenshot:
SageMaker Unified Studio previewing store_sales using Amazon Redshift compute

Figure 10: Query data using Amazon Redshift compute in Amazon SageMaker Unified Studio

The Amazon Redshift data shared from the producer in Sydney is returned. It is governed and centralized in one account and Region (ap-southeast-2) using Lake Formation and accessed from the consumer account in a second Region (sa-east-1), with the user identity propagated through TIP.

Both cases use the same Region split. The catalog and the data stay in the producer Region (Sydney), and only the client and the query run from the consumer Region (São Paulo). They differ only in the query engine. Case 1 uses Amazon Athena, and Case 2 uses Amazon Redshift Serverless. That difference in engine is also what changes their cross-Region behavior. With Athena (Case 1), queries work cross-Region without additional constraints. However, if you use an Amazon Redshift datashare, the Amazon Redshift query engine must be in the same Region as the Amazon Redshift namespace.

Clean up

To avoid ongoing charges, remove the resources that you created.

Consumer account:

  1. Delete the SageMaker Unified Studio project and domain (Case 1).
  2. Delete the Amazon Redshift Serverless workgroup and namespace (Case 2).
  3. In Lake Formation (sa-east-1), delete the resource links (rl_sydney_s3_datalake, rl_redshift_cross_db_tip).
  4. Revoke any Lake Formation grants you created and, if applicable, leave/deny the AWS RAM share.

Producer account:

  1. Revoke the Lake Formation grants to the consumer account and to the IAM Identity Center group.
  2. Revoke the AWS RAM share.
  3. Deregister the S3 data lake location, and delete the customers table and the sydney_lakehouse database.
  4. Empty and delete the S3 bucket.
  5. Deregister the Amazon Redshift namespace from the AWS Glue Data Catalog and delete the federated catalog (Case 2).
  6. Delete the IAM roles you created (LakeFormation-DataLocation-role, IAMIDCRedshiftRole).

Conclusion

In this post, you configured AWS Lake Formation Trusted Identity Propagation so that data governed and shared in one Region (Sydney) can be queried from another Region (São Paulo). Lake Formation cross-account grants are made in the producer Region, and the consumer reaches the shared data from the query Region in the way that fits each engine. For Amazon S3 data queried through Amazon Athena (Case 1), the consumer creates a resource link in the query Region that points back to the shared database. For Amazon Redshift data shared through a datashare and AWS Glue federated catalog (Case 2), the consumer instead creates an external schema in Amazon Redshift that references the producer’s Glue Data Catalog directly in the producer Region. No resource link is required. In both cases, because access is authorized against the user’s IAM Identity Center identity rather than a shared role, you get least-privilege access, per-user auditability in AWS CloudTrail, and a single place to manage permissions.

To add more consumer accounts or query Regions, repeat the consumer-side steps for the relevant case: accept the AWS RAM share in the producer Region, then either create a resource link in the query Region and grant Describe on it to your IAM Identity Center principals (Amazon S3 and Athena), or create an external schema that references the producer’s Glue catalog and grant usage to your IAM Identity Center principals for Amazon Redshift.

As a next step, review the Trusted Identity Propagation supported applications and services to plan which services in your stack to enable with TIP, using the Lake Formation cross-account sharing documentation as a reference.

 


About the authors

Maneesh Sharma

Maneesh Sharma

Maneesh is a Senior Analytics Specialist Solutions Architect at AWS with more than 15 years of experience designing and implementing large-scale data warehouse and analytics solutions. He works with FSI and Enterprise customers to implement modern analytics architectures using Amazon Redshift, Amazon SageMaker Unified Studio, Amazon S3 Tables, a capability of Amazon S3, AWS Glue, AWS Lake Formation, and AWS IAM Identity Center.

Laura Reith

Laura Reith

Laura is an Identity Solutions Architect at AWS, where she thrives on helping customers overcome security and identity challenges. In her free time, she enjoys wreck diving and traveling around the world.

Sri Pooja Ravipati

Sri Pooja Ravipati

Sri is a Technical Product Manager for AWS IAM Identity Center with eight years of experience across product and technology. She works with enterprise customers to simplify how they manage workforce identity and access, building capabilities that make secure access easier to manage at scale.

Songzhi Liu

Songzhi Liu

Songzhi is a Principal Big Data Architect with the AWS Identity Solutions team. In this role, he collaborates closely with AWS customers and cross-functional teams to design and implement scalable data architectures, focusing on integrating big data and machine learning solutions to enhance identity awareness within the AWS environment.

OS patching and upgrades with Amazon EC2 root volume replacement

Post Syndicated from Akshaya Shah original https://aws.amazon.com/blogs/compute/os-patching-and-upgrades-with-amazon-ec2-root-volume-replacement/

Customers running stateful workloads on Amazon Elastic Compute Cloud (Amazon EC2) need to patch operating systems, upgrade OS versions, remediate vulnerabilities quickly, and patch applications in user space. These workloads are connected to resources such as Elastic IP addresses, secondary network interfaces, attached data volumes, AWS Identity and Access Management (IAM) instance profiles, and DNS records. For OS upgrades or patches that require downtime, a common approach is to replace the instance with a new one launched from a newer Amazon Machine Image (AMI). That approach means moving each of these resources to the new instance. With Amazon EC2 Replace Root Volume (RRV) using Amazon Elastic Block Store (Amazon EBS), you can prepare and validate a replacement root volume in advance. You then swap it onto a running production instance with a single API call. The interruption is limited to the reboot that RRV performs and the time your application takes to restart.

In this post, we show how to use the existing EBS volume option of root volume replacement on a Linux instance to upgrade the operating system generation and apply patches, while the instance keeps its production identity. We also cover instances in Amazon EC2 Auto Scaling groups.

Constraints and considerations

RRV has the following requirements and considerations:

  • The replacement volume must be in the same Availability Zone as the instance.
  • The replacement volume must be in the available state and must not be attached to any instance at the time of the request.
  • If the original root volume is encrypted, the replacement volume must also be encrypted. You can move from an unencrypted volume to an encrypted one, but not from encrypted to unencrypted.
  • For AWS Marketplace instances, the product codes and billing codes on the replacement volume must match those on the instance.
  • The target instance must be in the running state.
  • The instance is automatically rebooted during the process. Memory contents and running processes do not survive the reboot.
  • The delete-on-termination attribute of the original root volume doesn’t transfer to the replacement volume.
  • At this time, root volume replacement supports virtualized instance types and EC2 Mac bare metal instances. Other bare metal instance types aren’t currently supported. Check the Amazon EC2 documentation for the latest list of supported instance types.
  • To avoid a failed boot, make sure the replacement volume has the Elastic Network Adapter (ENA) and NVMe drivers that Nitro-based instances require. Use the same CPU architecture and boot mode (UEFI or legacy BIOS) as the target instance.
  • To avoid an SSH host-key change, copy the existing host keys to the new volume before the swap.
  • Review your cloud-init configuration, since a volume from a fresh AMI snapshot can re-run user data on its first boot.
  • As a best practice, keep application-specific data and configuration on a separate (non-root) volume, so it is not impacted when the root volume is replaced.

Benefits of root volume replacement compared to instance replacement

Benefits of root volume replacement include:

  • Preparation of the replacement volume: You build and validate the replacement volume on a separate instance, so the production instance is untouched until the swap.
  • Reduce the downtime: Reduce the downtime to an in-place reboot instead of replacing the instance and reassociating its attached resources.
  • Test before commitment: You validate the replacement volume in non-production first. If validation fails, you discard it without ever touching production.
  • Resource preservation: The swap retains network interfaces (ENIs), Elastic IP addresses, EBS data volumes, instance store data, IAM instance profiles, security groups, placement groups, and DNS names. As a result, you avoid the re-association, re-attachment, and reconfiguration steps that instance replacement requires.
  • Rollback path: If you keep the original root volume, you can swap back to it.

How it works

It’s a two-phase process: in the preparation phase you build the new root volume, and in the replacement phase you call the root volume replacement API, which swaps the volume and reboots the instance.

Step 1 – Preparation phase: On a separate preparation instance, you create a new volume from a newer OS AMI, patch and configure it in a chroot, and test a copy on a non-production instance.

Preparation phase: on a separate preparation instance, a new root volume is created from a newer OS AMI, patched and configured in a chroot, then validated on a non-production instance.

Figure 1: Preparation phase. You build and validate the replacement root volume on a separate instance before touching production.

Step 2 – Replacement phase: The create-replace-root-volume-task call starts a task that, during one reboot, detaches the original root volume and attaches the prepared one. Network interfaces, data volumes, and Elastic IP addresses stay in place, and the instance returns at the same IP address after the application restarts.

Replacement phase: the replace root volume task detaches the original root volume and attaches the prepared one during a single reboot, while network interfaces, data volumes, and Elastic IP addresses stay in place.

Figure 2: Replacement phase. A single API call swaps the prepared volume onto the running instance, which returns at the same IP address.

What is preserved during the reboot

The reboot clears memory and running processes but preserves the instance’s identity and attached resources:

Resource Preserved Notes
Instance ID Yes Same instance. No termination occurs
Private IP address Yes Retained. The instance keeps its private IP address across the replacement
Public IP / Elastic IP address Yes Remains associated with the instance
Network interfaces (ENIs) Yes All ENIs retain their IP addresses, identifiers, and attachment IDs
EBS data volumes Yes Non-root volumes remain attached
Instance store volumes Usually Volumes stay attached and their data normally survives the reboot, but this isn’t guaranteed. A failed reboot or a problem with the underlying host can cause instance store data to be lost. Keep data you need on EBS volumes.
IAM instance profile Yes Role and policies (including tag-based policies) remain enforced
Security groups Yes All security group associations are retained
DNS names Yes Public and private DNS names are unchanged
Placement group Yes Instance remains in the same placement
Tags Yes Instance tags are unchanged
Memory (RAM) contents No Volatile memory is wiped during the reboot
Running processes No Applications must restart as part of the boot sequence

Solution walkthrough

The walkthrough has four steps:

  1. Create the replacement volume.
  2. Prepare and install patches on the volume.
  3. Test the prepared volume.
  4. Swap it onto the running instance.

Replace placeholder values (such as the instance ID, volume ID, and Availability Zone).

Prerequisites

Before you begin, make sure that you have:

  • AWS Command Line Interface (AWS CLI) v2.36.0 or later that supports the --volume-id parameter on create-replace-root-volume-task, configured with credentials that have permissions for Amazon EC2 and Amazon EBS operations (for encrypted volumes, including the AWS Key Management Service (AWS KMS) permissions to detach the original volume and to attach and decrypt the replacement).
  • A running Linux target instance with an Amazon EBS root volume that you want to replace.
  • A preparation instance in the target’s Availability Zone, launched from a different AMI than the replacement volume’s base so their root filesystems are less likely to share a UUID. If they do share one on XFS, mount the volume with -o nouuid.
  • A non-production test instance of the same instance type, launched from the target instance’s AMI so that it has the same boot mode.

Step 1: Create the replacement root volume

Create the replacement volume in the target instance’s Availability Zone, typically from the root snapshot of a newer OS AMI. If the original root volume is encrypted, encrypt the new one, and make it at least as large as the current root volume. A volume restored from a snapshot loads its blocks from Amazon Simple Storage Service (Amazon S3) on first read, so its first boot can be slower than a normal reboot. Setting the --volume-initialization-rate parameter, or enabling fast snapshot restore on the snapshot, reduces this. Both are billed separately.

First, confirm that the base AMI supports the instance’s boot mode and find the AMI’s root snapshot. The instance reports uefi or legacy-bios. The AMI reports uefi, legacy-bios, or uefi-preferred (which supports both). An AMI with no boot mode uses the instance type’s default.

aws ec2 describe-instances \
    --instance-ids <target-instance-id> \
    --query 'Reservations[0].Instances[0].CurrentInstanceBootMode'

aws ec2 describe-images \
    --image-ids <base-ami-id> \
    --query 'Images[0].{BootMode:BootMode,RootDevice:RootDeviceName,Snapshot:BlockDeviceMappings[0].Ebs.SnapshotId}'

Use the snapshot ID this returns as <base-snapshot-id> in the next command.

aws ec2 create-volume \
    --availability-zone <az> \
    --snapshot-id <base-snapshot-id> \
    --volume-type gp3 \
    --size <size-in-GiB> \
    --encrypted \
    --kms-key-id <kms-key-id> \
    --volume-initialization-rate <MiB-per-second>

Expected outcome: An available EBS volume with a bootable OS, ready to attach to the preparation instance.

Step 2: Prepare and install patches on the volume

Attach the volume to the preparation instance as a secondary disk:

aws ec2 attach-volume \
    --volume-id <new-root-volume-id> \
    --instance-id <prep-instance-id> \
    --device /dev/sdf

Mount the root partition and bind-mount /proc, /sys, /dev, and /dev/pts so that package managers inside the chroot install into the new volume. Use lsblk to find the device. On Nitro-based instances it appears as an NVMe device (for example, /dev/nvme1n1), not /dev/sdf. Copy /etc/resolv.conf so DNS works inside the chroot.

lsblk
sudo mkdir -p /mnt/newroot
sudo mount <root-partition> /mnt/newroot
sudo mount --bind /proc /mnt/newroot/proc
sudo mount --bind /sys /mnt/newroot/sys
sudo mount --bind /dev /mnt/newroot/dev
sudo mount --bind /dev/pts /mnt/newroot/dev/pts
sudo cp --remove-destination /etc/resolv.conf /mnt/newroot/etc/resolv.conf

Inside the chroot, apply OS security patches, install or upgrade application software, and apply configuration. Because the volume is detached from production, you can take as long as you need.

sudo chroot /mnt/newroot /bin/bash
# Inside the chroot, for example:
# - apply OS security updates with your package manager
# - install or upgrade application packages
# - write application and service configuration
# - enable services to start on boot
# - if you upgrade the kernel, rebuild the initramfs and update the GRUB configuration
# - on SELinux-enabled distributions, schedule a relabel (touch /.autorelabel)
# - validate configuration (for example, a service's own config test)
exit

Before detaching, create mount points for the target’s data volumes and add /etc/fstab entries on the new volume. Use UUID= or LABEL= entries rather than device names, which can change between boots on Nitro-based instances.

Then unmount and detach the volume:

sudo umount /mnt/newroot/dev/pts /mnt/newroot/dev /mnt/newroot/sys /mnt/newroot/proc
sudo umount /mnt/newroot

aws ec2 detach-volume --volume-id <new-root-volume-id>

Expected outcome: An available, patched EBS volume that has not been attached to the production instance.

Step 3: Test the prepared volume

Snapshot the prepared volume, create a test volume from the snapshot in the test instance’s Availability Zone, and replace the test instance’s root volume with it. Confirm that the instance boots, the application starts, and the data volumes mount.

aws ec2 create-snapshot \
    --volume-id <new-root-volume-id> \
    --description "Prepared root volume"

aws ec2 create-volume \
    --availability-zone <test-az> \
    --snapshot-id <prepared-snapshot-id> \
    --volume-type gp3 \
    --encrypted \
    --kms-key-id <kms-key-id>

aws ec2 create-replace-root-volume-task \
    --instance-id <test-instance-id> \
    --volume-id <test-volume-id> \
    --delete-replaced-root-volume

Expected outcome: The test instance boots and the application passes your checks. If it doesn’t, fix the prepared volume and repeat this step.

Step 4: Run the root volume replacement

Before you run the replacement, record the current root volume ID and snapshot it as a fallback. Amazon EC2 keeps the original root volume by default. This example also sets --no-delete-replaced-root-volume explicitly. Use --delete-replaced-root-volume only if you don’t need a rollback path.

aws ec2 describe-instances \
    --instance-ids <target-instance-id> \
    --query 'Reservations[0].Instances[0].[RootDeviceName,BlockDeviceMappings]'

aws ec2 create-snapshot \
    --volume-id <original-root-volume-id> \
    --description "Root volume before replacement"
# Then run the replacement:

aws ec2 create-replace-root-volume-task \
    --instance-id <target-instance-id> \
    --volume-id <new-root-volume-id> \
    --no-delete-replaced-root-volume

Monitor the task. It moves through pending, in-progress (when the instance reboots), and succeeded.

aws ec2 describe-replace-root-volume-tasks \
    --replace-root-volume-task-ids <task-id> \
    --query 'ReplaceRootVolumeTasks[0].TaskState'

Expected outcome: The task reports succeeded, and the instance comes back on the patched root volume with its instance ID, IP addresses, network interfaces, data volumes, and IAM instance profile retained. Disruption is roughly one reboot plus your application’s start time.

If the task doesn’t succeed:

  • failed: The instance was rebooted with its original root volume attached. Fix the volume (repeat Step 3), then retry.
  • failed-detached: The instance doesn’t have a root volume attached. Contact AWS Support before you take further action on the instance.

A task can report succeeded even if the instance fails to boot on the new volume, for example because of a boot mode or driver mismatch. Confirm that the instance passes its status checks after every replacement. If it doesn’t, check the console output or the EC2 serial console, then roll back.

aws ec2 describe-instance-status \
    --instance-ids <target-instance-id> \
    --include-all-instances

aws ec2 get-console-output \
    --instance-id <target-instance-id> \
    --latest

Finally, set delete-on-termination on the new root volume to match the original:

aws ec2 modify-instance-attribute \
    --instance-id <target-instance-id> \
    --block-device-mappings '[{"DeviceName":"<root-device-name>","Ebs":{"DeleteOnTermination":<true-or-false>}}]'

Rolling back

To roll back, run another replacement with the original root volume. If you deleted it, pass the snapshot from Step 4 with --snapshot-id instead of --volume-id (the two are mutually exclusive). Root volume replacement accepts snapshots of the instance’s previous root volumes.

aws ec2 create-replace-root-volume-task \
    --instance-id <target-instance-id> \
    --volume-id <original-root-volume-id> \
    --no-delete-replaced-root-volume

Then validate the instance as you did after the swap and set delete-on-termination again. Changes written to the new root volume after the swap aren’t carried back, so keep data that must survive a rollback on a data volume.

If the instance is in an Auto Scaling group, roll back before you exit Standby.

Using root volume replacement with Auto Scaling groups

Instances in an Amazon EC2 Auto Scaling group need different handling, because the group’s health checks and scaling can terminate or replace an instance mid-swap. Choose the approach that matches what you’re updating.

One instance at a time: Put the instance in Standby first, reducing the desired capacity so the group doesn’t launch a replacement. Standby temporarily takes the instance out of service. Then create, validate, and run the root volume replacement, update the launch template so future instances use the new OS, and return the instance to service. For the Standby steps, see Temporarily remove instances from your Auto Scaling group in the Amazon EC2 Auto Scaling User Guide.

A whole fleet: To update every instance in a group from a new AMI, use the ReplaceRootVolume strategy within instance refresh, where the Auto Scaling group replaces each instance’s root volume in stages while preserving network interfaces, data volumes, and instance store data. It applies the same AMI to every instance, so it suits fleet-wide OS updates rather than a volume prepared for one stateful instance. For details, see Replace root volumes during instance refresh in the Amazon EC2 Auto Scaling User Guide.

Clean up

To avoid ongoing charges, delete the resources that you no longer need.

The original root volume and its fallback snapshot, when you no longer need them for rollback:

aws ec2 delete-volume --volume-id <old-root-volume-id>

aws ec2 delete-snapshot --snapshot-id <original-root-snapshot-id>

The preparation instance:

aws ec2 terminate-instances --instance-ids <prep-instance-id>

The test instance, the test volume, and the Step 3 snapshot:

aws ec2 terminate-instances --instance-ids <test-instance-id>

aws ec2 delete-volume --volume-id <test-volume-id>

aws ec2 delete-snapshot --snapshot-id <prepared-snapshot-id>

If an Auto Scaling group uses an AMI registered from the snapshot, deregister the AMI before you delete the snapshot. If you launched a target instance only to try out this workflow, terminate it as well.

Conclusion

Amazon EC2 Replace Root Volume with the direct EBS volume option separates volume preparation from the production interruption. When you need to upgrade an operating system or apply a critical patch, you prepare and validate the change on a separate instance, then swap it in with a single API call. The instance keeps its IP addresses, ENIs, data volume attachments, and DNS names across the swap, so you avoid re-association and re-mounting, and the interruption is limited to the single reboot that RRV performs. Before adopting this approach in production, validate it against your own workload, including boot time and any personalized data on the root volume. To get started, see Replace the root volume for an Amazon EC2 instance without stopping it in the Amazon EC2 User Guide.

 

Best practices for integrating IAM Roles Anywhere with your PKI

Post Syndicated from Abhishek Avinash Agawane original https://aws.amazon.com/blogs/security/best-practices-for-integrating-iam-roles-anywhere-with-your-pki/

Using temporary credentials is a security best practice to reduce the risk from credentials getting compromised. With AWS IAM Roles Anywhere, now your workloads outside of Amazon Web Services (AWS) will be able to access AWS resources by obtaining temporary security credentials in AWS Identity and Access Management (IAM). IAM Roles Anywhere relies on public key infrastructure (PKI) to establish trust between your AWS account and certificate authorities that issues certificates to your workloads that run outside AWS. PKI involves the generation, distribution, and verification of digital certificates through public key encryption.

In this post, we show you some best practices, considerations, and solutions for integrating your PKI with IAM Roles Anywhere. In this post, we assume that you have a foundational knowledge of IAM and IAM Roles Anywhere. For more information, see the IAM and IAM Roles Anywhere documentation.

Note: As of July 2026, AWS Certificate Manager (ACM) supports exportable public certificates that can be used on workloads outside AWS (containers or on-premises). Additionally, ACM supports the ACME protocol for automated certificate issuance and renewal on external workloads. These capabilities complement IAM Roles Anywhere by providing both identity (using X.509 certificates for IAM Roles Anywhere) and encryption (using ACM public/private certificates) for hybrid environments.

X.509 certificates for workload identity

If you’re running multiple workloads in your infrastructure, you’re probably assigning unique workload identities and specific user permissions. In this environment, your workloads can use PKI tools like Active Directory Certificate Services to centrally manage and monitor the identities and permissions at scale. The same principles of segregating unrelated workloads apply to PKI and IAM Roles Anywhere. If you’re issuing certificates to your workloads today, they can probably be used with IAM Roles Anywhere without any changes.

As shown in Figure 1, the access boundary and permissions of Workload A can be very different from that of Workload B when accessing AWS resources, depending on the organization’s needs. In such scenarios, it’s recommended to establish a unique machine identity for each workload. This can be achieved by issuing a separate X.509 certificate with distinct identity attributes signed by your certificate authority (CA). To set up an effective PKI solution for machine identities, you must configure the appropriate certificate attributes and anchor the trust model around those attributes.

Figure 1: Workloads outside of AWS accessing AWS services

Figure 1: Workloads outside of AWS accessing AWS services

Temporary access credentials

Workloads with distinct CA signed X.509 certificates exchange certificates with IAM, as shown in Figure 2, to get temporary security credentials for the session. The signature validation process used by IAM Roles Anywhere verifies the certificate issuer and the subject before providing the temporary credentials. To enable this verification process, an IAM Roles Anywhere trust model should be set up by either uploading your CA’s digital certificate as a trust anchor or by referencing an existing AWS Private Certificate Authority (AWS Private CA) certificate. In this post, we focus on using a trust anchor.

To help define the appropriate scope for the temporary credentials, it’s recommended to configure distinct IAM roles for each workload to assume. Workloads must use X.509 certificates issued by your CA. These certificates are exchanged with IAM to get temporary security credentials for the session.

Figure 2: Getting temporary credentials from IAM using CA issued X.509 certificate

Figure 2: Getting temporary credentials from IAM using CA issued X.509 certificate

Setting up a trust model for IAM Roles Anywhere is a three-step process, see Extend AWS IAM Roles Anywhere to workloads outside of AWS for more information.

  1. Create a trust anchor.
  2. Set up IAM roles with the IAM Roles Anywhere trust policy that will be assumed by your non-AWS workload.
  3. Configure profiles with IAM roles and attach relevant session policies. This instructs IAM Roles Anywhere to use your selected session policies whenever you request credentials for any of the attached roles.

Trust model

The trust model is centered around setting up the right trust relationship between IAM Roles Anywhere and your CA by configuring a trust anchor. The model connects the role, the IAM Roles Anywhere service principal, and identities encoded in X509 certificates that are issued by a CA.

Certificate attributes

Certificates are a binary record with encoded key/value pairs as certificate attributes. Let’s look at one of the X.509 certificates to understand different attributes of certificate that are important for IAM Roles Anywhere PKI validation.

The following is an example PEM encoded certificate file that can be decoded with tools like openssl.

-----BEGIN CERTIFICATE-----
MIIKOzCCCSOgAwIBAgIQDlnyZvBeKjgHm1jJ++CbgDANBgkqhkiG9w0BAQsFADBE
MQswC ...........
-----END CERTIFICATE----


Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            e0:5d:b7:72:56:fa:75:e3
    Signature Algorithm: ecdsa-with-SHA512
        Issuer: C=US, ST=CA, L=San Francisco, O=MyCompany Inc., OU=AC, CN=MyCompany CA
        Validity
            Not Before: Sep 15 00:09:17 2023 GMT
            Not After : Sep 18 00:09:17 2023 GMT
        Subject: C=US, O=MyCompany, OU=eCommerce, CN=WorkloadA
        Subject Public Key Info:
            Public Key Algorithm: id-ecPublicKey
                Public-Key: (384 bit)
                pub: 
                    04:3b:74:e1:68:06:fa:b0:3d:fe:f7:5f:74:80:05:
        X509v3 extensions:
            X509v3 Subject Alternative Name: 
                DNS:ecommerce.mycompany.com, DNS:*ecommerce.mycompany.com, URI:spiffe://mycompany.com/workload/ecommerce
            X509v3 Basic Constraints: critical
                CA:FALSE
            X509v3 Key Usage: critical
                Digital Signature
    Signature Algorithm: ecdsa-with-SHA512
         30:64:02:30:19:a7:a0:33:49:ce:e1:cb:76:6e:c0:f3:c5:66:

IAM Roles Anywhere extracts values from the subject Common Name (CN), issuer, and Subject Alternative Name (SAN) fields of the authenticating certificate and makes them available for policy evaluation through the sts:sourceIdentity and principal tags. When configuring your PKI, set short validity periods on the certificates issued to workloads. Short-lived certificates limit the window of exposure if a certificate is compromised and reduce the need for active revocation.

"aws:PrincipalTag/x509Subject/CN": "WorkloadA" 
"aws:PrincipalTag/x509Issuer/CN": "MyCompany CA" 
"aws:PrincipalTag/x509SAN/DNS": "ecommerce.mycompany.com" 
"aws:PrincipalTag/x509SAN/URI": "spiffe://mycompany.com/workload/ecommerce" "aws:PrincipalTag/x509SAN/Name/CN": "WorkloadA"

Note: An empty Subject CN in a certificate isn’t supported by IAM Roles Anywhere.

Trust policy and effective permissions of credentials

IAM Roles Anywhere clients fetch temporary credentials for IAM Roles using the API method CreateSession. To successfully authorize this API call, the IAM role trust policy should trust the IAM Roles Anywhere service principal as shown in the following example.

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Service": "rolesanywhere.amazonaws.com"
            },
            "Action": [
                "sts:AssumeRole",
                "sts:TagSession",
                "sts:SetSourceIdentity"
            ],
            "Condition": {
                "StringEquals": {
                    "aws:PrincipalTag/x509Issuer/CN": "MyCompany CA",
                    "aws:PrincipalTag/x509Subject/CN": "WorkloadA",
                    "aws:PrincipalTag/x509SAN/Name/CN": "WorkloadA"
                },
                "ArnEquals": {
                    "aws:SourceArn": [
                        "arn:aws:rolesanywhere:region:account:trust-anchor/TA_ID"
                    ]
                }
            }
        }
    ]
}

As a security best practice, consider using additional condition statements to evaluate the session PrincipalTag attributes extracted from the certificate to further restrict authorization of temporary credentials. If the workloads have ephemeral certificates with a dynamic subject CN, then the issuer or SAN principal tags can be used to define condition statements in the trust policy. The trust anchor shown in the preceding trust policy helps establish the trust between IAM Roles Anywhere and the CA in your PKI.

Plan for trust anchor placement

Depending on the CA platform, you can configure one or more intermediate certificates in your PKI certificate hierarchy, as shown in Figure 3. The certificate level the trust anchor is placed at affects which end-entity certificates IAM Roles Anywhere accepts, and therefore which workloads are authorized to assume a role. To create a trust anchor, export the certificate bundle for the issuing CA certificate from your external CA platform in PEM-encoded format and upload it to the trust anchor console.

Figure 3: Certificate hierarchy in an external PKI

Figure 3: Certificate hierarchy in an external PKI

See the Solution deployment section to onboard a sample solution and verify your certificate bundle.

Fine-grained access control is vital for security because it allows organizations to precisely define and restrict permissions, minimizing the risk of unauthorized access and data breaches. The effective permissions of the temporary security credentials are an intersection of resource-based, identity-based, and session policies as shown in Figure 4. By setting up the trust anchor and profile configuration in IAM Roles Anywhere with session policies, you can limit the permissions for each session.

Figure 4: Effective permissions of session credentials

Figure 4: Effective permissions of session credentials

Credential helper

To obtain temporary security credentials from IAM Roles Anywhere, use the credential helper tool. This tool is compatible with the credential_process feature available across the AWS SDKs. The credential helper manages the process of obtaining temporary security credentials using IAM Roles Anywhere and returns them in JSON format. The private key of the workload and its CA signed certificate are the two critical parameters provided to the credential helper tool. To use this tool, you need both an end-entity certificate and a trust anchor created in IAM Roles Anywhere. As of December 2024, the credential helper (version 1.4.0 and later) supports Trusted Platform Module (TPM) 2.0.

The following is an example of how to obtain temporary security credentials:

$ ./aws_signing_helper credential-process \
--certificate /path/to/certificate \
--private-key /path/to/private-key \
--trust-anchor-arn arn:aws:rolesanywhere:region:account:trust-anchor/TA_ID \
--profile-arn arn:aws:rolesanywhere:region:account:profile/PROFILE_ID \
--role-arn arn:aws:iam::account:role/role-name-with-path

When credential-process is used with AWS SDKs, the temporary credentials are stored in memory and refreshed automatically. However, the AWS Command Line Interface (AWS CLI) invokes credential-process on every command. Each invocation creates a new IAM Roles Anywhere session, which adds latency to every CLI call. To avoid this delay when using the AWS CLI, we recommend using the update command, which writes refreshed credentials to a file that the CLI reads on each invocation without calling credential-process again.

When you use update, credentials are written to a credential file on disk and any user or process who can read the credential file might be able to read and use those AWS credentials, so it’s recommended to use serve unless your business has a specific use case that needs to read AWS credentials from a file. The serve command can be used to vend temporary credentials through an endpoint running on the local host using the same URIs and request headers as Instance Metadata Service Version 2 (IMDSv2). When using serve, no AWS configuration files are needed because the AWS SDKs can discover the server endpoint.

Just-in-time compared to short-lived certificates as workload identities

To help mitigate the risk around the exposure of your business-critical resources to compromised certificate user sessions, it’s recommended to use certificates with just-in-time credentials or short-lived access credentials. Just-in-time certificates are issued by a CA for immediate use for authentication on a system or application. The CA uses the roles and policies defined in IAM to determine when, and under what conditions, a workload can establish a session. With just-in-time access, admins don’t have to worry about revoking access if the users’ access roles change. Because the access credentials are recreated for each access request, any role changes can be applied in real time. Just-in-time access requires support from your CA, so verify compatibility with your CA before implementing it.

It’s recommended to tailor the validity periods for short-lived certificates to align with business requirements, workload lifecycle, and your organization’s risk tolerance. AWS Private CA offers a short-lived CA mode specifically designed for certificates with validity periods under 7 days. This mode is priced at $50 per month (compared to $400 per month for general-purpose CA mode), making it cost-effective for high-volume, ephemeral workload identities such as containers and serverless functions. For more information, see AWS Private Certificate Authority and How to use AWS Private CA short-lived certificate mode. See Zero trust on AWS for information about how short-lived credentials support a zero trust architecture.

Best practices for private key storage

Safeguarding private keys is of paramount importance in any environment. A private key is created by the certificate owner when requesting certificates with a Certificate Signing Request (CSR). The CA providing your certificate doesn’t create or have your private key. In fact, it’s recommended that no one outside your administrators should ever be given access to this material. Guardrails should be implemented to make sure the keys aren’t stored in an online repository.

Using the credential helper with IAM Roles Anywhere, you can integrate with PKCS #11-compatible modules. For implementation details, see How to implement cryptographic modules to secure private keys used with IAM Roles Anywhere. With the credential helper (version 1.1.0 and later), IAM Roles Anywhere supports PKCS #11 integration, so you can use private keys stored in hardware security modules (HSMs) or software-based secure stores and avoid storing them to disk. Additionally, as of December 2024, the credential helper supports Trusted Platform Module (TPM) 2.0, enabling private keys to be generated inside the TPM and marked as non-exportable. With TPM-backed keys, the private keys can’t be extracted even if the workload host is compromised. This is the strongest private key protection available for on-premises and hybrid workloads using IAM Roles Anywhere.

If your organization doesn’t implement PKCS #11–compatible modules, IAM Roles Anywhere supports credential helper version 1.0.5 for X.509 certificates and private keys stored in macOS and Windows certificate stores. This delegates the signing operations to keys stored within those OS-specific certificate stores. In Windows, both CryptoAPI and Cryptography API: Next Generation (CNG) are supported; in macOS, Keychain is supported. In these environments, the private key will never be directly readable, and the signing operation would be delegated to the secure store instead, which will improve security posture of your environment. If you have multiple certificates, then use the --cert-selector flag of the credential helper tool to search for a specific certificate (and associated private key) through the certificate subject, issuer, and serial number. The corresponding keys are x509Subject, x509Issuer, and x509Serial, respectively. These keys can be specified either through a JSON file format or through the command line. The following is an example of both approaches:

If you want to use a JSON file, it should be similar to this:

[
  {
    "Key": "x509Subject",
    "Value": "CN=Subject"
  },
  {
    "Key": "x509Issuer",
    "Value": "CN=Issuer"
  },
  {
    "Key": "x509Serial",
    "Value": "15D19632234BF759A32802C0DA88F9E8AFC8702D"
  }
]

If the preceding example is placed in a file called selector.json, it can be specified with the --cert-selector flag through file://path/to/selector.json.

The same certificate selector argument can be specified through the command line as shown in the following:

--cert-selector Key=x509Subject, Value=CN=Subject Key=x509Issuer, Value=CN=Issuer Key=x509Serial, Value=15D19632

Monitoring trust anchor certificates

Monitoring certificate expiry in PKI is crucial for maintaining secure communication, because expired certificates can lead to authentication failures, leaving systems vulnerable to unauthorized access. Compliance and regulatory requirements often mandate timely renewal of certificates, making monitoring certificate expiry essential to avoid legal penalties and maintain organizational integrity.

Using IAM Roles Anywhere, you can receive notifications for CA certificate expiry and end-entity certificate expiry. IAM Roles Anywhere can send metrics, events, and notifications through multiple notification channels as shown in the following table:

Event Channel Threshold Enabled
CA certificate expiry CloudWatch, Amazon EventBridge, and AWS Health 45 days before expiry TRUE
End-entity certificate expiry EventBridge and AWS Health 45 days before expiry TRUE

The preceding notifications thresholds are default settings, which you can customize using the steps in Customize notification settings in IAM Roles Anywhere.

Best practices for trust anchor certificate rotation

Consider rotating your certificates using blue/green deployments to seamlessly roll out new certificates without affecting service availability. Use automation to integrate certificate rotation testing in your blue/green deployment pipeline. See the sample AWS CloudFormation template in the Solution deployment section to build your automation pipeline.

As shown in Figure 5, two trust anchors have been created; one for each environment. Using this approach, you can test the certificate rotation process in a controlled staging environment before applying it to the production environment, thus reducing the risk of certificate-related outages.

 Figure 5: Trust anchor certificate rotation in blue/green deployment

Figure 5: Trust anchor certificate rotation in blue/green deployment

Solution deployment

The sample solution template is available in the IAM Roles Anywhere — External PKI Integration repository on GitHub. It contains the following main steps:

  1. Creates a distinct IAM role with policies scoped to the resources that workload requires.
  2. Assigns a trust relationship to a IAM Roles Anywhere principal.
  3. Creates a trust anchor using a certificate bundle from an external CA.
  4. Creates a profile with the required IAM Roles.

Parameters for this deployment are shown in the following table:

Parameter Required Default Description
TrustAnchorCertificateBody Yes — PEM-encoded X.509 certificate of your CA (includes BEGIN and END markers)
WorkloadSubjectCN No WorkloadA Subject CN from the workload certificate, used in trust policy conditions
WorkloadIssuerCN No MyCompany CA Issuer CN from the CA certificate, used in trust policy conditions
WorkloadSANCN No WorkloadA SAN CN from the workload certificate, provides additional identity assertion
SessionDurationSeconds No 3600 Maximum session duration in seconds (900–43200)
NotificationEmail No (empty) Email address for certificate expiry notifications. When provided, creates an Amazon Simple Notification Service (Amazon SNS) topic and an EventBridge rule

Important: The SessionDurationSeconds value must not exceed the IAM role’s MaxSessionDuration setting, which defaults to 3600 seconds (1 hour). If you request a longer session than the role allows, the CreateSession API returns an error. The companion template keeps both values in sync by setting the role’s MaxSessionDuration to the same parameter value. If you configure the role separately, update its MaxSessionDuration to match your desired session length. For more information, see IAM Roles Anywhere CreateSession API.

If the workload’s subject CN (WorkloadSubjectCN) doesn’t match the x509Subject CN of the authenticating certificate, the CreateSession call is denied. The same conditional verification applies to WorkloadIssuerCN and WorkloadSANCN.

The template enforces three certificate attribute conditions in the IAM trust policy: issuer CN, subject CN, and SAN CN must all match the values configured as parameters. Additionally, the trust policy scopes role assumption to the specific trust anchor Amazon Resource Name (ARN), preventing other trust anchors in the same account from assuming that role.

Conclusion

In this post, we discussed how AWS IAM Roles Anywhere, empowered by X.509 certificates, introduces a dynamic and secure way to manage workload identities and issue temporary access credentials. By understanding the trust model, using certificate attributes, and implementing fine-grained authorization, organizations can gain granular control over the effective permissions of these credentials.

The choice between just-in-time and short-lived certificates as workload identities depends on your specific requirements.

Adhering to best practices for private key storage is essential to maintain the integrity of your security infrastructure. Regularly rotating trust anchor certificates helps ensure the long-term trustworthiness of your system.

As you embark on your journey to implement IAM Roles Anywhere and integrate it with your PKI, remember that a well-architected approach to certificate management is a cornerstone of robust and resilient cloud security.

For additional deployment guidance, see Planning for your IAM Roles Anywhere deployment, which covers end-to-end deployment planning including network architecture, monitoring, and operational considerations.

If you have questions, you can start a new thread on AWS re:Post or reach out to AWS Support.


Abhishek Agawane

Abhishek Avinash Agawane

Abhishek is a Security Consultant at Amazon Web Services with more than 8 years of industry experience. He helps organizations architect resilient, secure, and efficient cloud environments, guiding them through complex challenges and large-scale infrastructure transformations. He has helped numerous organizations enhance their cloud operations through targeted optimizations, robust architectures and best-practice implementations.

Manoj Gupta

Manoj Gupta

Manoj is a Senior Solutions Architect at AWS, based in San Francisco. With over 4 years of experience at AWS, he works closely with customers to build optimized AI/ML powered solutions and cloud infrastructure. His primary focus areas are data, AI/ML, and security, helping organizations modernize their technology stacks. Outside of work, he enjoys outdoor activities and traveling with family.

Limit agent permissions and spend in AWS Transform custom

Post Syndicated from Hamza Alfarrash original https://aws.amazon.com/blogs/devops/limit-agent-permissions-and-spend-in-aws-transform-custom/

Engineering teams that own large Java estates face recurring upgrade deadlines. When they hand one of those upgrades to an AI agent, the agent brings its own permissions and its own bill. Consider a central engineering group with a few hundred Java services, several of which run on a Java Development Kit (JDK) version that reaches end of support in two quarters. Upgrading each service by hand means a dependency audit, a javax to jakarta migration, a test framework migration, and a release. The deadline does not move while the team works through the list.

Agents change the arithmetic. An agent upgrades a repository in place, where it runs the build, edits the source, and commits as it goes, and it finishes in minutes a job that takes a developer weeks. Across a few hundred services you don’t sit at a terminal approving each step. You invoke the agent from a pipeline, repository after repository, and an autonomous process with shell access to your source code now runs throughout your estate.

That speed comes with a footprint you must manage. The agent runs shell commands against the code that it’s changing, it uses the AWS credentials in its environment, and it bills you by the agent minute while it works. Before you point it at a few hundred repositories, you decide what permissions the agent holds and how much it can spend. That decision repeats wherever a runtime deadline meets a service count too large to upgrade by hand.

AWS Transform custom provides the agent, and you enforce that decision with three configuration controls. You write a trust allowlist to set which of the agent’s actions run without asking you. You scope an execution identity to limit what the agent can ask the service to do. You also set a cap on agent minutes, which stops a run at the limit you choose, and you configure an account-level alert on cumulative spend across runs. None of the three requires new infrastructure, because you manage each one as configuration, so you can adopt agentic transformation on a schedule you set.

The agent runs transformations through the AWS Transform CLI, either interactively on your machine or non-interactively in a pipeline, so the controls must hold in both places. In this post, we walk through the three controls against a working repository and give the measured result of each one. We show how a real run produced the trust allowlist, why interactive runs and pipeline runs need different controls, and how to scope an execution identity to approved transformations. The complete source code is available in the sample repository.

Unbounded agent risks

An agentic transformation runs as a process on a machine you own, rather than handing you a patch to review, and that footprint carries three distinct risks. The agent can run tools without limit, the identity it runs under can do more than run transformations, and the spend it accrues has no ceiling unless you set one. Each risk needs its own control, and you meet all three on the way to your first fleet-wide upgrade.

Tool execution

The agent works by calling tools. It reads a file, writes a file, runs the build, inspects git history, and commits, and the shell commands among them run with the privileges of whoever started the run. On your first run against one service, each of those calls stops for your approval, and after enough prompts the flag that trusts every tool looks like the obvious way to get through the day. That flag stops the prompts by removing the check behind them, which is the risk. The agent can then run whatever a transformation asks of it, on a machine that holds your source code and your credentials.

Identity

The AWS credentials in your environment decide what the caller can do to the service, and the managed policy that gets a first run working also covers authoring. If an identity can both run a transformation and rewrite its definition, it can also change what runs next time. On a laptop that identity belongs to a person exercising judgment, but in a pipeline it belongs to a role that the rest of the build inherits, and the pipeline is where your other few hundred runs will happen.

Spend

Billing is per agent minute, and the meter runs while the service plans, reasons, analyzes, and edits. A single run on a small service costs little, so the risk isn’t the run you watch but a fleet-wide upgrade of a few hundred automated runs, on a bill nobody reads until the end of the month.

Solution overview

The following diagram shows where each of the three controls intercepts a transformation run.

Three controls on an AWS Transform custom run: trust settings, scoped execution identity, a spend cap and a budget alert

Figure 1: Where each of the three controls intercepts a transformation run

  1. Invocation: A developer runs the AWS Transform CLI interactively, or a pipeline runs it non-interactively. The two paths carry different controls.
  2. Trust settings: Before the agent runs a shell command or writes a file, the CLI checks the trust settings file in your home directory. A command that matches the allowlist runs. A command that does not match returns to the person at the terminal as an approval prompt.
  3. Identity: AWS Transform custom authorizes each call the CLI makes against the AWS Identity and Access Management (IAM) identity in your environment. When you attach the execute-only managed policy and scope it to the transformations you approve, the policy bounds the service actions that the caller can request, including requests that originate from the agent.
  4. Spend: The run carries an agent-minute cap and stops when it reaches it. Underneath that, an AWS Budgets budget filtered to AWS Transform watches monthly spend across runs and notifies a subscriber at a threshold you set.

Inside a transformation run

AWS/java-version-upgrade is one of the AWS managed transformations, prebuilt and maintained by AWS rather than authored in a customer account. Running atx custom def list shows the AWS managed definitions alongside any custom ones you have created. The definition for this one covers a range of build systems and source and target JDK versions, with dependency modernization that includes Jakarta EE migration, database drivers, object-relational mapping (ORM) frameworks, and updates across the Spring libraries.

The target version is an input rather than a property of the transformation. The definition gives three ways to supply it: interactive chat with the agent, an additionalPlanContext configuration parameter, or a configuration file. We specified the target version in the prompt we gave to atx.

The sample repository is a small Spring Boot REST service with a single endpoint, running on Java 8 and Spring Boot 1.5. It stands in for one of the few hundred services in the opening scenario. The transformation takes it to Java 17 and Spring Boot 3.2, so the javax.servlet and javax.annotation imports become their jakarta equivalents and the JUnit 4 test moves to JUnit 5. We kept the business logic trivial from the beginning so anyone can follow along. If the logic were more complicated, it would introduce a monetary barrier for readers who want to try it themselves, and it would take longer to run.

In a run (AWS Transform CLI 3.10.0), the transformation made 43 tool calls across the sample’s 4 files: 13 shell commands, 11 file writes, 11 file reads, 6 editor operations, and 2 version control operations. Every shell command ran with the permissions of the identity that started the run, inside the working tree of the repository being upgraded. The counts in this post come from this definition and this sample in August and September 2026, so expect different counts on other CLI versions, definitions, and repositories.

The documentation describes multi-step transformations as using Git to commit intermediate steps. In that run, the 2 version control operations produced a single checkpoint commit on the result branch.

Prerequisites

The walkthrough requires the following:

  • An AWS account and an AWS Region where AWS Transform custom is available, listed in the getting started guide.
  • The AWS Transform CLI and Node.js 22 or later, which the CLI requires. Install the CLI with curl -fsSL https://transform-cli.awsstatic.com/install.sh | bash, then confirm with atx --version. The confirmation runs in this post used version 3.10.0.
  • Git, with the sample checked out as a valid repository, because the transformation commits its intermediate steps. On CLI 3.10.0, a run also stopped when the working directory wasn’t a repository, which is behavior we measured rather than a documented requirement.
  • JDK 8 to build the sample before the upgrade, JDK 17 after it, and Maven 3.6 or later. The commands in this post were verified on macOS. On Linux, set JAVA_HOME to a JDK 8 path directly instead of using the macOS-specific /usr/libexec/java_home helper.
  • AWS credentials with permission to call AWS Transform custom. Control 2 covers which policy to attach.

Restricted networks additionally need transform-cli.awsstatic.com, transform-custom.<region>.api.aws, and *.s3.amazonaws.com reachable. The repository README carries the full setup detail.

Control 1: Scope which tools run without asking

This control starts on a laptop, because that is where the allowlist gets built.

Our first run of AWS/java-version-upgrade against the sample repository used AWS Transform CLI 3.9.0 with no trust configuration, and it raised 23 approval prompts across four files. The later runs used 3.10.0, and the CLI version history lists no change to trust settings or approval prompts in that release.

Before the allowlist, you have two options. Either you grant blanket tool trust for the session, which comes with the risk of tool calls you don’t want the agent to make. Or you walk through every prompt carefully, reading what it says and deciding whether you want that tool call to run, which adds to the overall time the agent takes to complete the task.

Across a few hundred repositories, either choice makes the documented shortcut the obvious thing to reach for. The --trust-all-tools flag (-t) approves the tool calls the agent requests without asking.

The middle position is a trust settings file at ~/.aws/atx/trust-settings.yaml. The file is split into three categories. trustedTools covers the tool calls that are allowed automatically. trustedShellCommands are the shell commands you allow the agent to run on its own without prompting you. And alwaysPromptCommands are the commands the agent has to ask you about before executing, in interactive mode. A fourth mechanism sits alongside the file: answering t at an approval prompt trusts that tool for the rest of the session, which the prompt text itself names when it reports that a tool “hasn’t been trusted this session.”

We first made a test run with no trust configuration in place, so we could see which tool calls the agent makes for this specific transformation. We logged each tool call and added the shape of each command to the file, to make it straightforward for you to use. Twenty-three prompts collapse into a short list, because the agent asks for variations of the same few operations.

Rerunning with that first list (CLI 3.10.0) dropped the count from 23 prompts to 3, and all three were the same shape: cd <path> && <command> compounds in the validation phase. The matcher is a full-command-string glob, so JAVA_HOME=* mvn * does not match a string that starts with cd. Command shapes vary between runs, and this one issued a bare mvn during execution and cd ... && mvn during validation, so the shipped file carries both forms. With both forms added, a third run on a clean clone (CLI 3.10.0) reached zero prompts.

The following excerpt shows the shape of the file. The repository copy carries the full set, including the cd-prefixed variants:

trustedTools:
  - editor
  - file_write
  - vcs_control

trustedShellCommands:
  - "mvn *"
  - "JAVA_HOME=* mvn *"
  - "git log *"
  - "git status"
  - "java -version*"

alwaysPromptCommands:
  - "rm -rf *"
  - "sudo *"
  - "find * -exec *"

Copy the file into place and run the transformation interactively:

mkdir -p ~/.aws/atx
cp guardrails/trust-settings.yaml ~/.aws/atx/trust-settings.yaml

atx custom def exec -n AWS/java-version-upgrade -p . \
  -c "JAVA_HOME=$(/usr/libexec/java_home -v 1.8) mvn clean verify"

The mkdir -p matters on a fresh install, because ~/.aws/atx/ does not exist until the CLI has run at least once.

Patterns match against the full command string, which is why JAVA_HOME=* mvn * is listed separately from mvn *. The validation command pins JDK 8 because Spring Boot 1.5 can’t start its context on later versions, which is what puts that prefix in the run at all.

The alwaysPromptCommands entries are matched against each sub-command in a compound expression rather than against the whole string. In an interactive run, session trust and the --trust-all-tools flag don’t approve a match.

Important: alwaysPromptCommands doesn’t force a prompt in 2 cases.

  • A matching trusted pattern takes priority. The documentation states that trustedShellCommands patterns match the full command string and take priority over the always-prompt list, so a trailing * spans pipes, semicolons, and &&. In our verification run (CLI 3.10.0), the trusted pattern /usr/libexec/java_home * matched a compound that continued ; echo "---" ; ls ..., and the chained tail ran without a prompt. By the same rule, mvn * trusts mvn --version; printf canary even with printf * in alwaysPromptCommands. The file in this post uses mvn * to reach zero prompts on the sample, so narrow that pattern to the Maven goals your builds run before you reuse the file.
  • Non-interactive mode (-x) does not enforce alwaysPromptCommands, as the documentation states, so the list has no effect on a pipeline run.

Keep the two lists non-overlapping and the trusted patterns as narrow as the runs allow. The following diagram shows the order in which the CLI evaluates a proposed shell command.

Decision flow: a trusted full-string match runs first, then always-prompt patterns, then tool and session trust

Figure 2: How the CLI evaluates a proposed shell command in interactive mode

With the allowlist in place, a full run of AWS/java-version-upgrade completed with no approval prompts and without blanket trust. The run made 43 tool calls, each one covered by the allowlist or by a built-in trusted tool, with a green build and 2 of 2 tests passing on AWS Transform CLI 3.10.0. This lets us focus on other tasks, and we’re only interrupted when the task finishes or something unexpected comes up. The startup banner on that version also identified six built-in trusted tools: file_read, grep, both transformation-registry tools, document_manager for add operations, and editor for view and find-line operations. Those calls don’t prompt, so a prompt inventory understates the agent’s effective tool surface. Default trust is also scoped by path rather than only by tool. The same file_write tool runs without prompting when it targets the CLI artifacts directory and prompts when it targets the repository.

In a pipeline

The trust settings file governs interactive runs only. On CLI 3.10.0, -x without --trust-all-tools stops before the transformation starts, with the error Must explicitly --trust-all-tools when running in non-interactive mode, which we hit in a test. A pipeline run therefore trusts every tool, and the allowlist has no effect on it. The interactive path does not fit a pipeline either, because without a terminal it stops at the plan approval step and reports Input is not a TTY.

This control therefore belongs on a developer machine. A pipeline needs its boundary somewhere else, and that somewhere else is the next two controls.

Control 2: Separate the identity that runs from the identity that authors

The pipeline from the opening scenario needs an identity, and that identity should run the upgrade and little else.

Three AWS managed policies cover AWS Transform custom, and the split between them is the control. The following table describes each policy at version 4, the current version on September 30, 2026. Check the managed policies page for a later version before you attach one.

Policy What it grants Who gets it
AWSTransformCustomExecuteTransformations Run existing transformations, and read and update campaigns The pipeline execution role
AWSTransformCustomManageTransformations The preceding, plus authoring: upload and delete transformation packages, manage knowledge items, tag resources The people or pipelines that author definitions
AWSTransformCustomFullAccess Both of the preceding, reasonable for a first run but wider than a governed setup needs Nobody, after setup is complete

The execute-only policy also carries its own iam:CreateServiceLinkedRole statement scoped to the AWS Transform service-linked role, so a first run works without AWSTransformCustomFullAccess even in a fresh account.

Give the pipeline execution role the execute-only policy. Authoring stays with whoever maintains the transformation definitions, so a build step holding that role is not authorized to rewrite the definition that it’s about to run:

aws iam attach-role-policy \
  --role-name my-transform-ci-role \
  --policy-arn arn:aws:iam::aws:policy/AWSTransformCustomExecuteTransformations

Then confirm what the identity holds, rather than what you meant to give it:

aws iam list-attached-role-policies --role-name my-transform-ci-role
aws sts get-caller-identity

The execute-only policy still lets the role run whatever is in the registry. For transformations you publish yourself, you can narrow it further, because transformation packages are AWS resources with Amazon Resource Names (ARNs). The transform-custom:ExecuteTransformation action takes a package resource and supports the aws:ResourceTag condition key. Tag the packages you publish, and scope the role to the tag:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "RunOnlyApprovedTransformations",
      "Effect": "Allow",
      "Action": "transform-custom:ExecuteTransformation",
      "Resource": "arn:aws:transform-custom:us-east-1:111122223333:package/*",
      "Condition": {
        "StringEquals": { "aws:ResourceTag/Approved": "true" }
      }
    },
    {
      "Sid": "ConversationApiCannotBeScopedToAResource",
      "Effect": "Allow",
      "Action": "transform-custom:ConverseStream",
      "Resource": "*"
    }
  ]
}

We confirmed the mechanics with the IAM policy simulator: it allows the execute call when the tag is present and returns an implicit deny when the tag is absent. Note the scope of the example, though. The Resource element names packages in your account, and an AWS managed package such as AWS/java-version-upgrade is not a resource in your account, so a role holding only this policy can’t run it. The execute-only managed policy itself grants ExecuteTransformation on Resource "*" for that reason. The practical split is tag-scoping for the definitions you publish, alongside the managed policy’s broader grant for the AWS managed catalog.

The second statement is the limit of this approach. The actions table in the service authorization reference lists no resource type and no condition keys for ConverseStream, so that statement stays at "*". A role scoped to one transformation can still open a conversation with the service.

The broader limit is that IAM bounds the API surface and not the edits. The execute-only policy still authorizes whatever the transformation does to your working tree, because the scope of changes inside a repository is a local filesystem question. That’s what Control 1 is for on a laptop, and what code review of the resulting branch is for in a pipeline.

Control 3: Cap what a run can spend

Now think about the same upgrade from the cost perspective. The fleet-wide upgrade from the opening scenario means a few hundred runs, but you’re constrained by your budget. With the spend control, you can bound the agent minutes used by each run and use the account-level budget to watch cumulative spend across the fleet.

Start with what an agent minute measures. You’re billed per agent minute, but an agent minute is not a wall-clock minute. The two match only while a single agent is running, and AWS Transform can run several agents at once on one task, so each of them accrues its own minutes. Per the AWS Transform pricing page, minutes accrue while the service is actively planning, reasoning, analyzing, or modifying code, and not while it waits on local compilation, tests, or builds.

AWS Cost Explorer recorded 54.0 agent minutes for the discovery run (CLI 3.9.0) and 45.8 agent minutes for the clean-clone confirmation run (CLI 3.10.0). At the published rate of USD $0.035 per agent minute, the confirmation run cost approximately USD $1.60. A 30-agent-minute cap would therefore stop this particular upgrade partway through, which makes it a useful review checkpoint rather than a budget sized for uninterrupted completion. In practice, the number is experimental. It depends on your budget for the task, and it can take multiple iterations at a smaller scale first to get an understanding of the agent minutes your tasks need.

Set the per-run cap with --limit:

atx custom def exec -n AWS/java-version-upgrade -p . \
  -c "JAVA_HOME=$(/usr/libexec/java_home -v 1.8) mvn clean verify" \
  --limit 30

We wanted to see what the CLI does when it hits the cap, so we ran the same transformation non-interactively on a scratch clone with --limit 1. On AWS Transform CLI 3.10.0 the run stopped at 1.09 agent minutes, printed Budget limit reached: 1.09 / 1.00 Agent Minutes. Exiting., and returned process exit code 2. The repository was left clean. The exit code is the part we were checking. The web documentation describes the message and the resume path but not an exit code. The CLI --help output on 3.10.0 does state that the run exits with code 2 when the limit is reached, and the run confirmed it. It’s also what turns spend into control flow, because a pipeline can tell a budget stop from a transformation failure. The 0.09-minute overshoot comes from how coarsely the CLI receives usage events, so treat the limit as a close bound rather than an exact fractional-minute cutoff.

The work is preserved. Review the partial result, then resume the same conversation with a higher cap. The trust settings file from Control 1 still governs the resumed interactive session:

atx --conversation-id <conversation-id> --limit 60

A pipeline can handle the bounded stop separately:

status=$?
if [ "$status" -eq 2 ]; then
  echo "Agent-minute budget reached; review before resuming"
  exit 0
elif [ "$status" -ne 0 ]; then
  echo "Transformation failed"
  exit 1
fi

Entering /usage during an interactive session reports the accumulated agent minutes without ending the conversation. Minutes persist across an interruption, so when you resume a session after Ctrl+C, the count continues rather than starting over. Per the pricing page, an interrupted transformation can be resumed for up to 24 hours before it’s stopped automatically. We did not test the expiry, so treat that window as documented rather than measured.

The per-run cap says nothing about how many runs start, so back it with an account-level AWS Budgets budget. A budget like the one in this sample sends a notification and does not stop runs, so the next run still starts after the alert. AWS Budgets also updates its data up to 3 times a day, which means a fleet of runs can pass the threshold before the alert arrives. To stop new runs at a threshold, you can configure a budget action that applies an IAM policy or a service control policy (SCP), which this post does not cover.

The sample repository ships a monthly budget of USD $50 filtered to the AWS Transform service, with an alert at 80 percent of actual spend:

aws budgets create-budget \
  --account-id "$(aws sts get-caller-identity --query Account --output text)" \
  --budget file://guardrails/budget.json \
  --notifications-with-subscribers file://guardrails/notifications.json

Edit the email address in guardrails/notifications.json before running that command, because the file ships a placeholder. To see where the month stands, query AWS Cost Explorer for the same service dimension:

aws ce get-cost-and-usage \
  --time-period Start=$(date -u +%Y-%m-01),End=$(date -u +%Y-%m-%d) \
  --granularity MONTHLY --metrics UnblendedCost \
  --filter '{"Dimensions":{"Key":"SERVICE","Values":["AWS Transform"]}}'

Use the cap to bound each run, and use the budget to find out when the runs together cost more than you planned.

Limits of these controls

Retention is a separate question from permissions. The --do-not-learn flag opts one execution out of lesson extraction for that transformation definition. A different control governs whether your content is used for service improvement, and you configure it as an AI services opt-out policy in AWS Organizations. Neither one changes the fact that the transformation sends code to the service to do the work. Both govern what happens to it afterward.

Retained lessons also reach back into later runs. In the verification run, the agent diffed against a baseline commit carried in from a previous run on the same repository, so a README that we had added by hand between the two runs appeared in the diff, and the validation summary credited the transformation with adding it. Lessons persist repository-specific facts across runs, and on a repository that changes between runs they can misattribute human work to the agent.

The number of commits a run produces is not fixed. The same transformation on the same repository produced two commits on the first run and a single commit on each of the next two, and the commits covered a different set of plan steps each time. Review the result branch with git log and git diff rather than assuming one commit per plan step.

Clean up

The controls in this post are configuration rather than provisioned infrastructure, so there is little to tear down. Delete the budget when you no longer want the alert:

aws budgets delete-budget \
  --account-id "$(aws sts get-caller-identity --query Account --output text)" \
  --budget-name aws-transform-monthly

The trust settings file at ~/.aws/atx/trust-settings.yaml and the policy attachments on the execution role are yours to keep or remove. If you also deployed the sample application to AWS to see the upgraded service running, run npx cdk destroy from the infra/ directory. One artifact survives by design. The container image stays in the AWS Cloud Development Kit (AWS CDK) bootstrap repository in Amazon Elastic Container Registry (Amazon ECR), because cdk destroy does not remove bootstrap assets. Storage for it costs about a cent a month, and deleting the image from the cdk-*-container-assets-* repository clears it. Leaving the sample deployed costs roughly USD $13 a month, so tear it down when you’re finished with it.

Conclusion and next steps

You can now put a few hundred upgrades on a schedule, because the execution identity and the agent-minute cap apply to each run wherever it executes. A trust settings allowlist adds a third control on a developer machine. That allowlist took the interactive sample run from 23 approval prompts to zero without blanket tool trust. We built the execution identity from the AWS Transform managed policies in IAM and narrowed it to approved transformations with a resource tag. An AWS Budgets budget and alert on the account backed the agent-minute cap on each run. Because every measurement came from running AWS/java-version-upgrade against the sample repository, you can repeat the runs in your own account before you decide what to change. We ran them in August and September 2026, with the confirmation runs on AWS Transform CLI 3.10.0.

The configuration leaves room to grow. If you publish your own transformation definitions, tag them and scope the execute permission to the tag, which holds a pipeline role to the transformations you have reviewed. And if you run transformations over proprietary repositories, the --do-not-learn flag covers a single execution. An artificial intelligence services opt-out policy in AWS Organizations covers service improvement across the account.

To go further, see the AWS Transform custom user guide for the execution modes and the trust settings reference, the AWS managed policies for AWS Transform for the identity split, and the service authorization reference when you scope a policy to specific transformations. Pricing and the definition of an agent minute are on the AWS Transform pricing page. The complete source code is available in the sample repository.

For more on what these transformations do, see AWS Transform custom: AI-driven Java modernization to reduce tech debt, Automating AWS SDK for Java v1 to v2 upgrades with AWS Transform, and Migrating your Java applications to AWS Graviton using AWS Transform custom.

 


About the authors

Hamza Alfarrash

Hamza Alfarrash

Hamza is an Associate Delivery Consultant with AWS Professional Services on the Application Modernization team. He applies AI to application modernization and helps insurance services customers bring legacy applications to AWS.

Salman Moghal

Salman Moghal

Salman is a Principal Consultant at AWS Professional Services Canada, specializing in agentic AI and cloud-native architecture. He architects and builds the AI systems that modernize operations across banking, insurance, and financial services.

Building an AEO scoring pipeline with AWS Lambda durable functions

Post Syndicated from Nick Beall original https://aws.amazon.com/blogs/compute/building-an-aeo-scoring-pipeline-with-aws-lambda-durable-functions/

When an AI agent crawls your documentation looking for answers, it doesn’t browse like a human. It scores structure, extracts entities, checks factual consistency, and moves on, often in under a second. If your content doesn’t surface clearly in those evaluations, it won’t appear in AI-generated answers regardless of its current search ranking. This problem is called Agent Engine Optimization (AEO), and solving it requires running multiple evaluation steps that are expensive, stateful, and failure-prone.

In this post, you build a five-step AEO scoring pipeline using AWS Lambda durable functions. Each step (fetch, score, analyze, publish, record) runs as a checkpointed operation that suspends without incurring additional costs between stages, retries without re-spending on prior work, and reads like ordinary Python. You score content against five AEO signals, see a real audit example, and walk away with a deployable AWS Serverless Application Model (AWS SAM) template.

A note on fetching: The pipeline uses a plain HTTP GET, mimicking how non-rendering AI crawlers (GPTBot, ClaudeBot, PerplexityBot) see pages today. Sites that rely on client-side JavaScript may score lower because most AI agents do not execute JavaScript.

Solution overview

The pipeline runs as a single Lambda durable function that chains five checkpointed steps. Scoring is fully deterministic (pure Python, no large language model (LLM)), so the same page consistently produces the same score. Amazon Bedrock generates prose analysis explaining the results. For production deployments that surface generated content to end users, consider adding Amazon Bedrock Guardrails for responsible AI controls. Because the function suspends between steps without incurring additional costs, a failure at step 3 resumes from step 3. The deterministic scores from step 2 are already checkpointed.

The following architecture diagram shows the five pipeline stages:

  1. Fetch – Retrieve the target page through an HTTP GET and extract structured signals (headings, token count, robots.txt rules, llms.txt, AGENTS.md).
  2. Score – Apply the deterministic rubric: threshold-based checks produce a reproducible 0–100 score with no LLM involvement.
  3. Analyze – Send the scores and raw signals to Amazon Bedrock, which generates prose findings and fix recommendations (temperature 0 for consistency).
  4. Publish – Render the audit report as HTML and JSON, write to Amazon Simple Storage Service (Amazon S3).
  5. Record – Persist the score and metadata to Amazon DynamoDB for trend tracking.
Architecture diagram of the AEO scoring pipeline showing five checkpointed Lambda durable function steps in sequence: fetch, score, analyze with Amazon Bedrock, publish to Amazon S3, and record to Amazon DynamoDB.

Figure 1: The five checkpointed stages of the AEO scoring pipeline, from fetch through record.

This gives you retry safety, cost predictability, and simpler debugging. The pipeline runs fully automated by default, with an optional callback gate for workflows requiring human approval.

Why Lambda durable functions

An AEO audit is five operations that depend on each other, any of which can fail mid-flight. Without checkpointing, a retry means re-running every step from the beginning, including Bedrock calls that already returned valid results.

Lambda durable functions solve this with checkpointed steps. Each step’s output is persisted before the next begins. When a Bedrock call gets throttled at step 3, only step 3 retries. The pipeline doesn’t re-run steps 1 and 2 or re-spend on their inference tokens.

Between steps, the function suspends with no environment held open, so you pay nothing while waiting. The pipeline reads as a single Python file, top to bottom. Each step’s checkpoint is independently inspectable, so debugging a failed run means looking at one step’s result rather than replaying the full execution log.

Prerequisites

A SAM template in the GitHub repository provisions the DynamoDB table, S3 bucket, Lambda durable function, and required IAM roles. You deploy the full stack with sam build && sam deploy --guided.

Estimated time: 20–30 minutes. Each audit run uses a single Bedrock inference call plus brief Lambda durable function and DynamoDB usage. See the AWS Pricing Calculator for estimates based on your Region and usage volume.

The five AEO signals

Scoring evaluates content against five signals drawn from Addy Osmani’s Agent Engine Optimization framework. Each signal maps to a capability an AI agent needs when deciding whether to use your content. Scoring is deterministic: every point maps to a measurable threshold (file exists, token count under ceiling, heading hierarchy valid). No LLM is involved in producing the number.

1. Discoverability (0–25)

This signal checks whether the URL returns server-rendered HTML, whether an llms.txt file exists at the domain root, and whether AI crawlers receive a successful response. A page returning HTTP 200 with parseable content passes. A page returning 403 or 429 to AI user agents fails.

2. Parsability (0–25)

This signal scores heading hierarchy (H1 through H6), presence of code blocks and tables, and whether the page has semantic landmarks. Because agents parse structurally rather than linearly, it’s essential for sites to be well organized and logical. Osmani suggests serving Markdown and HTML, leading each section with the outcome, and using tables for parameter references, among other tips that make your site easier to parse.

3. Token efficiency (0–25)

It measures total token count against a 30,000-token ceiling and a 15,000-token quick-start target. Pages bloated with navigation elements or repeated boilerplate score lower.

4. Capability signaling (0–15)

This signal checks for AGENTS.md or skill.md files describing available actions, required inputs, authentication, and rate limits. Without these, agents discover capabilities through trial and error.

5. Access control (0–10)

It checks robots.txt for AI-specific rules (GPTBot, ClaudeBot, PerplexityBot). Pages with explicit Allow or Disallow rules for AI user agents score higher than pages with no AI-specific rules, where access is open by default. Either choice is fine. The signal rewards making the decision on purpose.

Deterministic scores feed into the analyze step, where Bedrock generates prose explanations and actionable fix recommendations.

Example: auditing a real page

To see the pipeline in action, here are the results from a technical documentation page: a conceptual overview for a cloud computing service.

Target: A technical documentation page (conceptual overview, 958 words)

Overall score: 58/100

Signal Score Key finding
Discoverability 17/25 Server-rendered, llms.txt exists (but bloated at 295 KB / ~74K tokens compared to the recommended 5K token limit). No AGENTS.md.
Parsability 14/25 Invalid heading hierarchy (H1 to H6 to H2). No fenced code blocks or tables despite being technical documentation. 63.9% HTML noise ratio.
Token efficiency 25/25 1,245 tokens. Well under both the 15K quickstart target and 30K ceiling.
Capability signaling 0/15 No skill.md. Agents cannot discover what actions this documentation enables.
Access control 2/10 No AI-specific rules in robots.txt. Access is accidental rather than intentional.

Top three fixes:

  1. Create a skill.md declaring what agents can do with this service (available actions, required inputs, constraints).
  2. Fix the heading hierarchy to follow valid nesting (H1, H2, H3) and add fenced code blocks with language tags.
  3. Add an AGENTS.md at the domain root and explicit AI crawler rules to robots.txt.

The scoring is deterministic, and every point maps to a measurable condition. Amazon Bedrock then runs LLM inference on the results to generate the prose findings.

Under the hood: Steps and callbacks

Every step in the preceding audit, from fetch through record, runs as a checkpointed step. The callback is optional and sits after the analyze step, if you want a human to review the report before it’s published.

Lambda durable functions expose two core primitives: steps and callbacks. This pipeline runs fully automated by default (five steps, no human gate), but you can opt into a callback-based approval gate when the use case calls for it.

Steps

A step is a unit of work. The runtime checkpoints its return value before the next step begins. You declare one with the @step decorator:

@durable_step
def score_content(step_context: StepContext, page_content: str) -> dict:
    response = bedrock.invoke_model(
        modelId=os.environ["BEDROCK_MODEL_ID"],
        body=json.dumps({"prompt": build_scoring_prompt(page_content)})
    )
    return json.loads(response["body"].read())

If this step succeeds, its return value is persisted. On retry, completed steps are skipped and their cached results passed forward. Two rules:

  • Keep return values small. Return structured results, not raw API responses.
  • One side effect per step. A step that calls Bedrock and also writes to DynamoDB is harder to reason about on retry. Make one external call per step.

Callbacks

By default, the pipeline runs all five steps without stopping. To add a human review gate, turn on the callback. The function suspends without incurring additional costs until an external signal arrives. In practice, you wire this to a Slack bot or approval dashboard rather than a CLI command. Resumption happens through the SendDurableExecutionCallbackSuccess AWS API call with the callback ID. Because this is an authenticated API call, only principals with the appropriate IAM permissions can signal completion.

# In the handler (called on DurableContext, not inside a step):
callback = context.create_callback(name="review_report")
send_to_reviewer(callback.callback_id, report)

# Execution suspends here until the external system calls
# SendDurableExecutionCallbackSuccess with the callback ID.
result = callback.result()

The function suspends at callback.result() without incurring additional costs. The external system calls SendDurableExecutionCallbackSuccess with the callback ID and the approval payload. Timeouts raise TimeoutError for fallback routing.

When to add a callback

Run fully automated when… Add a callback when…
Scheduled audits or continuous integration and continuous delivery (CI/CD) regression monitoring High-stakes pages needing human sign-off
Reports feed directly into dashboards or alerts External workflow integration (Slack, Jira, webhook)
Fixes are suggestions, not auto-applied actions Downstream actions are gated (auto-filing tickets)

By default, the pipeline skips the callback entirely. To turn it on, set APPROVAL_GATE=true in the function’s environment variables.

Clean up

To avoid ongoing charges, delete the stack when you’re done experimenting:

sam delete --stack-name aeo-scoring-pipeline

This removes the Lambda durable function, DynamoDB table, S3 bucket (if empty), and the associated IAM roles. If your S3 bucket contains audit reports you want to keep, download them first or remove the DeletionPolicy: Delete setting from the template before deploying.

Conclusion

You now have a five-step AEO scoring pipeline in a single Lambda function that checkpoints between steps without incurring additional costs. Some steps involve LLM inference you don’t want to pay for twice. Durable checkpoints help make sure you do not.

To get started:

  1. Clone the repository: github.com/aws-samples/sample-aeo-scoring-pipeline
  2. Deploy with sam build && sam deploy --guided
  3. Run your first audit: aws lambda invoke --function-name AeoScoringPipeline --payload '{"url": "https://docs.aws.amazon.com/lambda/latest/dg/durable-functions.html"}'

Next steps: schedule audits using Amazon EventBridge, add Slack alerts when scores drop, or extend the rubric with domain-specific signals.

For more on Lambda durable functions, see the AWS Lambda durable functions Developer Guide. For the AEO framework, see Addy Osmani’s original post on agent-optimized content.