published: 6th of September 2022
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".
The environment will need to be setup with the following.
Checkout my previous post on getting the environment setup.
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.
The following software was used in this post.
The below diagram show the components we will use and build to host our static website.
The following section describes the above diagram.
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 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.
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.
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.
IAM provides the Programatic access keys that are used by Pulumi to communicate with AWS via the API.
The Pulumi service is a SaaS offering from the Pulumi team that allows you to host your Pulumi Projects and Stack configuration data.
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.
Finally we get to the enemy ... Users access the website via CloudFront.
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.
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.
<pulumi-project-directory>
├── LICENSE
├── Pulumi.dev.yaml
├── Pulumi.yaml
├── README.md
├── go.mod
├── go.sum
├── main.go
└── www
└── _site
├── error.html
└── index.html
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.
// 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
})
}
Alright Hufflepuffs let's do this.
Run the incantation to summon your infrastructure pulumi up.
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/
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.
https://docs.aws.amazon.com/AmazonS3/latest/userguide/example-bucket-policies.html
https://www.pulumi.com/registry/packages/aws/api-docs/
https://docs.aws.amazon.com/acm/latest/userguide/acm-regions.html