Publishing a static website with AWS CLI

Posted in software by Christopher R. Wirz on Sat Jul 13 2019

Amazon Web Services (AWS) allows you to deploy to their cloud using their Command Line Interface (CLI). While this is not as infrastructure-focused as CloudFormation (or terraform, or CDK, or pulumi), under the hood, it is the same API being called. A common "getting started" example is static web hosting that can be updated from your CI/CD pipeline. To get started, apply your credentials to the CLI instance:


REGION="us-east-1"
aws configure set aws_access_key_id     "$ACCESS_KEY_ID"
aws configure set aws_secret_access_key "$SECRET_ACCESS_KEY"
aws configure set region                "$REGION" 
AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)

Now, set the parameters for that static site and CI/CD:


DOMAIN="your-web-site.com"
CICDUSER="your-web-site-deploy-user"
POLICY_NAME="YourWebSiteDeployPolicy"
PROFILE="your-web-site-deploy-profile"
BUCKET_NAME="$DOMAIN-static-hosting-$(date +%s)"
DEFAULT_ROOT_OBJECT="index.html"

Since all websites should use HTTPS, generate a certificate (this verifies with email):


CERT_ARN=$(
  aws acm request-certificate \
    --domain-name "$DOMAIN" \
    --subject-alternative-names "*.$DOMAIN" \
    --validation-method EMAIL \
    --region "$REGION" \
    --query CertificateArn \
    --output text
)

Now set up the S3 bucket


echo "==> Creating (or verifying existence of) S3 bucket: $BUCKET_NAME in $REGION"

# Check if the bucket already exists
if aws s3api head-bucket --bucket "$BUCKET_NAME" 2>/dev/null; then
  echo "Bucket $BUCKET_NAME already exists. Skipping create-bucket."
else
  # Bucket does not exist yet → create it
  if [ "$REGION" = "us-east-1" ]; then
    aws s3api create-bucket \
      --bucket "$BUCKET_NAME"
  else
    aws s3api create-bucket \
      --bucket "$BUCKET_NAME" \
      --create-bucket-configuration LocationConstraint="$REGION"
  fi

  echo "Bucket $BUCKET_NAME created."
fi

# UPDATED: Lock down public access at the bucket level (CloudFront will use OAI fetch objects)
echo "==> Applying public access block to $BUCKET_NAME"
aws s3api put-public-access-block \
  --bucket "$BUCKET_NAME" \
  --public-access-block-configuration BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

echo "Public access block applied."

UPDATED: create a CloudFront Origin Access Identity (OAI):


# We use the current UNIX timestamp as a CallerReference to ensure idempotency
CALLER_REF="oai-$DOMAIN-$(date +%s)"

# Create the OAI
OAI_ID=$(aws cloudfront create-cloud-front-origin-access-identity \
  --cloud-front-origin-access-identity-config \
    CallerReference="$CALLER_REF",Comment="OAI for $DOMAIN" \
  --query "CloudFrontOriginAccessIdentity.Id" \
  --output text)

# Some value like E1WVR5QO3X455F
echo "OAI created with ID: $OAI_ID"

# Grab the S3CanonicalUserId for the bucket policy
OAI_S3_CANONICAL_USER=$(aws cloudfront get-cloud-front-origin-access-identity \
  --id "$OAI_ID" \
  --query "CloudFrontOriginAccessIdentity.S3CanonicalUserId" \
  --output text)

# Some value like 2aab88cf5dd3b744b75d2bac01085d01fce0d1600d22436a0afcbf8201d9c5eeb63eb5a822a509164fda91a8e78915ed
echo "Retrieved OAI S3CanonicalUserId: $OAI_S3_CANONICAL_USER" 

Attach a bucket policy granting the OAI read access:


echo "==> Attaching bucket policy to allow CloudFront OAI to get objects"

