logohsb.horse
← Back to blog index

Blog

A Deployment Script for Serving an SPA with CloudFront + S3

A practical deployment script for serving an SPA from S3 behind CloudFront. This article organizes cache-control settings, a blue-green style deployment layout, and rollback steps.

Published:

Translations

This is a practical deployment script for serving an SPA with CloudFront + S3.

Assumptions

  • AWS is the cloud provider
  • Frontend build artifacts are placed in S3 and served through CloudFront

Infrastructure layout

S3

  • Bucket versioning: disabled
  • Encryption: SSE-S3
  • Block Public Access: ON
  • Static website hosting: disabled

CloudFront

  • Price class: use all edge locations
  • Supported HTTP versions: HTTP/3, 2, 1.1, 1.0

Origin

  • Origin name: S3-frontend-app
  • Origin domain: <bucket_name>.s3.ap-northeast-1.amazonaws.com
  • Origin path: /current
  • Origin access: Origin access control settings

Behavior

Default (*)

  • Origin: S3-frontend-app
  • Viewer protocol policy: HTTP to HTTPS
  • Cache policy: Managed-CacheOptimized
  • Viewer request: CloudFront Functions

Deployment script

I am skipping AWS credential details because that depends on the execution environment.

Terminal window
# global vars
readonly S3_BUCKET=""
readonly DIST_DIR=""
function main() {
# BUILD_ID can also be a Git commit hash
local -r build_id=$(date -Iseconds)
local -r base_uri="s3://$S3_BUCKET"
local -r deploy_uri="$base_uri/builds/$build_id"
local -r previous_uri="$base_uri/previous"
local -r current_uri="$base_uri/current"
# Deploy into builds/
# js,css
aws s3 sync "$DIST_DIR" "$deploy_uri" \
--exclude "*" \
--include "*.js" \
--include "*.css" \
--metadata-directive "REPLACE"
--cache-control "public,max-age=31536000,immutable"
# everything except js,css,html
aws s3 sync "$DIST_DIR" "$deploy_uri" \
--exclude "*.js" \
--exclude "*.css" \
--exclude "*.html" \
--metadata-directive "REPLACE" \
--cache-control "public,max-age=1,stale-while-revalidate=604800"
# html
aws s3 sync "$DIST_DIR" "$deploy_uri" \
--exclude "*" \
--include "*.html" \
--metadata-directive "REPLACE" \
--cache-control "no-cache"
# Back up the currently served frontend assets from current to previous
aws s3 sync "$current_uri" "$previous_uri" --delete --exact-timestamp
# Copy this build into current and publish it
aws s3 sync "$deploy_uri" "$current_uri" --delete --exact-timestamp
}

Why set Cache-Control in this order

Ideally, I would like to set cache-control in the very last step, aws s3 sync "$deploy_uri" "$current_uri", but when you sync from one S3 bucket path to another using --metadata-directive "REPLACE", AWS stops inferring Content-Type and everything ends up as binary/octet-stream.

When syncing from local files to S3, Content-Type inference is preserved, so this arrangement is a practical compromise.

Rollback script

If something goes wrong, restore previous into current.

Terminal window
# global vars
readonly S3_BUCKET=""
function revert() {
local -r base_uri="s3://$S3_BUCKET"
local -r previous_uri="$base_uri/previous"
local -r current_uri="$base_uri/current"
# copy previous to current
aws s3 sync "$previous_uri" "$current_uri" --delete --exact-timestamp
}

Summary

When serving an SPA with CloudFront + S3, combining appropriate cache-control settings with a blue-green style deployment layout gives you a safer and more efficient deployment flow.

By keeping build history under builds and managing the active and previous versions with current and previous, you can roll back quickly when necessary.

Related posts