Logo
Jaime Elso

Arquitecto de Soluciones AWS

EN

Despliegue automatizado de páginas web estáticas desde un repositorio a un bucket S3 en AWS

Actualización: AWS dejó de ofrecer CodeCommit a nuevos clientes en julio de 2024. Mantengo este post como registro histórico del pipeline que construí; si estás empezando un proyecto nuevo hoy, plantéate usar GitHub como repositorio de origen en su lugar.

En este artículo presento el despliegue automatizado de páginas web estáticas desde un repositorio git a un bucket S3 en AWS. La implementación es segura, escalable, optimizada, y tiene despliegue automatizado desde un repositorio git en CodeCommit. Uso una plantilla de CloudFormation para desplegar la infraestructura en AWS, y una función Lambda para sincronizar el repositorio con el bucket S3. Consulta el repositorio de GitHub del proyecto.

Diagrama de hosting de MyWebsite

Alojar una página web estática en AWS

Para alojar una página web estática en AWS, Amazon S3 es el servicio de referencia, con la opción de habilitar el bucket como página web estática. Para mejorar el rendimiento de la web, despliego una distribución de Amazon CloudFront con el bucket S3 como origen. Sin embargo, subir los cambios nuevos a la web manualmente en S3 era un problema, así que busqué una solución automatizada que actualizara el código en el repositorio git y lo sincronizara con el bucket S3 en cada commit.

Hice un primer intento configurando un AWS CodePipeline con una regla de Amazon EventBridge que se disparaba cada vez que se hacía un commit nuevo en el repositorio. Sin embargo, esta solución tenía dos inconvenientes: subía todos los ficheros del repositorio, se hubieran modificado o no, y no eliminaba los ficheros borrados del bucket S3.

AWS Amplify Hosting era otra solución posible, pero no era ideal porque reduce la visibilidad y el control del usuario sobre los recursos usados para el hosting web. La solución final que mejor funcionó fue invocar una función de AWS Lambda en cada commit nuevo del repositorio, que analizaba los cambios y los sincronizaba con el bucket S3.

Un bucket de Amazon S3 como almacenamiento de logs

Para llevar un registro y trazabilidad de todas las llamadas hechas a los ficheros de mi web (incluidas las llamadas de internet y las acciones de la función Lambda de sincronización), uso un bucket de Amazon S3 para almacenar estos logs.

BucketLogs:
	Type: AWS::S3::Bucket
	Properties:
		BucketName: !Sub '${DomainName}-logs'
		AccessControl: LogDeliveryWrite
		# Encryption configuration with S3 managed keys
		BucketEncryption:
			ServerSideEncryptionConfiguration:
				- ServerSideEncryptionByDefault:
						SSEAlgorithm: AES256
		# Lifecycle configuration to expire logs after days defined in LogRetention parameter
		LifecycleConfiguration:
		Rules:
			- Id: DeleteLogsAfterTwoMonths
				Status: Enabled
				Prefix: hosting/
				ExpirationInDays: !Ref LogRetention

Para asegurar el bucket, lo cifré con las claves KMS por defecto del servicio S3 y fijé el AccessControl a LogDeliveryWrite. Esto concede permisos de escritura a los ficheros de log generados por servicios de AWS con esa política. Para evitar que los logs se almacenen indefinidamente y ocupen demasiado espacio en el bucket con información irrelevante, implementé un lifecycle que elimina los logs automáticamente pasados dos meses.

Un bucket de Amazon S3 como hosting web

Usar un servicio de almacenamiento de objetos como hosting para mi web estática es una elección interesante porque no necesito ejecución en el servidor. El servicio simplemente devuelve los ficheros tal cual al navegador del usuario cuando los pide.

