multiple-sites-same-s3-bucket

In a previous article I explained how to host a static website in AWS using S3. In this article we will see how to host multiples sites using the same S3 bucket and the same Cloudfront distribution

First of all, check this prerequisites and make sure you have all of them

Prerequisites

  • An AWS account: If you don’t have one you can check this page to create a new one
  • AWS credentials created: You must setup your AWS credentials in your account. See this link to get more information on how to create them. Then use this link to know how to config your credentials in your environment
  • AWS CDK installed: You can see how to install the CDK in this page
  • An internet domain: You can buy a domain in many pages. Use your favourite one

Let’s start

Create a new directory and config your environment. In this tutorial we will use Python as the language

mkdir multisite-example
cd multisite-example
cdk init --language python
python3 -m venv .env
source .env/bin/activate
pip install -r requirements.txt

With this command we will have the environment with the python modules installed and with an empty cdk project like this:

.
├── README.md
├── app.py
├── cdk.json
├── requirements.txt
├── setup.py
└── multisite_example
    ├── __init__.py
    └── multisite_example_stack.py

Let me point the important files:

  • app.py: Entrypoint to our infrastructure. See Apps on the CDK Documentation for more information
  • multisite_example/multisite_example_stack.py: Our stack. See Stacks on the CDK Documentation for more information
  • setup.py: Setup configurations of the project

In the multisite_example_stack.py file we will define our AWS resources

Create the stack

We need to install a few modules. Edit setup.py and add the following modules in the install_requires variable

install_requires=[
        "aws-cdk.core==1.72.0",
        "aws-cdk.aws-s3==1.72.0",
        "aws-cdk.aws-certificatemanager==1.72.0",
        "aws-cdk.aws-cloudfront==1.72.0",
        "aws-cdk.aws-lambda==1.72.0"
]

Install the new modules from the console

pip install -r requirements.txt

First we are going to modify the app.py file

12#!/usr/bin/env python3

from aws_cdk import core

from multisite_example.multisite_example_stack import MultisiteExampleStack


app = core.App()
multisite_stack = MultisiteExampleStack(
    app,
    "multisite-example",
    env=core.Environment(region="us-east-1"),
)
app.synth()

In line 12 we define our region. In this case we will use North Virginia (us-east-1) because the certificate and the lambda function must be in that region in order to use with Cloudfront

Edit the file multisite_example_stack.py like this

from datetime import datetime
from aws_cdk import (
    core,
    aws_s3 as s3,
    aws_certificatemanager as cm,
    aws_cloudfront as cf,
    aws_lambda as _lambda,
    aws_iam as iam,
)
from aws_cdk.aws_cloudfront import Distribution
from aws_cdk.core import RemovalPolicy


