logo hsb.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:

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.