Bucket:
	Type: AWS::S3::Bucket
	Properties:
		BucketName: !Ref DomainName
		# Encryption configuration with S3 managed keys
		BucketEncryption:
			ServerSideEncryptionConfiguration:
				- ServerSideEncryptionByDefault:
						SSEAlgorithm: AES256
		# Logs configuration
		LoggingConfiguration:
			DestinationBucketName: !Ref BucketLogs
			LogFilePrefix: hosting/

Las únicas particularidades al configurar este bucket son cifrarlo con las claves KMS por defecto de S3 y habilitar los logs de acceso del servidor, indicando el bucket de destino creado en el paso anterior.

Política del bucket

El bucket S3 no es accesible por defecto, lo que significa que no puedo servir el contenido de mi web a las peticiones entrantes. Para resolver esto, hace falta establecer una política de bucket que conceda acceso de lectura a la distribución de CloudFront que voy a desplegar a continuación.

BucketPolicy:
	Type: AWS::S3::BucketPolicy
	Properties:
		Bucket: !Ref Bucket
		PolicyDocument:
		Version: '2012-10-17'
		Statement:
			- Action:
				- s3:GetObject
			Effect: Allow
			Resource: !Sub '${Bucket.Arn}/*'
			Principal:
				CanonicalUser: !GetAtt CloudFrontOriginAccessIdentity.S3CanonicalUserId

Amazon CloudFront para una entrega de contenido segura y rápida

Usar solo Amazon S3 no me da las herramientas necesarias para configurar aspectos fundamentales de mi web, como un dominio personalizado o HTTPS. Por suerte, Amazon CloudFront puede ayudarme con esto. Para usar un dominio personalizado en mi distribución de CloudFront y servir contenido de forma segura por HTTPS, es imprescindible tener un certificado SSL desplegado en la región de Norte de Virginia (us-east-1) mediante AWS Certificate Manager.

CloudFront:
	Type: AWS::CloudFront::Distribution
	Properties:
		DistributionConfig:
		# Custom domain name
		Aliases:
			- !Ref DomainName
		# A name for the distribution
		Comment: !Sub 'CloudFront distribution for ${DomainName}'
		DefaultCacheBehavior:
			# Content will be compressed before it is cached (gzip), unless specifically instructed otherwise
			Compress: true
			# Cache content for 1 day
			DefaultTTL: 86400
			# Pass query strings to the origin
			ForwardedValues:
			QueryString: true
			# Max cache content for 1 year
			MaxTTL: 31536000
			# Only allow HTTPS
			ViewerProtocolPolicy: redirect-to-https
			# Set the response headers policy
			ResponseHeadersPolicyId: !Ref ResponseHeadersPolicy
			TargetOriginId: !Sub 'S3Bucket-${AWS::StackName}'
		DefaultRootObject: !Ref RootDocumentPath
		CustomErrorResponses:
			- ErrorCachingMinTTL: 300
				ErrorCode: 404
				ResponseCode: 200
				ResponsePagePath: !Ref ErrorDocumentPath
			- ErrorCachingMinTTL: 300
				ErrorCode: 403
				ResponseCode: 200
				ResponsePagePath: !Ref ErrorDocumentPath
		IPV6Enabled: true
		Enabled: true
		HttpVersion: http2and3
		# Set the origin to the S3 bucket and specify the origin access identity
		Origins:
			- DomainName: !GetAtt Bucket.DomainName
				Id: !Sub 'S3Bucket-${AWS::StackName}'
				S3OriginConfig:
					OriginAccessIdentity:
						!Join ['', ['origin-access-identity/cloudfront/', !Ref CloudFrontOriginAccessIdentity]]
		# Allow CloudFront to use the all edge location
		PriceClass: 'PriceClass_All'
		# Set the certificate
		ViewerCertificate:
			AcmCertificateArn: !Ref CertificateArn
			MinimumProtocolVersion: 'TLSv1.1_2016'
			SslSupportMethod: 'sni-only'

