Intro

Are you tired of pointing and clicking your way through the AWS console to build out your AWS services? Me too! Fear not my lovely adventurers, in this post I will show you how to use the fantastic Infrastructure as Code (IaC) tool Pulumi. With its magic, we will build the necessary AWS infrastructure to host a static website in a far away place called "CloudLandia".

Note
The code used in this post can be found here.

Pre-Flight Check

The environment will need to be setup with the following.

  • AWS Account - API credentials used to build the infrastructure.
  • Pulumi Account - Pulumi Stack(s) service Host.
  • Go Runtime - IaC development language.
  • Pulumi with Go language - Build and manage AWS infrastructure.

Checkout my previous post on getting the environment setup.

Domain Name

You will also need to register a domain-name in Route 53. The process to register a domain can be found in the the docs here. I have previously registered stratuslabs.net and will use that domain for this demo.

Software

The following software was used in this post.

  • Go - 1.19
  • Pulumi - 1.1.7
  • Ubuntu - 22.04

Architecture

The below diagram show the components we will use and build to host our static website.

blog/aws-static-website-with-pulumi/aws-static-website-pulumi.png

The following section describes the above diagram.

Route 53

Route 53 is used to Register our domain stratuslabs.net. Route 53 also hosts the following DNS records in a hosted zone.

Type Record Description
SOA stratuslabs.net Start of Authority domain record which has information about the domain, including the domain administrator, server refresh time, etc..
NS stratuslabs.net Points to the authoratative name servers for the domain.
A / AAAA - (Alias) www.stratuslabs.net Alias record that points to the CloudFront distribution.
A / AAAA - (Alias) stratuslabs.net Alias record that points to the CloudFront distribution.
CNAME _<id-string>.www.stratuslabs.net Canonical name used to validate domain ownership for HTTPS certificates generated by Certificate Manager.
CNAME _<id-string>.stratuslabs.net Canonical name used to validate domain ownership for HTTPS certificates generated by Certificate Manager.

Certificate Manager

Certificate Manager is used to generate HTTPS certificate for use with AWS services. We will create a certificate and use it in our CloudFront distribution so we can serve our website securely via HTTPS.

Important
The HTTPS certificate must be generated with Certificate Manager in the us-east-1 region. Although CloudFront is a Global service, it can only use certificates from the us-east-1 region.

CloudFront

CloudFront is a Content Delivery Network the helps speed up the UX of your website by hosting content close to your users. It can also be used to serve HTTPS certificates for an S3 website. An Origin Access Identity (OAI) is used to authenticate the CloudFront Distribution to S3.

Note
AWS recently launched a new service; Origin Access Control (OAC) which has a similar purpose to OAI with some additional features. OAC is the recommended service to use by AWS, but at the time of writing, OAC was not supported by Pulumi.

Simple Storage Service (S3)

S3 is used to store the content of our website and act as the webserver for the content. A bucket policy will be applied to the bucket to restrict access to the buckets objects from the CloudFront distribution only.

Identitiy and Access Management (IAM)

IAM provides the Programatic access keys that are used by Pulumi to communicate with AWS via the API.

Pulumi Service

The Pulumi service is a SaaS offering from the Pulumi team that allows you to host your Pulumi Projects and Stack configuration data.

Pulumi Application

The Pulumi application is where the magic happens. It communicates with both the Pulumi Service and the AWS API's by converting your code to infrastructure. The Pulumi application can be executed via an Administrator and/or an IaC pipline such as Jenkins or GitHub actions.

Users

Finally we get to the enemy ... Users access the website via CloudFront.

Code

Assuming you followed my previous post, you should already have your environment setup. In this section we will go through the code to deploy our website.

Website Code

Within your Pulumi project directory create a directory tree of www\_site with the index.html and error.html files. These are the only files you need for a basic site. Check out the code in the above GitHub link for some sample contents.

directory tree
<pulumi-project-directory>
├── LICENSE
├── Pulumi.dev.yaml
├── Pulumi.yaml
├── README.md
├── go.mod
├── go.sum
├── main.go
└── www
    └── _site
        ├── error.html
        └── index.html

Pulumi Code

This is it, the moment you have been waiting for. The code below describes our AWS infrastructure. Place the following code in your main.go file. The code is commented to explain what is happening to build the infrastructure.

file
// main.go
package main