read -r -d '' BUCKET_POLICY_JSON <l<lEOF
{
  "Version": "2008-10-17",
  "Statement": [
    {
      "Sid": "GrantCloudFrontOAIReadAccess",
      "Effect": "Allow",
      "Principal": {
        "CanonicalUser": "$OAI_S3_CANONICAL_USER"
      },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::$BUCKET_NAME/*"
    }
  ]
}
EOF

aws s3api put-bucket-policy \
  --bucket "$BUCKET_NAME" \
  --policy "$BUCKET_POLICY_JSON"

echo "Bucket policy attached."

Create the CloudFront distribution with non-proxy S3 origin (UPDATED using the OAI)


echo "==> Generating CloudFront distribution configuration JSON"

read -r -d '' DIST_CONFIG <<EOF
{
  "CallerReference": "$(date +%s)-$DOMAIN",
  "PriceClass": "PriceClass_100",
  "Aliases": {
    "Quantity": 2,
    "Items": ["$DOMAIN", "www.$DOMAIN"]
  },
  "DefaultRootObject": "$DEFAULT_ROOT_OBJECT",
  "Origins": {
    "Quantity": 1,
    "Items": [{
      "Id": "S3-$BUCKET_NAME",
      "DomainName": "$BUCKET_NAME.s3.amazonaws.com",
      "S3OriginConfig": {
        "OriginAccessIdentity": "origin-access-identity/cloudfront/$OAI_ID"
      }
    }]
  },
  "DefaultCacheBehavior": {
    "TargetOriginId": "S3-$BUCKET_NAME",
    "ViewerProtocolPolicy": "redirect-to-https",
    "AllowedMethods": {
      "Quantity": 2,
      "Items": ["GET","HEAD"]
    },
    "Compress": true,
    "ForwardedValues": {
      "QueryString": false,
      "Cookies": {"Forward": "none"}
    },
    "TrustedSigners": {"Enabled": false,"Quantity": 0},
    "TrustedKeyGroups": {"Enabled": false,"Quantity": 0},
    "MinTTL": 0
  },
  "Comment": "CloudFront distribution for $DOMAIN",
  "Enabled": true,
  "ViewerCertificate": {
    "ACMCertificateArn": "$CERT_ARN",
    "SSLSupportMethod": "sni-only",
    "MinimumProtocolVersion": "TLSv1.2_2018"
  },
  "Restrictions": {
    "GeoRestriction": {"RestrictionType": "none","Quantity": 0}
  }
}
EOF

echo "==> Creating CloudFront distribution ..."
DIST_ID=$(aws cloudfront create-distribution \
  --distribution-config "$DIST_CONFIG" \
  --query "Distribution.Id" \
  --output text)
  
# Something like F11H1ZUR2NWO24
echo "CloudFront distribution created. ID: $DIST_ID"

# Retrieve the domain name of the new distribution
DIST_DOMAIN=$(aws cloudfront get-distribution \
  --id "$DIST_ID" \
  --query "Distribution.DomainName" \
  --output text)

# Something like f2gzio890nkokl.cloudfront.net
echo "CloudFront distribution domain name: $DIST_DOMAIN"

With the static hosting now established, create the CI/CD user


echo "==> Creating CI/CD user"

DIST_ARN=$(
  aws cloudfront get-distribution \
    --id "$DIST_ID" \
    --query "Distribution.ARN" \
    --output text
)

echo "Creating IAM user $CICDUSER ..."
aws iam create-user --user-name "$CICDUSER" || echo "User may already exist, continuing ..."

read -r -d '' POLICY <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
	{
		"Sid": "CloudFrontCreateInvalidation",
		"Effect": "Allow",
		"Action": "cloudfront:CreateInvalidation",
		"Resource": [
			"$DIST_ARN"
		]
	},
    {
      "Sid": "AllowListBucket",
      "Effect": "Allow",
      "Action": ["s3:ListBucket"],
      "Resource": "arn:aws:s3:::$BUCKET"
    },
    {
      "Sid": "AllowPutObjects",
      "Effect": "Allow",
      "Action": ["s3:PutObject","s3:PutObjectAcl"],
      "Resource": "arn:aws:s3:::$BUCKET/*"
    }
  ]
}
EOF

echo "Attaching inline policy $POLICY_NAME to $CICDUSER ..."
aws iam put-user-policy \
  --user-name "$CICDUSER" \
  --policy-name "$POLICY_NAME" \
  --policy-document "$POLICY"
  

read ACCESS_KEY_ID SECRET_ACCESS_KEY < <(
  aws iam create-access-key \
    --user-name "$CICDUSER" \
    --query 'AccessKey.[AccessKeyId,SecretAccessKey]' \
    --output text
)

echo "Use the access key $ACCESS_KEY_ID and secret key $SECRET_ACCESS_KEY"
echo "in your development pipeline for $CICDUSER"

Using the user, run the post-build action to update the static contents of the website


echo "==> Running CI/CD post-build action"

echo "Configuring AWS CLI profile [$PROFILE] ..."
aws configure set aws_access_key_id     "$ACCESS_KEY_ID"     --profile "$PROFILE"
aws configure set aws_secret_access_key "$SECRET_ACCESS_KEY" --profile "$PROFILE"
aws configure set region                "$REGION"            --profile "$PROFILE"

echo "Syncing the build to the S3 bucket ..."
aws s3 sync build/ s3://$BUCKET_NAME --delete --profile $PROFILE

echo "Making the new contents live ..."
aws cloudfront create-invalidation --distribution-id $DIST_ID --paths "/*" --profile $PROFILE