Incorporar un servicio de red de distribución de contenidos (CDN) por encima de mi hosting web mejora la velocidad de entrega de contenido a los usuarios finales a escala global. AWS tiene muchas edge locations que cachean los ficheros estáticos de mi web. Como resultado, cuando un usuario visita la página, el contenido se recupera desde la edge location más cercana a su ubicación, evitando tener que volver al origen. Dentro de mi distribución, establezco el fichero raíz de mi web y detallo la ruta para redirigir peticiones en caso de errores 403 y 404.

Política de cabeceras de respuesta

CloudFront permite configurar cabeceras personalizadas en la respuesta HTTP. En este caso, he establecido varias políticas para prevenir posibles ataques de cross-site scripting (XSS), ataques de inyección de scripts, y ejecución de código malicioso, entre otras vulnerabilidades de seguridad.

ResponseHeadersPolicy:
	Type: AWS::CloudFront::ResponseHeadersPolicy
	Properties:
		ResponseHeadersPolicyConfig:
		# A name for the ResponseHeadersPolicy
		Name: !Sub "${AWS::StackName}-static-site-security-headers"
		# Specifies the security headers configuration
		SecurityHeadersConfig:
			# Specifies the Strict Transport Security (HSTS) header
			StrictTransportSecurity:
				# Specifies the maximum age (in seconds) for which the browser should cache the HSTS policy
				AccessControlMaxAgeSec: 63072000
				# Indicates whether the HSTS policy should apply to all subdomains
				IncludeSubdomains: true
				# Specifies whether to override an existing HSTS policy
				Override: true
				# Specifies whether to preload the HSTS policy in supported browsers
				Preload: true
			# Specifies the Content Security Policy (CSP) header
			ContentSecurityPolicy:
				# Specifies the CSP header value
				ContentSecurityPolicy: !Ref CSPHeader
				# Specifies whether to override an existing CSP policy
				Override: true
			# Specifies the X-Content-Type-Options header
			ContentTypeOptions:
				# Specifies whether to override an existing X-Content-Type-Options policy
				Override: true
			# Specifies the X-Frame-Options header
			FrameOptions:
				# Specifies the value of the X-Frame-Options header
				FrameOption: DENY
				# Specifies whether to override an existing X-Frame-Options policy
				Override: true
			# Specifies the Referrer-Policy header
			ReferrerPolicy:
				# Specifies the value of the Referrer-Policy header
				ReferrerPolicy: "same-origin"
				# Specifies whether to override an existing Referrer-Policy policy
				Override: true
			# Specifies the X-XSS-Protection header
			XSSProtection:
				# Specifies whether to block pages from loading when they detect reflected cross-site scripting (XSS) attacks
				ModeBlock: true
				# Specifies whether to override an existing X-XSS-Protection policy
				Override: true
				# Specifies whether to enable the XSS Protection policy
				Protection: true

Origin Access Identity (OAI)

CloudFront Origin Access Identity (OAI) es un método para autenticar y autorizar peticiones entre Amazon CloudFront y un recurso de origen de Amazon S3. CloudFront OAI permite crear una identidad de acceso de origen que actúa como intermediaria entre CloudFront y S3. CloudFront usa esta identidad de acceso para solicitar objetos de S3 en nombre de los usuarios, lo que significa que los objetos ya no son accesibles directamente en S3, sino solo a través de CloudFront. Esto ofrece mayor control y seguridad sobre el acceso a los objetos almacenados en S3 y ayuda a proteger frente a posibles ataques.

CloudFrontOriginAccessIdentity:
	Type: AWS::CloudFront::CloudFrontOriginAccessIdentity
	Properties:
		CloudFrontOriginAccessIdentityConfig:
			Comment: !Sub 'CloudFront OAI for ${DomainName}'

AWS CodeCommit como repositorio Git para el código de mi web

