Introduction
Managing the data on a real estate platform at scale can be a very challenging task — from tracking properties and images to generating useful summaries for buyers and sellers. Traditional real estate applications use very simple architecture, without adapting to the newest AI available features.
That’s why I’ve decided to create a new real estate platform. The one where:
- you will learn how to batch data and lower the Lambda bill by up to 10x
- integration of AWS Rekognition and Bedrock
- learn how to create real-world architecture pattern that’s not beginner-level
This new platform will be full with AI features, powered by technologies like NextJS for the frontend and AWS for the backend. We’ll take this project apart, take a deep dive into all parts of this project, from CI/CD deployments, mock testing to deploying via Lambda aliases and canary deployments.
In this post, I’ll walk through a serverless, event-driven backend I built entirely with AWS CDK in Python, designed to create labels to describe the images of properties and summaries of properties based on those labels. The automatic image analysis is done by using Amazon Rekognition, and AI-generated property summaries with AWS Bedrock. The system leverages S3, Lambda, SQS and DynamoDB to create a scalable, cost-efficient, and resilient solution that can process thousands of images and generate real-time insights — all without managing servers.
By the end of this post, you’ll understand how to design modular serverless pipelines, implement event-driven workflows, and integrate AI-powered summarization into your own applications.
All code can be found at the GitHub repository by clicking on the this link.
Let’s start!
Architecture Overview of AWS Rekognition Image Labeling
Here is an architecture diagram, which executes when the user uploads an image of the property to the pre-signed URL provided by our property service for creating an property inside our system.
When the image is fully uploaded to S3, S3 sends an Event Notification to SQS with the information about the uploaded image.
Now, imagine receiving 100 images and not having them batched. That would mean that the system needs to spin up 100 Lambdas, but if we configure the batching setting to 10 that would mean 10 times less than the invocations which lowers cost.
That’s why the backend is configured to batch up to 10 Event Notifications inside the queue, and those 10 messages are sent to the land to lower cold starts and to lower cost. It’s worth mentioning that this setting can be changed on your project’s requirements.
The Lambda takes each of the image and send it to AWS Rekognition for it to do the image analysis, to create labels based on the image. Also, in the payload we ask Rekognition to only return labels in which it’s 75% confident. After the labels are received, they are saved inside the DynamoDB database for that particular image
Code Deep-Dive - AWS Rekognition Label Detection
Let's take a closer look into the configuration of this pipeline and how can you do it yourself. This section will only contain snippets, but you can take a closer look into the app.py file which can be found on GitHub.
Here is the CDK code for initializing the Lambda resource, the SQS queue, the DynamoDB table and the S3 bucket:
# S3 bucket for property images
self.property_bucket = s3.Bucket(
self, "PropertyBucket",
versioned=True,
removal_policy=RemovalPolicy.DESTROY,
auto_delete_objects=True,
public_read_access=False,
block_public_access=s3.BlockPublicAccess(
block_public_acls=False,
block_public_policy=False,
ignore_public_acls=False,
restrict_public_buckets=False
),
cors=[
s3.CorsRule(
allowed_headers=["*"],
allowed_methods=[
s3.HttpMethods.PUT,
s3.HttpMethods.POST,
s3.HttpMethods.DELETE,
s3.HttpMethods.GET
],
allowed_origins=["*"],
max_age=3000
)
]
)
# Property Images Table - stores individual image metadata and analysis
self.property_images_table = dynamodb.Table(
self, "PropertyImagesTable",
partition_key=dynamodb.Attribute(name="PK", type=dynamodb.AttributeType.STRING),
sort_key=dynamodb.Attribute(name="SK", type=dynamodb.AttributeType.STRING),
billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST,
removal_policy=RemovalPolicy.DESTROY,
)
# SQS Queue for image upload events - batches multiple uploads
image_queue = sqs.Queue(
self, "ImageAnalysisQueue",
visibility_timeout=Duration.seconds(360),
retention_period=Duration.days(14),
removal_policy=RemovalPolicy.DESTROY
)
# Image Analysis Lambda - Triggered by SQS with batching
image_analysis_code_dir = "./ImageAnalysisHandler"
image_analysis_fn = _lambda.Function(
self, "ImageAnalysisLambda",
runtime=_lambda.Runtime.PYTHON_3_12,
handler="handler.handler",
code=_lambda.Code.from_asset(image_analysis_code_dir),
environment={
"PROPERTY_IMAGES_TABLE": self.property_images_table.table_name,
"PROPERTY_BUCKET": self.property_bucket.bucket_name,
"ALLOWED_ORIGIN": "*",
"CODE_VERSION": image_code_hash
},
timeout=Duration.seconds(60)
)
Also, we need to add the necessary IAM permissions to the Lambda to have read-only access to images inside S3, write-only permissions to the PropertyImagesTable , and to be able to call the AWS Rekognition API:
# Enabling read-only access to S3
self.property_bucket.grant_read(image_analysis_fn)
# Enabling write-only access to the PropertyImages Table
self.property_images_table.grant_write_data(image_analysis_fn)
# Enabling the Lambda to be able to call the Rekognition
# service to detect labels
image_analysis_fn.add_to_role_policy(
iam.PolicyStatement(
actions=["rekognition:DetectLabels"],
resources=["*"]
)
)
Now, we need to tell our CDK code to:
- create an Event Notification on image upload to S3
- send that Event Notification for the particular image to SQS
- tell SQS queue to invoke our
ImageAnalysisHandlerLambda with 10 batched notifications at most
We can do that with the following code:
# S3 Event Notification
self.property_bucket.add_event_notification(
s3.EventType.OBJECT_CREATED, # Create the notification only when an object is uploaded
s3n.SqsDestination(image_queue), # Put the notification inside the SQS queue
s3.NotificationKeyFilter(prefix="properties/") # Only send the Notificaiton if it's uploaded to the "properties" folder
)
# Lambda SQS Event Source
image_analysis_fn.add_event_source(
lambda_event_sources.SqsEventSource(
image_queue, # Take payloads from the SQS queue
batch_size=10, # Batch up to 10 payloads to reduce cost
max_batching_window=Duration.seconds(2), # Wait for 2 seconds to fill up the batch
report_batch_item_failures=True,
)
)
Now that we’ve created our infrastructure, let’s take a look at the main method inside our Lambda which takes the images from S3 and gets the labels from Rekognition. As previously said, if you need the full Lambda code, it is available on the provided GitHub link.
What makes using Rekognition great is that you can tell it the image location inside the created S3 bucket and it’s going to do everything else for you - no extra payload fields!
I’ve configured my payload to make Rekognition return 5 labels at most and it needs to be at least 75% confident in it’s label recognition.
def analyze_image_with_rekognition(bucket_name: str, object_key: str) -> dict:
"""
Use AWS Rekognition to detect labels only
Returns:
dict: Analysis results with labels
"""
analysis = {
"labels": []
}
try:
# Detect labels
logger.info("Detecting labels")
labels_response = rekognition_client.detect_labels(
Image={
'S3Object': {
'Bucket': bucket_name,
'Name': object_key
}
},
MaxLabels=5,
MinConfidence=75
)
analysis['labels'] = [
{
'name': label['Name'],
'confidence': round(label['Confidence'], 2)
}
for label in labels_response.get('Labels', [])
]
logger.info(f"Detected {len(analysis['labels'])} labels")
except Exception as e:
logger.error("Failed to detect labels", extra={"error": str(e)})
return analysis
Lastly, the Lambda saves the results to the PropertyImagesTable DynamoDB table via the PynamoDB model:
def save_image_analysis(property_id: str, image_name: str, image_key: str, analysis: dict):
"""
Save image metadata and analysis results to PropertyImageTable using PropertyImageModel.
"""
try:
# Create image record using Pynamodb model
image_item = PropertyImageModel.create_image(
property_id=property_id,
image_name=image_name,
image_key=image_key,
labels=analysis.get('labels', []),
analyzedAt=datetime.now()
)
# Save to DynamoDB
image_item.save()
logger.info("Image analysis saved to PropertyImageTable", extra={
"property_id": property_id,
"image_name": image_name,
"image_key": image_key,
"labels_count": len(analysis.get('labels', []))
})
except Exception as e:
logger.error("Failed to save image analysis", extra={
"error": str(e),
"property_id": property_id,
"image_name": image_name,
"image_key": image_key
}, exc_info=True)
Architecture Overview - AWS Bedrock Property Summarization
Here is a simple overview of the Summarization part:
The Lambda is available to the user via API Gateway, and when the user calls the URL, Lambda fetches all images from the PropertyImagesTable for the requested property and sends those labels to an AWS Bedrock model to get the summary.
We are using the amazon.nova-micro-v1:0 model, which is the cheapest model I found inside the model repository and it got the job done!
It’s worth mentioning that we won’t be opening this Lambda up to the Internet for now - this part of the backend is a part of the bigger project which will be covered in next blog posts and that’s why this part may seem simple! The following code will be enough to get you off the ground in any Bedrock integration!
Code Deep-Dive - AWS Bedrock Property Summarization
Here is the CDK code for generating necessary resources and adding necessary IAM permissions for accessing the Bedrock model:
# Add Bedrock permissions to the role
bedrock_lambda_role.add_to_policy(
iam.PolicyStatement(
effect=iam.Effect.ALLOW,
actions=[
"bedrock:InvokeModel",
],
resources=[
f"arn:aws:bedrock:*:{self.account}:inference-profile/eu.amazon.nova-micro-v1:0",
"arn:aws:bedrock:*::foundation-model/amazon.nova-micro-v1:0"
],
)
)
# Bedrock Summary Generation Lambda
summary_generation_code_dir = "./GetPropertySummaryHandler"
summary_generation_fn = _lambda.Function(
self, "SummaryGenerationLambda",
runtime=_lambda.Runtime.PYTHON_3_12,
handler="handler.handler",
code=_lambda.Code.from_asset(summary_generation_code_dir),
role=bedrock_lambda_role,
environment={
"PROPERTY_IMAGES_TABLE": self.property_images_table.table_name,
"BEDROCK_MODEL_ID": "eu.amazon.nova-micro-v1:0",
"ALLOWED_ORIGIN": "*",
},
timeout=Duration.seconds(30)
)
# Giving the SummaryGenerationLambda permissions to
# look into the DynamoDB tables
self.property_images_table.grant_read_data(summary_generation_fn)
Now, the Lambda code - to be completely honest, I expected this part to be way harder to configure, but it was really easy!
Building the request_body JSON was interesting, because inside the request_body.system.text , you can define how to customize the response of the model. The model was instructed to act as following:
You are an experienced real estate agent creating property listings. Write concise, appealing, and professional property descriptions that highlight key features and amenities based on the labels provided. Keep descriptions brief (up to 10 sentences) and focus on what makes the property attractive to potential buyers or renters. Use descriptive language but remain factual based on the provided information.
You can also configure the maximum response length via a field max_new_tokens which was set to 200 to keep the costs as low as possible. In addition, configuration fields like temperature and top_p were used - for more information about this, please see this blog post by clicking on this link.
Here is the code to get the response from the Bedrock model:
# Initialize AWS clients
BEDROCK_CLIENT = client("bedrock-runtime")
BEDROCK_MODEL_ID = os.environ.get("BEDROCK_MODEL_ID")
# Fetch labels from PropertyImagesTable using model
images = PropertyImageModel.get_property_images(property_id)
# Extract all unique labels from all images
all_labels = []
for image_item in images:
for label in image_item.labels or []:
if isinstance(label, dict):
label_name = label.get('name', '')
if label_name:
all_labels.append(label_name)
elif isinstance(label, str):
all_labels.append(label)
property_labels_str = ", ".join(set(all_labels))
request_body = {
"system": [
{
"text": "You are an experienced real estate agent creating property listings. Write concise, appealing, and professional property descriptions that highlight key features and amenities based on the labels provided. Keep descriptions brief (up to 10 sentences) and focus on what makes the property attractive to potential buyers or renters. Use descriptive language but remain factual based on the provided information."
}
],
"messages": [
{
"role": "user",
"content": [
{
"text": property_labels_str
}
]
}
],
"inferenceConfig": {
"max_new_tokens": 200,
"temperature": 0.7,
"top_p": 0.9
}
}
response = BEDROCK_CLIENT.invoke_model(
modelId=BEDROCK_MODEL_ID,
contentType="application/json",
accept="application/json",
body=json.dumps(request_body)
)
Conclusion
This architecture cuts image-processing costs, improves backend reliability and scales with ease.
By combining AWS services like S3, Lambda, Rekognition and Bedrock, with the power of AWS CDK as the Infrastructure-as-Code solution, you’ve learned how to create an AI powered pipeline, which can be applied to ANY project.
In the following post, we will go over the rest of the Property stack, which is responsible for CRUD operations with best practices and best performance for fetching images from S3 and data from DynamoDB tables.
Thank you for reading, see you in the next post!


Top comments (0)