import (
    "fmt"
    "os"

    "github.com/pulumi/pulumi-aws/sdk/v5/go/aws/acm"
    "github.com/pulumi/pulumi-aws/sdk/v5/go/aws/cloudfront"
    "github.com/pulumi/pulumi-aws/sdk/v5/go/aws/iam"
    "github.com/pulumi/pulumi-aws/sdk/v5/go/aws/route53"
    "github.com/pulumi/pulumi-aws/sdk/v5/go/aws/s3"
    "github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)

// Structs to store a data.
type Project struct {
    name string
}

type Environment struct {
    name string
}

type Site struct {
    dir string
}

type Domain struct {
    name string
}

type Tags struct {
    tags map[string]string
}

type WebBucket struct {
    name          string
    indexDocument string
    errorDocument string
}

func main() {

    pulumi.Run(func(ctx *pulumi.Context) error {

        // Project Variables
        // -----------------
        project := Project{
            name: "stratusLabs",
        }

        environment := Environment{
            name: "dev",
        }

        site := Site{
            dir: "./www/_site",
        }

        domain := Domain{
            name: "stratuslabs.net",
        }

        tags := Tags{
            tags: map[string]string{
                "project":     project.name,
                "environment": environment.name,
            },
        }

        var priceClass string
        switch environment.name {
        case "dev":
            priceClass = "PriceClass_100"
        case "prod":
            priceClass = "PriceClass_All"
        default:
            priceClass = "PriceClass_100"
        }

        wb := WebBucket{
            name:          "www.stratuslabs.net",
            indexDocument: "index.html",
            errorDocument: "error.html",
        }

        // Website Files
        // -------------
        // Load the file to transfer to the websites S3 bucket.
        files, err := os.ReadDir(fmt.Sprintf("%s/", site.dir))
        if err != nil {
            return err
        }

        // Domain Name
        // -----------
        // Load the instance of the domain name that was purchased for the website.
        domainZone, err := route53.LookupZone(ctx, &route53.LookupZoneArgs{
            Name: pulumi.StringRef(domain.name),
        }, nil)
        if err != nil {
            return err
        }

        // S3
        // --
        // Create an S3 bucket and enalbe Web Hosting in order to host the website.
        bucket, err := s3.NewBucket(ctx, fmt.Sprintf("%sBucket", project.name), &s3.BucketArgs{
            Bucket: pulumi.String(wb.name),
            Website: &s3.BucketWebsiteArgs{
                IndexDocument: pulumi.String(wb.indexDocument),
                ErrorDocument: pulumi.String(wb.errorDocument),
            },
            Tags: pulumi.ToStringMap(tags.tags),
        })
        if err != nil {
            return err
        }

        // Make bucket private. This blocks all access directly to the bucket.
        // Access will be permitted for CloudFront to the bucket via a bucket policy.
        _, err = s3.NewBucketPublicAccessBlock(ctx, fmt.Sprintf("%sBucketNoPublic", project.name), &s3.BucketPublicAccessBlockArgs{
            Bucket:                bucket.ID(),
            BlockPublicAcls:       pulumi.Bool(true),
            BlockPublicPolicy:     pulumi.Bool(true),
            IgnorePublicAcls:      pulumi.Bool(true),
            RestrictPublicBuckets: pulumi.Bool(true),
        })
        if err != nil {
            return err
        }

        // Upload the website files to the bucket.
        for _, file := range files {
            _, err = s3.NewBucketObject(ctx, file.Name(), &s3.BucketObjectArgs{
                Key:         pulumi.String(file.Name()),
                Bucket:      bucket.ID(),
                Source:      pulumi.NewFileAsset(fmt.Sprintf("%s/%s", site.dir, file.Name())),
                ContentType: pulumi.String("text/html"),
                Tags:        pulumi.ToStringMap(tags.tags),
            })
            if err != nil {
                return err
            }
        }

        // Certificate Manager
        // -------------------
        // Create a Public Certificate that will be used in the CloudFront distribution
        // to enable TLS connections to the website.

        certificate, err := acm.NewCertificate(ctx, fmt.Sprintf("%sCert", project.name), &acm.CertificateArgs{
            DomainName:       pulumi.String(domain.name),
            ValidationMethod: pulumi.String("DNS"),
            SubjectAlternativeNames: pulumi.StringArray{
                pulumi.String(fmt.Sprintf("www.%s", domain.name)),
            },
            Tags: pulumi.ToStringMap(tags.tags),
        })
        if err != nil {
            return err
        }

        // Add CNAME records to Route53. This is used to validate that we own
        // the domain we are requesting certificates for.
        for i := 0; i <= 1; i++ {
            _, err := route53.NewRecord(ctx, fmt.Sprintf("%sCname%d", project.name, i), &route53.RecordArgs{
                ZoneId: pulumi.String(domainZone.Id),
                Name:   certificate.DomainValidationOptions.Index(pulumi.Int(i)).ResourceRecordName().Elem(),
                Type:   pulumi.String("CNAME"),
                Ttl:    pulumi.Int(60),
                Records: pulumi.StringArray{
                    certificate.DomainValidationOptions.Index(pulumi.Int(i)).ResourceRecordValue().Elem(),
                },
            })
            if err != nil {
                return err
            }
        }

        // CloudFront
        // ----------
        // Create a CloudFront Origin Access Identity.
        // This is used to attach the CloudFront Distribution to an S3 bucket.
        originAccessId, err := cloudfront.NewOriginAccessIdentity(ctx, fmt.Sprintf("%sOriginAccessId", project.name), &cloudfront.OriginAccessIdentityArgs{
            Comment: pulumi.String(project.name),
        })
        if err != nil {
            return err
        }

        // Create a CloudFront Distribution
        cloudFrontDist, err := cloudfront.NewDistribution(ctx, fmt.Sprintf("%sDistribution", project.name), &cloudfront.DistributionArgs{
            Origins: cloudfront.DistributionOriginArray{
                &cloudfront.DistributionOriginArgs{
                    DomainName: bucket.BucketRegionalDomainName,
                    OriginId:   bucket.ID(),
                    S3OriginConfig: &cloudfront.DistributionOriginS3OriginConfigArgs{
                        OriginAccessIdentity: originAccessId.CloudfrontAccessIdentityPath,
                    },
                },
            },
            Enabled:           pulumi.Bool(true),
            HttpVersion:       pulumi.String("http2and3"),
            IsIpv6Enabled:     pulumi.Bool(true),
            DefaultRootObject: pulumi.String("index.html"),
            // No logging config at this time, this will be added as an
			// option in the future.
            // LoggingConfig: &cloudfront.DistributionLoggingConfigArgs{
            // 	IncludeCookies: pulumi.Bool(false),
            // 	Bucket:         pulumi.String("mylogs.s3.amazonaws.com"),
            // 	Prefix:         pulumi.String("myprefix"),
            // },
            Aliases: pulumi.StringArray{
                pulumi.String(domain.name),
                pulumi.String(fmt.Sprintf("www.%s", domain.name)),
            },
            DefaultCacheBehavior: &cloudfront.DistributionDefaultCacheBehaviorArgs{
                AllowedMethods: pulumi.StringArray{
                    pulumi.String("GET"),
                    pulumi.String("HEAD"),
                },
                CachedMethods: pulumi.StringArray{
                    pulumi.String("GET"),
                    pulumi.String("HEAD"),
                },
                TargetOriginId: bucket.ID(),
                ForwardedValues: &cloudfront.DistributionDefaultCacheBehaviorForwardedValuesArgs{
                    QueryString: pulumi.Bool(false),
                    Cookies: &cloudfront.DistributionDefaultCacheBehaviorForwardedValuesCookiesArgs{
                        Forward: pulumi.String("none"),
                    },
                },
                ViewerProtocolPolicy: pulumi.String("redirect-to-https"),
                MinTtl:               pulumi.Int(0),
                DefaultTtl:           pulumi.Int(3600),
                MaxTtl:               pulumi.Int(86400),
            },
            PriceClass: pulumi.String(priceClass),
            Restrictions: &cloudfront.DistributionRestrictionsArgs{
                GeoRestriction: &cloudfront.DistributionRestrictionsGeoRestrictionArgs{
                    // No Geo-Restrictions at this time, this will be added 
                    // as an option in the future.
                    // Update this section to enable Geo-Restrictions.
                    RestrictionType: pulumi.String("none"),
                    // Locations: pulumi.StringArray{
                    // 	pulumi.String("US"),
                    // 	pulumi.String("CA"),
                    // 	pulumi.String("GB"),
                    // 	pulumi.String("DE"),
                    // },
                },
            },
            ViewerCertificate: &cloudfront.DistributionViewerCertificateArgs{
                CloudfrontDefaultCertificate: pulumi.Bool(false),
                AcmCertificateArn:            certificate.Arn,
                SslSupportMethod:             pulumi.String("sni-only"),
                MinimumProtocolVersion:       pulumi.String("TLSv1.2_2021"),
            },
            Tags: pulumi.ToStringMap(tags.tags),
        })
        if err != nil {
            return err
        }

        // Create DNS records for the website.
        // The A/AAAA records are alias records that point to the
        // CloudFront distribution. Records are created for both
        // the bare domain `example.domain` and the `www.example.domain`
        for _, record := range []string{"A", "AAAA"} {
            _, err := route53.NewRecord(ctx, fmt.Sprintf("%s%s", project.name, record), &route53.RecordArgs{
                ZoneId: pulumi.String(domainZone.Id),
                Name:   pulumi.String(domain.name),
                Type:   pulumi.String(record),
                Aliases: route53.RecordAliasArray{
                    &route53.RecordAliasArgs{
                        Name:                 cloudFrontDist.DomainName,
                        ZoneId:               cloudFrontDist.HostedZoneId,
                        EvaluateTargetHealth: pulumi.Bool(true),
                    },
                },
            })
            if err != nil {
                return err
            }
            _, err = route53.NewRecord(ctx, fmt.Sprintf("www%s%s", project.name, record), &route53.RecordArgs{
                ZoneId: pulumi.String(domainZone.Id),
                Name:   pulumi.String(fmt.Sprintf("www.%s", domain.name)),
                Type:   pulumi.String(record),
                Aliases: route53.RecordAliasArray{
                    &route53.RecordAliasArgs{
                        Name:                 cloudFrontDist.DomainName,
                        ZoneId:               cloudFrontDist.HostedZoneId,
                        EvaluateTargetHealth: pulumi.Bool(true),
                    },
                },
            })
            if err != nil {
                return err
            }
        }

        // S3
        // --
        // Create a bucket policy that allows access to the bucket
        // only from the CloudFront distribution.
        bucketPolicy := iam.GetPolicyDocumentOutput(ctx, iam.GetPolicyDocumentOutputArgs{
            PolicyId: pulumi.String("PolicyForCloudFrontPrivateContent"),
            Version:  pulumi.String("2008-10-17"),
            Statements: iam.GetPolicyDocumentStatementArray{
                &iam.GetPolicyDocumentStatementArgs{
                    Sid: pulumi.String("1"),
                    Principals: iam.GetPolicyDocumentStatementPrincipalArray{
                        &iam.GetPolicyDocumentStatementPrincipalArgs{
                            Type: pulumi.String("AWS"),
                            Identifiers: pulumi.StringArray{
                                originAccessId.IamArn,
                            },
                        },
                    },
                    Actions: pulumi.StringArray{
                        pulumi.String("s3:GetObject"),
                    },
                    Resources: pulumi.StringArray{
                        pulumi.Sprintf("%v/*", bucket.Arn),
                    },
                },
            },
        }, nil)

        // Attach the bucket policy to the S3 Bucket.
        _, err = s3.NewBucketPolicy(ctx, fmt.Sprintf("%sBucketPolicy", domain.name), &s3.BucketPolicyArgs{
            Bucket: bucket.ID(),
            Policy: bucketPolicy.ApplyT(func(bucketPolicy iam.GetPolicyDocumentResult) (string, error) {
                return bucketPolicy.Json, nil
            }).(pulumi.StringOutput),
        })
        if err != nil {
            return err
        }

        // Exports will be shown as outputs to the terminal.
        ctx.Export("bucketName", bucket.ID())
        ctx.Export("cloudFrontDist", cloudFrontDist.ID())
        return nil
    })
}

Deploy

Alright Hufflepuffs let's do this.

Wingardium Leviosa

Run the incantation to summon your infrastructure pulumi up.

cmd
pulumi up
  
# Output
     Type                                    Name                         Status      
 +   pulumi:pulumi:Stack                     stratuslabs-website-dev      created     
 +   ├─ aws:s3:Bucket                        stratusLabsBucket            created     
 +   ├─ aws:cloudfront:OriginAccessIdentity  stratusLabsOriginAccessId    created     
 +   ├─ aws:acm:Certificate                  stratusLabsCert              created     
 +   ├─ aws:s3:BucketPublicAccessBlock       stratusLabsBucketNoPublic    created     
 +   ├─ aws:s3:BucketObject                  index.html                   created     
 +   ├─ aws:s3:BucketObject                  error.html                   created     
 +   ├─ aws:s3:BucketPolicy                  stratuslabs.netBucketPolicy  created     
 +   ├─ aws:route53:Record                   stratusLabsCname0            created     
 +   ├─ aws:route53:Record                   stratusLabsCname1            created     
 +   ├─ aws:cloudfront:Distribution          stratusLabsDistribution      created     
 +   ├─ aws:route53:Record                   stratusLabsA                 created     
 +   ├─ aws:route53:Record                   wwwstratusLabsAAAA           created     
 +   ├─ aws:route53:Record                   stratusLabsAAAA              created     
 +   └─ aws:route53:Record                   wwwstratusLabsA              created     
 
Outputs:
    bucketName    : "www.stratuslabs.net"
    cloudFrontDist: "E2WEUT86LF3EKA"

Resources:
    + 15 created

Duration: 4m23s

After a few minutes you will have a website that you can browse to. Check out this one at https://stratuslabs.net/

Outro

In this post, we build the infrastructure on AWS to host a static website using the Pulumi IaC tool. In the next post I will show you how to use GitHub actions to tie in automatic deployments when you commit new content.

# aws
# pulumi
# golang