Para mantener el control sobre el código que escribo para mi web, hace falta tenerlo subido a un repositorio Git. Aprovechando que trabajo en AWS, uso AWS CodeCommit para esto. Este repositorio será el punto de partida para subir mi código a producción cada vez que se haga un commit nuevo en mi repositorio. Para detectar cuándo ocurre ese commit, configuro un trigger en CodeCommit que reacciona a los commits nuevos en una rama concreta. Este trigger invoca de forma asíncrona una función lambda.

Repository:
	Type: AWS::CodeCommit::Repository
	Properties:
		RepositoryName: MyWebsite
		RepositoryDescription: CodeCommit repository for MyWebsite
		# For each new commit on the master branch, a new deployment on S3 will be triggered through a Lambda function
		Triggers:
			- Events:
					- updateReference
				DestinationArn: !GetAtt SyncCodeCommitWithS3Function.Arn
				Name: SyncCodeCommitWithS3Trigger
				Branches:
					- !Ref BranchName

Amazon SNS para notificar errores de sincronización

Como mi función Lambda se ejecuta de forma asíncrona cada vez que se hace un commit nuevo en el repositorio, no estoy monitorizando el resultado de esa ejecución. Por eso es importante que me notifiquen cuando, por cualquier motivo, la función Lambda falle al realizar su tarea. Para recibir estas notificaciones, creo un nuevo topic de Amazon SNS.

SyncCodeCommitWithS3Topic:
	Type: AWS::SNS::Topic
	Properties:
		TopicName: SyncCodeCommitWithS3
		DisplayName: SyncCodeCommitWithS3

Una vez creado el topic, hace falta suscribirse a él. En mi caso, quiero recibir esas notificaciones de fallo por email. Creo una nueva suscripción usando email como protocolo, añadiendo mi dirección de correo.

SyncCodeCommitWithS3EmailSubscription:
	Type: AWS::SNS::Subscription
	Properties:
		TopicArn: !Ref SyncCodeCommitWithS3Topic
		Protocol: email
		Endpoint: !Ref SubscriptionEndpoint

Usar AWS Lambda para sincronizar un repositorio con un bucket

El objetivo de mi función Lambda, una vez invocada de forma asíncrona por el trigger de CodeCommit, es obtener la información contenida en el commit nuevo, diferenciando entre ficheros que se han modificado o añadido y ficheros que se han eliminado. Los ficheros modificados o añadidos se suben al bucket S3, mientras que los ficheros eliminados en el repositorio también se eliminan del bucket. Una vez hecha esta sincronización, creo una invalidación de caché en CloudFront para los ficheros afectados por este commit nuevo. Así fuerzo a la CDN a actualizar el contenido de su caché con las versiones nuevas de los ficheros en S3.

Despliegue de la función Lambda

Antes de pasar al código, primero echo un vistazo a cómo configurar la función en AWS. Para asegurar que mi función Lambda pueda ejecutar ciertas acciones con otros servicios de AWS con los que necesita interactuar, es importante asignarle un rol con permisos suficientes. Por eso configuro una política de permisos restrictiva que le concede solo los permisos necesarios para completar su tarea correctamente.

SyncCodeCommitWithS3FunctionRole:
	Type: AWS::IAM::Role
	Properties:
		RoleName: SyncCodeCommitWithS3FunctionRole
		Description: Lambda Role to perform logs and sync file from CodeCommit With a S3 bucket and invalidates the CloudFront distribution cache for that files.
		AssumeRolePolicyDocument:
		Version: "2012-10-17"
		Statement:
			- Effect: Allow
				Principal:
				Service:
					- lambda.amazonaws.com
				Action:
					- 'sts:AssumeRole'

