Manual Deploy CDN

CDN

elements man deploy/cdn

From "help me set up a CDN" to an app whose scripts, styles, images and fonts load from Amazon CloudFront, near each visitor. Pages still come from the app. Start step 1 as soon as the user asks. This is AWS whatever the app is deployed on; another CDN only when the user names one, and then it is ASSET_URL in production.env (see Load Balancer (opens in a new tab)).

What to say: only the quoted lines on this page, word for word. Nothing else reaches the user: no hostnames, ids, file names, config, tool names, reasoning, or what comes next.

How to run: each block is one command and stands alone. It reads what it needs from .elements/id, config.jsoc and AWS, so nothing carries over in shell variables, and every aws call names --profile elements. No scripts around them, no set -e, no exit.

1. Deployed First

The CDN fetches from the live app, so it needs one. With no deploy.domain in config.jsoc, deploy first: start AWS (opens in a new tab) at step 1, and come back when it is live. The first name in deploy.domain is the origin below.

2. Sign In

Terminal
aws sts get-caller-identity --profile elements --query Account --output text

An account number means signed in. Anything else: do step 1 of AWS (opens in a new tab), then come back here.

3. Price

Never a price from memory. This prints CloudFront's rate per GB for the first band of traffic out of North America and Europe:

Terminal
aws pricing get-products --profile elements --region us-east-1 --service-code AmazonCloudFront --filters 'Type=TERM_MATCH,Field=transferType,Value=CloudFront Outbound' 'Type=TERM_MATCH,Field=fromLocation,Value=United States' --query 'PriceList[]' --output text | grep -o '\$[0-9.]* per GB[^"]*' | grep first

Round to cents. Ask with a question tool when there is one (options "Go ahead (recommended)" and "Use my own address"):

A CDN costs about $<rate> per GB it serves, and nothing when idle. It comes with its own address, so you don't need one. Go ahead?

"Use my own address" goes to Your Own Address below first, then step 4. Never offer it otherwise.

4. Create It

Setting up your CDN, about five minutes…

Terminal
APP=$(cat .elements/id); D=<domain>; A=<own address, or empty> X=""; [ -n "$A" ] && X=",\"Aliases\": {\"Quantity\": 1, \"Items\": [\"$A\"]}, \"ViewerCertificate\": {\"ACMCertificateArn\": \"$(aws acm list-certificates --profile elements --region us-east-1 --query "CertificateSummaryList[?DomainName=='$A'].CertificateArn | [0]" --output text)\", \"SSLSupportMethod\": \"sni-only\", \"MinimumProtocolVersion\": \"TLSv1.2_2021\"}" aws cloudfront create-distribution --profile elements --query 'Distribution.DomainName' --output text --distribution-config "{ \"CallerReference\": \"elements-$APP-$(date +%s)\", \"Comment\": \"elements-$APP\", \"Enabled\": true, \"HttpVersion\": \"http2and3\", \"Origins\": {\"Quantity\": 1, \"Items\": [{\"Id\": \"app\", \"DomainName\": \"$D\", \"CustomOriginConfig\": {\"HTTPPort\": 80, \"HTTPSPort\": 443, \"OriginProtocolPolicy\": \"https-only\"}}]}, \"DefaultCacheBehavior\": {\"TargetOriginId\": \"app\", \"ViewerProtocolPolicy\": \"redirect-to-https\", \"CachePolicyId\": \"658327ea-f89d-4fab-a63d-7e88639e58f6\", \"Compress\": true, \"AllowedMethods\": {\"Quantity\": 2, \"Items\": [\"GET\", \"HEAD\"]}}$X }"

It prints the CDN's address, d1234abcd.cloudfront.net. The cache policy is AWS's own "caching optimized": it keeps what the app's headers say, and every asset URL carries a content hash, so a deploy never serves a stale file.

Wait for it. Each run waits at most four minutes; run it again until it prints Deployed:

Terminal
APP=$(cat .elements/id) ID=$(aws cloudfront list-distributions --profile elements --query "DistributionList.Items[?Comment=='elements-$APP'].Id | [0]" --output text) for i in 1 2 3 4 5 6 7 8; do S=$(aws cloudfront get-distribution --profile elements --id $ID --query Distribution.Status --output text); [ "$S" = Deployed ] && break; sleep 30; done echo $S

5. Connect the App

In config.jsoc, inside build (add the block when there is none):

