Trim Your AWS Bill: Migrating a Static Site from Amplify to S3
A walk-through of the hidden costs of Amplify's WAF and a step-by-step migration to S3 and CloudFront

Software Engineer / Cloud Enthusiast / Coding Bootcamp Mentor
AWS Amplify is a zero-friction deployment platform for frontend developers. With just a GitHub repository and a click of a button, you have a globally distributed website with CI/CD, custom domains, and a Web Application Firewall (WAF). This can be a great tool for your hosting needs, but if your AWS bill starts to creep up, you may want to take a second to see whether Amplify is more than you need for a static website. Here, we'll cover Amplify's cost, S3 migration steps, DNS and CDN configuration.
The main problem is Amplify's WAF. Enabled by default on new Amplify apps, it adds a fixed monthly charge for a level of DDoS and bot protection that most static websites simply do not need. For a site that could be hosted almost free on S3 and CloudFront, it's hard to not make the migration off Amplify.
How to migrate to S3:
Navigate to S3 in the AWS console. Create a new bucket in your preferred region. The bucket name doesn't need to match your domain since CloudFront will handle the routing.
Open the bucket, go to the Properties tab, scroll to the bottom, and enable static website hosting. Set your index document and error document. If you're hosting a Single Page App (React, Vue, etc.), set the error document to index.html. This lets client-side routing handle 404s rather than returning an S3 error page. You can also handle this more elegantly with a CloudFront function.
Sync your local build directory to the bucket. The AWS CLI's "sync" command is the most efficient way to do this—it only uploads changed files and can set cache headers in a single pass.
# Build your project first (example: Vite / Create React App)
npm run build
# Upload and set appropriate cache headers
aws s3 sync ./dist s3://my-company-static-site \
--delete \
--cache-control "max-age=31536000,immutable" \
--exclude "*.html"
# Upload HTML files without long-lived caching
aws s3 sync ./dist s3://my-company-static-site \
--delete \
--cache-control "no-cache,no-store,must-revalidate" \
--include "*.html" \
--exclude "*"
Navigate to CloudFront, create a new distribution, and point its origin at your S3 bucket. You'll create an Origin Access Control (OAC) to allow CloudFront to read from your private bucket.
After creating the distribution, CloudFront will display a prompt asking you to update your S3 bucket policy. Copy the generated bucket policy and paste it into your bucket's Permissions › Bucket policy tab. It looks like this:
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "AllowCloudFrontServicePrincipal",
"Effect": "Allow",
"Principal": {
"Service": "cloudfront.amazonaws.com"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-company-static-site/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::123456789:distribution/EXAMPLEID"
}
}
}]
}
CloudFront requires SSL certificates to be issued in the us-east-1 (N. Virginia) region, regardless of where your other resources live. Navigate to AWS Certificate Manager in us-east-1 and request a public certificate. AWS Certificate Manager will ask you to add a CNAME record to your DNS. If your domain is in Route 53, click "Create records in Route 53" and ACM will add it automatically. Validation typically completes within 5 minutes.
Once your CloudFront distribution is deployed (status: Enabled) and your ACM certificate is validated, update your DNS records to point at the new distribution. Use an A record alias rather than a CNAME for your root domain.
Repeat this step for the www subdomain. DNS propagation is typically instant for Route 53 alias records within AWS, but external resolvers may take up to 60 seconds to update.
Before deleting your Amplify app, verify your new setup is working correctly. Load your site over the CloudFront URL, check the SSL certificate, and confirm routing works on all paths. Only then remove the Amplify app to stop billing.
Amplify's biggest non-WAF feature is automatic deployments on every git push. Replacing this takes about 15 minutes with GitHub Actions. Add the following workflow file to your repository:
# .github/workflows/deploy.yml
name: Deploy to S3 + CloudFront
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Sync to S3
run: |
aws s3 sync ./dist s3://my-company-static-site --delete \
--exclude "*.html" \
--cache-control "max-age=31536000,immutable"
aws s3 sync ./dist s3://my-company-static-site \
--exclude "*" --include "*.html" \
--cache-control "no-cache"
- name: Invalidate CloudFront cache
run: |
aws cloudfront create-invalidation \
--distribution-id ${{ secrets.CF_DISTRIBUTION_ID }} \
--paths "/*"
Store AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and CF_DISTRIBUTION_ID as encrypted secrets in your repository settings. Create a dedicated IAM user with minimal permissions—only s3:PutObject, s3:DeleteObject, s3:ListBucket, and cloudfront:CreateInvalidation.
The migration from Amplify to S3 + CloudFront is one of those rare infrastructure projects where you get a better, more transparent system and a lower bill at the end of it.