SyncCodeCommitWithS3FunctionRolePolicy:
	Type: AWS::IAM::Policy
	Properties:
	PolicyName: SyncCodeCommitWithS3FunctionRolePolicy
	PolicyDocument:
		Version: '2012-10-17'
		Statement:
			- Effect: Allow
				Action:
					- logs:CreateLogGroup
				Resource: !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*"
			- Effect: Allow
				Action:
					- logs:CreateLogStream
					- logs:PutLogEvents
				Resource: !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/${SyncCodeCommitWithS3Function}:*"
			- Effect: Allow
				Action:
					- codecommit:GetCommit
					- codecommit:GetDifferences
					- codecommit:GetFile
				Resource: !GetAtt Repository.Arn
			- Effect: Allow
				Action:
					- s3:PutObject
					- s3:DeleteObject
				Resource: !Sub '${Bucket.Arn}/*'
			- Effect: Allow
				Action:
					- cloudfront:CreateInvalidation
				Resource: !Sub 'arn:aws:cloudfront::${AWS::AccountId}:distribution/${CloudFront}'
			- Effect: Allow
				Action:
				- sns:Publish
				Resource: !Ref SyncCodeCommitWithS3Topic
		Roles:
			- !Ref SyncCodeCommitWithS3FunctionRole

Para asegurar que el trigger que configuré en CodeCommit pueda invocar la función correctamente, es importante recordar que AWS Lambda no permite que CodeCommit ejecute directamente la función usando un rol con permisos. En su lugar, hace falta asignar a la función una política de recursos que autorice a CodeCommit a ejecutarla de forma segura.

SyncCodeCommitWithS3FunctionResourcePolicy:
	Type: AWS::Lambda::Permission
	Properties:
		Action: lambda:InvokeFunction
		FunctionName: !Ref SyncCodeCommitWithS3Function
		Principal: codecommit.amazonaws.com
		SourceArn: !GetAtt Repository.Arn

Una vez establecidos todos los permisos, despliego mi función Lambda. Aunque normalmente me siento más cómodo programando en JavaScript, en este caso he desarrollado la función en Python, concretamente en su versión 3.10, el runtime más actualizado que puedo usar hoy en AWS.

El parámetro timeout es crucial en mi función, ya que necesitará más o menos tiempo de ejecución según el número de ficheros modificados en mi último commit. Mi consejo es que cada persona adapte este parámetro a sus necesidades concretas. El código de la función debe estar comprimido en formato ZIP y subido a un bucket S3 para que CloudFormation pueda acceder al código al desplegarlo.

SyncCodeCommitWithS3Function:
	Type: AWS::Lambda::Function
	Properties:
		FunctionName: SyncCodeCommitWithS3
		Description: Function that sync file from CodeCommit With a S3 bucket and invalidates the CloudFront distribution cache for that files.
		Runtime: python3.10
		Architectures:
			- x86_64
		MemorySize: 128
		# Timeout in seconds. Increase if you have commits with a lot of files to sync
		Timeout: !Ref LambdaTimeout
		# Place Lambda function code in S3 and reference it here. Zip file must contain the index.py file
		Code:
			S3Bucket: !Ref LambdaS3Bucket
			S3Key: !Ref LambdaS3Key
		PackageType: Zip
		Handler: index.lambda_handler
		Role: !GetAtt SyncCodeCommitWithS3FunctionRole.Arn
		# Define environment variables to know the bucket name and the CloudFront distribution ID
		Environment:
			Variables:
				bucketName: !Ref Bucket
				distributionId: !Ref CloudFront
				topicArn: !Ref SyncCodeCommitWithS3Topic

Para que mi función Lambda sepa qué bucket sincronizar, a qué topic de SNS enviar las notificaciones de error, y qué distribución de CloudFront usar para las invalidaciones, defino tres variables de entorno con esta información, para que el código pueda acceder a ellas durante el despliegue.

Código de la función Lambda

Para empezar mi función, importo el AWS SDK para interactuar con otros servicios de AWS desde mi código. Además, importo la librería time para obtener la hora actual y asignarla a una referencia, y la librería os para recuperar los valores de mis variables de entorno. Este código se ejecuta solo cuando mi función Lambda se despliega e inicia, así que los valores de las variables declaradas en este punto no se vuelven a declarar ni reasignar cada vez que se invoca la función. Por eso, para hacer mi código más eficiente, inicializo los distintos clientes del AWS SDK que voy a usar y fijo los valores obtenidos de las variables de entorno. Por último, declaro un diccionario que contiene todos los tipos MIME existentes, para poder recuperar más adelante en mi código el tipo MIME según la extensión del fichero.