class MultisiteExampleStack(core.Stack):
    def __init__(self, scope: core.Construct, id: str, **kwargs) -> None:
        super().__init__(scope, id, **kwargs)

        certificate = cm.Certificate(
            self,
            "MultisiteCertificate",
            domain_name="*.static.rubenjgarcia.es",
            validation_method=cm.ValidationMethod.DNS,
        )

        bucket = s3.Bucket(
            self,
            "MultiSiteBucket",
            bucket_name="multisite-bucket",
        )

        bucket.add_to_resource_policy(
            iam.PolicyStatement(
                principals=[iam.AnyPrincipal()],
                resources=[bucket.arn_for_objects("*")],
                actions=["s3:GetObject"],
                conditions={
                    "StringLike": {
                        "aws:Referer": [
                            "http://*.static.rubenjgarcia.es/*",
                            "https://*.static.rubenjgarcia.es/*",
                        ]
                    }
                },
            )
        )

        version_alias = datetime.now().strftime("%Y%m%d%H%M%S")
        routing_lambda = _lambda.Function(
            self,
            "MultisiteRoutingLambda",
            runtime=_lambda.Runtime.PYTHON_3_7,
            handler="handler.main",
            code=_lambda.Code.asset("./lambdas/MultisiteRouting"),
            timeout=core.Duration.seconds(10),
            description=version_alias,
        )

        version = routing_lambda.add_version(version_alias)

        version.add_permission(
            "AllowEdgeLambda",
            principal=iam.ServicePrincipal("edgelambda.amazonaws.com"),
            action="lambda:GetFunction",
        )

        cf.CloudFrontWebDistribution(
            self,
            "MultisiteCDN",
            price_class=cf.PriceClass.PRICE_CLASS_100,
            alias_configuration=cf.AliasConfiguration(
                names=["*.static.rubenjgarcia.es"],
                acm_cert_ref=certificate.certificate_arn,
                ssl_method=cf.SSLMethod.SNI,
                security_policy=cf.SecurityPolicyProtocol.TLS_V1_1_2016,
            ),
            origin_configs=[
                cf.SourceConfiguration(
                    behaviors=[
                        cf.Behavior(
                            is_default_behavior=True,
                            forwarded_values=cf.CfnDistribution.ForwardedValuesProperty(
                                query_string=True, headers=["host"]
                            ),
                            lambda_function_associations=[
                                cf.LambdaFunctionAssociation(
                                    event_type=cf.LambdaEdgeEventType.ORIGIN_REQUEST,
                                    lambda_function=version,
                                )
                            ],
                        )
                    ],
                    s3_origin_source=cf.S3OriginConfig(
                        s3_bucket_source=bucket,
                        origin_access_identity=cf.OriginAccessIdentity(
                            self, "MultisiteBucketOAI"
                        ),
                    ),
                )
            ],
        )

In line 18 we create the certificate. Put your domain name with a wildcard (*) before your static path like I’ve done in line 21
In line 25 we create the bucket where we will deploy our sites
In line 31 we are attaching a policy to our bucket. With this policy we restrict the access to only referrers that match our subdomains
In line 48 we create the lambda function that we will use as a Lambda@Edge. We need to create a version to deploy it, so we create this version in line 58 and add permission to Lambda@Edge to invoke it in line 60
In line 66 we create the CloudFront Distribution using our bucket as the origin (line 92). We need to pass the host header to our Lambda (line 81). We config the Lambda to be invoked when a request from an origin hits the distribution (line 84)

Lambda function

Create a folder named lambdas and under it create another folder named MultisiteRouting. Inside this folder create a file called handler.py with this code

import re


def main(event, context):
    request = event["Records"][0]["cf"]["request"]
    host = request["headers"]["host"][0]["value"]

    regex = r"^([a-z0-9]+)\.static\.rubenjgarcia\.es$"
    matches = re.search(regex, host)
    path = f"/{matches.group(1)}" if matches else ""

    domain = "multisite-bucket.s3.amazonaws.com"

    request["origin"] = {
        "custom": {
            "domainName": domain,
            "port": 80,
            "protocol": "http",
            "path": path,
            "sslProtocols": ["TLSv1.1", "TLSv1.2"],
            "readTimeout": 5,
            "keepaliveTimeout": 5,
            "customHeaders": {
                "referer": [{"key": "referer", "value": f"http://{host}/"}]
            },
        }
    }

    if request["uri"] == "" or request["uri"] == "/":
        request["uri"] = "/index.html"

    request["headers"]["host"] = [{"key": "host", "value": domain}]
    return request

You can see in lines 8 to 10 that we search the subdomain that we want to serve in the host header. Then we change the origin to point to the path that match with the subdomain

This is how it works: If we want to serve a site under a folder called site1 in our bucket we point to https://site1.static.rubenjgarcia.es and then the request will be modified to match the path site1/. We can upload multiples folders with multiples sites but remember that we need to create the DNS entries in our DNS Server pointing to the Cloudfront distribution for all the subdomains that we want to serve

That’s all we need. Now we can deploy our stack to AWS

cdk deploy