In an earlier post, I described a DIY static site CMS builder. Hosting a small personal static website on a VPS may be overkill, and I have had difficulty finding a provider that offers a right balance between pricing and provided service. Another option is to use a CDN on a platform like Amazon Web Services (AWS) for hosting which may be cheaper and more scalable, and easier to maintain. Actually, some shared hosting providers use AWS under the hood, so some may be relying on AWS even if they are not directly using it.
AWS official documentations are great and have
detailed guides for
hosting a static site on their platforms using the console. It is
definitely worth a read if starting out, but clicking through the
console may not be the most reproducible or maintainable way of
creating the resources. Thus this post streamlines the creation of
the needed infrastructure on AWS with HTTPS support in a
reproducible manner using only cloudformation (CFN) with
AWS CLI
(the only dependency), even though there are
other wrappers available that are easier to use than CFN. As a
side note, as reliable as any service may be, always have a backup
plan in case the platform provider closes your
account.
The deployment described in this post is done in a two separate steps:
Besides the obvious reasons as to why one would want to use HTTPS, from my understanding, it has positive effect on the page ranking on certain search engines.
This is broken up into two steps because, as of this writing, the
certificate must be created in us-east-1
while
the S3 bucket and cloudfront may be created in any region. Since
cloudformation by itself does not seem to have a straightforward
way (though there are ways) of creating resources in two
different regions in the same stack, it is easier to break them up
into two different stacks. These two stack templates can easily be
merged into a single stack but with the caveat that all the
resources would have to be created in the same AWS region.
ALIAS
records or their equivalents. Otherwise, you
may have to use AWS DNS (Route 53) since normal DNS does not
allow domain root CNAME
records.
~/.aws/credentials
. If not, pass
--profile <profile-name>
to all aws
commands.
Download my certificate manager CFN template. Navigate to the location of the template, then create the stack which will create the certificate:
# Note: region must be "us-east-1". Do not change.
aws cloudformation create-stack \
--stack-name <cert-stack-name> \
--region "us-east-1" \ # Do not change!
--template-body file://./cert.yaml \
--parameters ParameterKey=DomainName,ParameterValue="<sub.example.com>" \
ParameterKey=VerificationDomain,ParameterValue="<example.com>" \
ParameterKey=VerificationMethod,ParameterValue="{EMAIL,DNS}" # optional
where,
DomainName::ParameterValue
example.com
or sub.example.com
. Do not use www.
in
DomainName
value.
VerificationMethod::ParameterValue
EMAIL
(default)
and DNS
. If the value is set to EMAIL
(default,
easier, faster), then AWS will dispatch confirmation emails to
administrator@<VerificationDomain>
hostmaster@<VerificationDomain>
postmaster@<VerificationDomain>
webmaster@<VerificationDomain>
admin@<VerificationDomain>
The domain must be confirmed for the cloudformation stack creation to reach completion. Otherwise, if ignored for too long, the stack creation will timeout, fail, rollback, and delete the certificate.
Alternatively, if those email addresses are not accessible,
set the ValidationMethod
value to DNS
and
enter the required DNS records for your domain and wait for
AWS to verify the entries.
VerificationDomain::ParameterValue
VerificationMethod
set to EMAIL
). If domain name was set to a
subdomain (e.g. sub.example.com
), this value must
be the root domain (e.g. example.com
).
<cert-stack-name>
--region
value
us-east-1
for the cloudfront certificate (as of
this writing). Do not change.
Note: The AWS profile used to create the stack must have appropriate permissions for creating Cloudformation stacks, S3 buckets, Cloudfront distribution, and Certificate.
The status of the stack can be viewed using AWS console or using the aws cli:
aws --region <region-name> \
cloudformation describe-stacks --stack-name <cert-stack-name>
In particular, what is needed for the next step is the Amazon Resource Name (ARN) of the generated certificate resource. This is outputted by the CFN template to the "Output" field. There are various ways of fetching this information:
describe-stacks
mentioned above
Example of describe-stacks
output:
{
"Stacks": [
...
"StackStatus": "CREATE_COMPLETE",
"Outputs": [
{
"Description": "Certificate ARN",
"OutputKey": "CertArn",
"OutputValue": "arn:aws:acm:us-east-1:1234567890:certificate/aaaaaaaa-bbbb-cccc-dddd"
}
],
...
]
}
Or perhaps hackingly grep just the ARN info from the stack info
aws --region <region-name> \
cloudformation describe-stacks --stack-name <cert-stack-name> \
| grep -B1 -C2 "CertArn"
Download the second template. This template will create S3 bucket(s), Cloudfront CDN, and Route 53 hosted zone and recordset (optional). Then create the stack with a profile with appropriate permissions:
aws cloudformation create-stack \
--stack-name <s3cf-stack-name> \
--region "<AWS-region>" \
--template-body file://./src/s3-cloudfront.yaml \
--parameters ParameterKey=DomainName,ParameterValue="<sub.example.com>" \
ParameterKey=S3BucketName,ParameterValue="<bucket-name>" \
ParameterKey=CertificateArn,ParameterValue="<long-cert-ARN-string>" \
ParameterKey=CreateRoute53,ParameterValue="{true,false}" \ # Optional
ParameterKey=LogCloudfront,ParameterValue="{true,false}" # Optional
where,
DomainName::ParameterValue
example.com
excluding www.
<bucket-name>
com.example
<long-cert-ARN-string>
<AWS-region>
us-west-2
<s3cf-stack-name>
CreateRoute53
(Optional)
false
) parameter used to specify
whether a Route53 hosted zone along with A ALIAS
record will be created pointing to the cloudfront URL. This will
result in an automatic
$0.50/month charge if
left for more than 12 hours.
LogCloudfront
(Optional
true
or false
(default). If
true
, will create a bucket named <sbucket-name>-accesslog
for cloudfront logs. Note: cloudfront console provides some
logging even if this is set to false
.
After running it, if there are no issues, you can go and grab a cup of coffee since creation of the resources in the second stack may take a while (e.g. 20 minutes) to complete. I have had cases where it took over 25 minutes to complete.
Once the last stack is created, get the cloudfront URL using either
the AWS web console or using the describe-stacks
--stack-name <s3cf-stack-name>
approach highlighted
above.
If using a subdomain, you can just add a CNAME
entry to your DNS configuration list:
<subdomain> CNAME <cloudfront-url>
Alternative, if using the root domain and using Google domains, a few entries may be added to the DNS records:
@
to www.<example.com>
. Enable path forwarding.
CNAME
record:
www.<example.com> CNAME <cloudfront-url>
CNAME
record:
<example.com> CNAME <www.example.com>
Alternatively if the DNS provider does not support `ALIAS` records
or their equivalent, and you are using root domain, you may have
to use AWS DNS (Route53). Refer to CreateRoute53
option above.
The AWS cloudfront website URL that should be redirected to in DNS
is in the output fields with key CloudFrontURL
:
aws --region <region-name> \
cloudformation describe-stacks --stack-name <s3cf-stack-name>
Depending on your exact use case and traffic, total associated cost can be as low as less than $1/month:
Undoing is straightforward. Delete both stacks:
aws --region us-east-1 \ # must have been in `us-east-1`
delete-stack --stack-name <cert-stack-name>
S3BucketName
and possibly <sbucket-name>-accesslog
depending
on selection options above) is or are non-empty, manually empty
the S3 bucket(s). Stack deleting will fail if the bucket(s) are
not empty.
aws --region <aws-region> \
s3 rm s3://<S3BucketName> --recursive
aws --region <stack-region> \
delete-stack --stack-name <s3cf-stack-name>
<cert-stack-name>
and <s3cf-stack-name>
are the stack names used when creating the stacks (see
previous section). A list of stacks
in a given region using can be fetched with
aws cloudformation list-stacks
Note: the S3 bucket(s) are configured to be delete on stack deletion, i.e. the buckets will be deleted during stack deletion. If the buckets are not empty, this step will fail. See the previous step.
While new resources cannot be added to an existing stacks, some
components of the stack a may be updated
using the update-stack
API.
So far the focus has been on creating the initial infrastructure on AWS. The contents of the local build directory can be synced with the S3 storage bucket:
aws s3 sync /path/to/local-build-dir s3://<bucket-name> \
--delete \
--acl public-read
The --delete
flag is useful to remove files on the
remote storage that no longer exist on the local
directory. Without the --acl
flag, the updated files
may lose their publicly readable value and may become
inaccessible.
The created website, using the templates expects two key documents:
/index
(no extension)
/404
(no extension)
If you're not happy with these naming convention, it's trivial to change them in the template.
A few notes:
It may serve stale contents for up to 24 hours after they are changed. You can change default settings, or create an invalidation to force changes to propagate.
To achieve clean URLS, remove the HTML file extensions and
when uploading them, use metadata Content-Type: text/html
If the files are extensionless, my experience is that it can guess wrong. This can manifest itself as links being prompted for download by the browser. Consider updating content-types of new files:
aws s3 cp local-file s3://<bucket-name> \
--content-type "<mime-type>" \
--metadata-directive REPLACE
I believe that AWS compares local and remote file hashes (without metadata). It will not actually reupload the files to S3 if they are the same, but instead only update associated metadata.
A few additional notes or tips to consider
Certificate must be in us-east-1
regardless of
where other resources are. This may be due to the
cloudfront
end point being in
us-east-1
Route53 schema may be somewhat confusing. The following trick can be used in case in case you get stuck
# Get <zone-id> of hosted zones
aws --profile <profile-name> route53 list-hosted-zones
# Get recordset schema
aws --profile <profile-name> \
route53 list-resource-record-sets --hosted-zone-id <zone-id>
I used this trick to figure out why there were two HostedZoneId
entries in the Route53 Route53::RecordSet
schema:
each refers to id of a different resource.
Parameter default value must be a string literal and cannot have references to other resources
Official cloudformations used for creating the templates