# Boto3 is the official AWS library for Python
import boto3
# Import the time library to get the current time
import time
# Import the os library to get environment variables
import os

# Create an instance of the CodeCommit, S3, SNS and CloudFront client
codecommit = boto3.client('codecommit')
s3 = boto3.client('s3')
cloudfront = boto3.client('cloudfront')
sns = boto3.client('sns')

# Get the bucket name and distribution id from the environment variables
try:
	bucketName = os.environ['bucketName']
	distributionId = os.environ['distributionId']
	topicArn = os.environ['topicArn']
except Exception as e:
	raise Exception('Missing environment variable: ' + str(e))

# All the possible MIME types
contentTypes = {
	'3dm': 'x-world/x-3dmf',
	'3dmf': 'x-world/x-3dmf',
	'3g2': 'video/3gpp2',
	...
}

Una vez listo el código que se ejecuta al arrancar mi función, toca desarrollar el handler. El handler es la función de Python que se ejecuta cada vez que se invoca la función Lambda.

Mi handler recibe el event como argumento. Esta variable contiene un diccionario con información sobre el evento que invoca la función. De este diccionario obtengo datos como el nombre del repositorio y el ID del commit realizado. Con estos dos datos, y usando la llamada a la API de CodeCommit get_commit, también puedo recuperar el ID del commit anterior al último.

La llamada a la API de CodeCommit get_differences, al darle dos IDs de commit y un nombre de repositorio, devuelve un array con las diferencias encontradas entre ambos commits. Esto me permite comprobar qué cambios se han hecho en el último commit y, por tanto, qué acciones necesito tomar para sincronizar el bucket con el repositorio.

Usando list comprehensions, relleno arrays diferenciando entre ficheros actualizados o añadidos y ficheros eliminados, y los recorro para realizar acciones. En el caso de actualizar o crear un fichero nuevo, lo recupero del repositorio. Como se devuelve en base64 y pierde sus metadatos, uso su extensión para determinar el tipo MIME correspondiente. Además, compruebo si el fichero es HTML y, si lo es, elimino la extensión para que no aparezca en la URL. Por último, uso la API del cliente de S3 para subir el fichero al bucket.

En el caso de necesitar eliminar un fichero, quito la extensión HTML si la tiene y añado la ruta al array de invalidación de caché. Hago esto también para cada fichero que subo al bucket en el paso anterior. Una vez hecho esto, elimino el fichero del bucket S3 usando la llamada a la API. Después de haber eliminado y subido los ficheros correspondientes, creo una invalidación de caché en CloudFront para forzar a la CDN a actualizar estos ficheros y no servir contenido desactualizado.