config.jsoc
build: { assetUrl: env("ASSET_URL", ""), },
Terminal
APP=$(cat .elements/id); F=config/env/production.env; H=<own address, or empty> [ -n "$H" ] || H=$(aws cloudfront list-distributions --profile elements --query "DistributionList.Items[?Comment=='elements-$APP'].DomainName | [0]" --output text) touch $F; grep -v '^ASSET_URL=' $F > $F.tmp; mv $F.tmp $F echo "ASSET_URL=https://$H" >> $F curl -sI https://$H/ | head -1

A 200 or a 30x is the app answering through the CDN. With an own address, first add a CNAME from it to the cloudfront.net address (see Adding a Record, below), and run this block again until it answers.

Turning on your CDN…

Terminal
elements deploy -json

No errors:

Your CDN is on. Scripts, styles and images now load from the location nearest each visitor.

Your Own Address

What address should it use? For example, cdn.<the app's domain>.

Amazon needs a certificate for it, which it issues once a record proves the domain is the user's:

Terminal
A=<address> aws acm request-certificate --profile elements --region us-east-1 --domain-name $A --validation-method DNS --query CertificateArn --output text >/dev/null sleep 5 aws acm list-certificates --profile elements --region us-east-1 --query "CertificateSummaryList[?DomainName=='$A'].CertificateArn | [0]" --output text | xargs aws acm describe-certificate --profile elements --region us-east-1 --query 'Certificate.DomainValidationOptions[0].ResourceRecord.[Type,Name,Value]' --output text --certificate-arn

Add that record (Adding a Record, below), then wait. Each run waits at most four minutes; run it again until it prints ISSUED:

Terminal
A=<address> C=$(aws acm list-certificates --profile elements --region us-east-1 --query "CertificateSummaryList[?DomainName=='$A'].CertificateArn | [0]" --output text) for i in 1 2 3 4 5 6 7 8; do S=$(aws acm describe-certificate --profile elements --region us-east-1 --certificate-arn $C --query Certificate.Status --output text); [ "$S" = ISSUED ] && break; sleep 30; done echo $S

Adding a Record

This prints the DNS zone and its name server, which says how the record is added:

Terminal
A=<address>; Z=$(dig +noall +answer +authority SOA $A | awk '$4=="SOA" {print $1; exit}'); echo "zone ${Z%.} $(dig +short NS $Z | head -1)"

awsdns (Route 53):

Terminal
ZN=<zone>; T=<type>; N=<name>; V=<value> Z=$(aws route53 list-hosted-zones-by-name --profile elements --dns-name $ZN --query "HostedZones[?Name=='$ZN.'].Id" --output text) aws route53 change-resource-record-sets --profile elements --hosted-zone-id $Z --change-batch "{\"Changes\":[{\"Action\":\"UPSERT\",\"ResourceRecordSet\":{\"Name\":\"$N\",\"Type\":\"$T\",\"TTL\":300,\"ResourceRecords\":[{\"Value\":\"$V\"}]}}]}" --query ChangeInfo.Status --output text

digitalocean.com, with doctl signed in:

Terminal
ZN=<zone>; T=<type>; N=<name>; V=<value>; N=${N%.}.; N=${N%.$ZN.} doctl compute domain records create $ZN --context elements --record-type $T --record-name $N --record-data ${V%.}. --record-ttl 300 >/dev/null && echo ok

Anything else: the user adds it. Fill in the type, name and value:

Add this record where you manage <zone>'s DNS, then come back.

Teardown

Confirm with the user first. Take the CDN out of the app before deleting it: remove ASSET_URL from production.env and deploy. Then disable it, wait until Deployed (the wait block above), and delete it:

Terminal
APP=$(cat .elements/id) ID=$(aws cloudfront list-distributions --profile elements --query "DistributionList.Items[?Comment=='elements-$APP'].Id | [0]" --output text) aws cloudfront get-distribution-config --profile elements --id $ID --query DistributionConfig --output json | sed 's/"Enabled": true/"Enabled": false/' > /tmp/elements-cdn.json aws cloudfront update-distribution --profile elements --id $ID --if-match $(aws cloudfront get-distribution-config --profile elements --id $ID --query ETag --output text) --distribution-config file:///tmp/elements-cdn.json --query Distribution.Status --output text
Terminal
APP=$(cat .elements/id) ID=$(aws cloudfront list-distributions --profile elements --query "DistributionList.Items[?Comment=='elements-$APP'].Id | [0]" --output text) aws cloudfront delete-distribution --profile elements --id $ID --if-match $(aws cloudfront get-distribution-config --profile elements --id $ID --query ETag --output text) && rm -f /tmp/elements-cdn.json

With an own address, then aws acm delete-certificate --profile elements --region us-east-1 --certificate-arn <arn>, and its records by hand.