CDN
elements man deploy/cdnFrom "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
Terminalaws 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:
Terminalaws 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…
TerminalAPP=$(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:
TerminalAPP=$(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.jsocbuild: { assetUrl: env("ASSET_URL", ""), },
TerminalAPP=$(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…
Terminalelements 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:
TerminalA=<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:
TerminalA=<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:
TerminalA=<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):
TerminalZN=<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:
TerminalZN=<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:
TerminalAPP=$(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
TerminalAPP=$(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.