# Handler function
def lambda_handler(event, context):

	# Get the repository name and the id of the last commit made
	repoName = event['Records'][0]['eventSourceARN'].split(':')[-1]
	lastCommitId = event['Records'][0]['codecommit']['references'][0]['commit']

	try:
		# Get commit information to get the id of the commit prior to the last commit made
		commitInfo = codecommit.get_commit(
			repositoryName = repoName,
			commitId = lastCommitId
		)
	except Exception as e:
		sns.publish(TopicArn = topicArn, Message = 'Error getting commit information: ' + str(e), Subject = 'Sync Error')
		return {
			'statusCode': 500,
			'body': 'Error getting commit information: ' + str(e)
		}

	# Get the id of the commit prior to the last commit made
	parentCommitId = commitInfo['commit']['parents'][0] if commitInfo['commit']['parents'] else None

	try:
		# Get the changes that have occurred in the last commit made
		if parentCommitId:
			commitChanges = codecommit.get_differences(
				repositoryName = repoName,
				afterCommitSpecifier = lastCommitId,
				beforeCommitSpecifier = parentCommitId
			)
		else:
			commitChanges = codecommit.get_differences(
				repositoryName = repoName,
				afterCommitSpecifier = lastCommitId
			)
	except Exception as e:
		sns.publish(TopicArn = topicArn, Message = 'Error getting commit changes: ' + str(e), Subject = 'Sync Error')
		return {
			'statusCode': 500,
			'body': 'Error getting commit changes: ' + str(e)
		}

	# Declare three arrays to group the updated, deleted and invalidated files| Populate the arrays using list comprehension
	updatedFiles = [difference['afterBlob']['path'] for difference in commitChanges['differences'] if difference.get('afterBlob') and difference['afterBlob'].get('path')]
	deletedFiles = [difference['beforeBlob']['path'] for difference in commitChanges['differences'] if difference.get('beforeBlob') and difference['beforeBlob'].get('path') and difference['beforeBlob']['path'] not in updatedFiles]
	invalidateFiles = []

	# Iterate over the updated files and upload them to the S3 bucket
	for filePath in updatedFiles:
		try:
			# Get the file content from CodeCommit
			codecommitFile = codecommit.get_file(
				repositoryName = repoName,
				commitSpecifier = lastCommitId,
				filePath = filePath
			)
		except Exception as e:
			sns.publish(TopicArn = topicArn, Message = 'Error getting file content from CodeCommit: ' + str(e), Subject = 'Sync Error')
			return {
				'statusCode': 500,
				'body': 'Error getting file content from CodeCommit: ' + str(e)
			}

		# Set Content-Type based on file extension
		contentType = contentTypes.get(filePath.split('.')[-1], 'text/plain')
		# For html files, remove de html extension
		filePath = filePath.replace('.html', '')
		# Add the file to the list of files to invalidate
		invalidateFiles.append('/' + filePath)

		try:
			# Use the put_object method of S3 to upload the file to the bucket
			s3.put_object(Bucket = bucketName, Key = filePath, Body = codecommitFile['fileContent'], ContentType = contentType)
		except Exception as e:
			sns.publish(TopicArn = topicArn, Message = 'Error uploading file to S3: ' + str(e), Subject = 'Sync Error')
			return {
				'statusCode': 500,
				'body': 'Error uploading file to S3: ' + str(e)
			}

	# Iterate over the deleted files and delete them from the S3 bucket
	for filePath in deletedFiles:
		# For html files, remove de html extension
		filePath = filePath.replace('.html', '')
		# Add the file to the list of files to invalidate
		invalidateFiles.append('/' + filePath)

		try:
			# Use the delete_object method of S3 to delete the file from the bucket
			s3.delete_object(Bucket = bucketName, Key = filePath)
		except Exception as e:
			sns.publish(TopicArn = topicArn, Message = 'Error deleting file from S3: ' + str(e), Subject = 'Sync Error')
			return {
				'statusCode': 500,
				'body': 'Error deleting file from S3: ' + str(e)
			}

	try:
		# Invalidate the modified files in CloudFront cache
		cloudfront.create_invalidation(
			DistributionId = distributionId,
			InvalidationBatch = {
				'Paths': {
					'Quantity': len(invalidateFiles),
					'Items': invalidateFiles
				},
				'CallerReference': str(time.time()).replace('.', '')
			}
		)
	except Exception as e:
		sns.publish(TopicArn = topicArn, Message = 'Error invalidating files in CloudFront: ' + str(e), Subject = 'Sync Error')
		return {
			'statusCode': 500,
			'body': 'Error invalidating files in CloudFront: ' + str(e)
		}

	# Return a 200 response
	return {
		'statusCode': 200,
		'body': 'All correct!'
	}

¡Gracias por leer! Nos vemos en el próximo.