Built a Website but Don’t Know How to Make It Live?
Here’s how I made my website production-ready using AWS S3, CloudFront, Terraform, and GitHub Actions

You’ve built your frontend.
The UI works. The code looks fine.
But then comes the hard part:
How do you deploy it like a real production website?
With HTTPS, fast loading, redeployments on every push, and zero manual AWS work?
I was stuck here for a long time.
After breaking things multiple times in the AWS console, I finally implemented a fully automated, production-grade setup using:
Amazon S3
CloudFront
Terraform
GitHub Actions
This blog explains exactly how I did it, step by step.
What “production-ready” actually means
For me, production-ready meant:
HTTPS by default
CDN for fast loading
No public S3 buckets
Infrastructure as code
Automatic deployments on every push
No manual AWS console clicks
Uploading files to S3 manually is not production-ready.
See the Full Project on GitHub
This project is available at https://github.com/yuvraj-sankilwar/my_hello_website — feel free to check it out to better understand the complete setup.
Final architecture

Terraform handles infrastructure
GitHub Actions handles deployments
Tech stack used
Frontend: any static site (HTML / React / Vite)
AWS S3 – static file storage
AWS CloudFront – CDN + HTTPS
Terraform – infrastructure automation
GitHub Actions – CI/CD
Terraform remote state stored in S3
Project name:
my_hello_website
Step 1: Build the frontend
Your project must generate static files without errors, which means your frontend is ready for deployment.
Example:
npm run build
Output:
dist/
├── index.html
└── assets/
Step 2: Terraform project structure
Now, inside the same frontend project, create a folder named infra. This folder will store all your infrastructure-related files. These could be Ansible playbooks or YAML files, but in our project we are using Terraform, so we will create the following files.
infra/
├── main.tf
├── state-storage.tf
└── variables.tf
Step 3: Store Terraform state in S3 (IMPORTANT)
We need this state-storage.tf file to store our infrastructure state in a central place so it’s not tied to one laptop.
By storing the Terraform state in S3:
The state is not local
It is safe to use with CI/CD
It can be shared across the team
You can recover everything even if you lose your local data
This makes managing AWS resources much simpler and safer.
state-storage.tf
resource "aws_s3_bucket" "terraform_state" {
bucket = var.state_storage_bucket_name
tags = merge(var.tags, {
Name = var.state_storage_bucket_name
Purpose = "terraform-state-storage"
Description = "Stores Terraform state files for ${var.bucket_name} infrastructure"
})
}
resource "aws_s3_bucket_versioning" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_public_access_block" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
output "terraform_state_bucket" {
value = aws_s3_bucket.terraform_state.id
}
output "terraform_state_bucket_arn" {
value = aws_s3_bucket.terraform_state.arn
}
Step 4: Terraform infrastructure
This creates:
A private S3 bucket to store your website files (the
buildordistfolder)A CloudFront distribution that serves your website securely to users across the globe
Origin Access Control (OAC) to control access between CloudFront and the S3 bucket
A secure bucket policy that allows the CloudFront distribution to access the S3 bucket for serving content
main.tf
terraform {
required_version = ">= 1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
backend "s3" {
bucket = "my-hello-website-terraform-state"
key = "infrastructure/terraform.tfstate"
region = "ap-south-1"
encrypt = true
}
}
provider "aws" {
region = var.aws_region
}
resource "aws_s3_bucket" "site" {
bucket = var.bucket_name
tags = var.tags
}
resource "aws_s3_bucket_versioning" "site" {
count = var.enable_versioning ? 1 : 0
bucket = aws_s3_bucket.site.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "site" {
bucket = aws_s3_bucket.site.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_public_access_block" "block" {
bucket = aws_s3_bucket.site.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_cloudfront_origin_access_control" "oac" {
name = "my-hello-website-oac"
description = "OAC for my-hello-website"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
resource "aws_cloudfront_function" "rewrite_request" {
name = "my-hello-website-rewrite-request"
runtime = "cloudfront-js-2.0"
code = <<-EOF
function handler(event) {
var request = event.request;
var uri = request.uri;
var hasExtension = /\.[^/]+$/.test(uri);
if (!hasExtension) {
if (!uri.endsWith('/')) {
uri = uri + '/';
}
request.uri = uri + 'index.html';
}
return request;
}
EOF
publish = true
}
resource "aws_cloudfront_distribution" "cdn" {
origin {
domain_name = aws_s3_bucket.site.bucket_regional_domain_name
origin_id = "my-hello-website-origin"
origin_access_control_id = aws_cloudfront_origin_access_control.oac.id
}
enabled = true
is_ipv6_enabled = true
default_root_object = "index.html"
comment = "My Hello Website Distribution"
default_cache_behavior {
allowed_methods = ["GET", "HEAD", "OPTIONS"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "my-hello-website-origin"
viewer_protocol_policy = "redirect-to-https"
compress = true
cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6"
function_association {
event_type = "viewer-request"
function_arn = aws_cloudfront_function.rewrite_request.arn
}
}
custom_error_response {
error_code = 404
response_code = 200
response_page_path = "/index.html"
}
custom_error_response {
error_code = 403
response_code = 200
response_page_path = "/index.html"
}
price_class = var.cloudfront_price_class
restrictions {
geo_restriction {
restriction_type = "none"
}
}
viewer_certificate {
cloudfront_default_certificate = true
}
wait_for_deployment = true
lifecycle {
ignore_changes = [web_acl_id, price_class]
}
tags = merge(var.tags, {
Name = "my-hello-website-distribution"
})
}
resource "aws_s3_bucket_policy" "policy" {
bucket = aws_s3_bucket.site.id
depends_on = [aws_cloudfront_distribution.cdn]
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AllowCloudFrontServicePrincipal"
Effect = "Allow"
Principal = {
Service = "cloudfront.amazonaws.com"
}
Action = "s3:GetObject"
Resource = "${aws_s3_bucket.site.arn}/*"
Condition = {
StringEquals = {
"AWS:SourceArn" = aws_cloudfront_distribution.cdn.arn
}
}
}
]
})
}
output "s3_bucket_name" {
value = aws_s3_bucket.site.id
}
output "s3_bucket_arn" {
value = aws_s3_bucket.site.arn
}
output "cloudfront_distribution_id" {
value = aws_cloudfront_distribution.cdn.id
}
output "cloudfront_distribution_arn" {
value = aws_cloudfront_distribution.cdn.arn
}
output "cloudfront_url" {
value = "https://${aws_cloudfront_distribution.cdn.domain_name}"
}
output "cloudfront_domain_name" {
value = aws_cloudfront_distribution.cdn.domain_name
}
variables.tf
variable "bucket_name" {
type = string
default = "my-hello-website"
}
variable "aws_region" {
type = string
default = "ap-south-1"
}
variable "cloudfront_price_class" {
type = string
default = "PriceClass_100"
validation {
condition = contains([
"PriceClass_All",
"PriceClass_200",
"PriceClass_100"
], var.cloudfront_price_class)
error_message = "Price class must be one of: PriceClass_All, PriceClass_200, PriceClass_100"
}
}
variable "enable_versioning" {
type = bool
default = true
}
variable "tags" {
type = map(string)
default = {
Project = "my-hello-website"
Environment = "production"
ManagedBy = "terraform"
}
}
variable "state_storage_bucket_name" {
type = string
default = "my-hello-website-terraform-state"
}
Step 5: Deploy infrastructure
terraform init
terraform plan
terraform apply
Terraform now creates everything correctly without manual AWS work.
Note: By default, CloudFront may create a Pay-As-You-Go pricing plan, which can result in usage-based charges. To avoid unexpected billing, follow the steps below to switch to the Free plan.
AWS Console → CloudFront → Distributions → select my_hello_website → click Distribution ID → Switch to a plan → select Free plan → Review changes → Submit
Step 6: GitHub Actions for automatic deployment
Goal
Every time I push to main on github:
Frontend builds
Your
distorbuildfolder get uploaded to S3CloudFront cache invalidates which refresh your website contents immediately
.github/workflows/deploy.yml
name: Deploy Next.js to S3 via CloudFront
# This workflow deploys the Next.js app to S3 and invalidates CloudFront cache
# Infrastructure (S3 bucket, CloudFront distribution, bucket policies) is managed by Terraform
# See infra/ directory for infrastructure as code
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
env:
AWS_REGION: ${{ secrets.AWS_REGION }}
S3_BUCKET: ${{ secrets.S3_BUCKET }}
CLOUDFRONT_DISTRIBUTION_ID: ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Build Next.js project
run: pnpm build
- name: Verify build output
run: |
echo "🔍 Checking build output..."
if [ ! -d "out" ]; then
echo "❌ Error: 'out' directory not found!"
echo "Build might have failed. Checking for errors..."
exit 1
fi
echo "✅ Build output directory exists"
echo "📁 Contents of out/ directory:"
ls -la out/ | head -20
echo "📊 Total files: $(find out -type f | wc -l)"
- 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: ${{ secrets.AWS_REGION }}
- name: Verify AWS access and S3 bucket
run: |
echo "🔍 Verifying AWS credentials..."
aws sts get-caller-identity
echo "🔍 Checking S3 bucket access..."
if aws s3 ls s3://${{ secrets.S3_BUCKET }} 2>&1; then
echo "✅ S3 bucket accessible: ${{ secrets.S3_BUCKET }}"
echo "📁 Current files in bucket:"
aws s3 ls s3://${{ secrets.S3_BUCKET }} --recursive | head -10
else
echo "❌ Error: Cannot access S3 bucket: ${{ secrets.S3_BUCKET }}"
echo "Please check:"
echo " 1. Bucket name is correct"
echo " 2. AWS credentials have s3:ListBucket permission"
exit 1
fi
- name: Upload build to S3
run: |
set -e # Exit on any error
echo "📤 Starting S3 upload..."
echo "📦 Bucket: ${{ secrets.S3_BUCKET }}"
echo "📁 Source: out/"
# Upload static assets (JS, CSS, images, etc.) with long cache
echo "📤 Uploading static assets..."
aws s3 sync out/ s3://${{ secrets.S3_BUCKET }} \
--delete \
--exclude "*.html" \
--exclude "*.json" \
--cache-control "public, max-age=31536000, immutable" \
--no-progress || {
echo "❌ Failed to upload static assets"
exit 1
}
# Upload HTML files with no-cache for immediate updates
echo "📤 Uploading HTML files..."
aws s3 sync out/ s3://${{ secrets.S3_BUCKET }} \
--exclude "*" \
--include "*.html" \
--cache-control "public, max-age=0, must-revalidate" \
--content-type "text/html" \
--no-progress || {
echo "❌ Failed to upload HTML files"
exit 1
}
# Upload JSON files (like _next/static files) with appropriate cache
echo "📤 Uploading JSON files..."
aws s3 sync out/ s3://${{ secrets.S3_BUCKET }} \
--exclude "*" \
--include "*.json" \
--cache-control "public, max-age=31536000, immutable" \
--content-type "application/json" \
--no-progress || {
echo "❌ Failed to upload JSON files"
exit 1
}
echo "✅ Files uploaded successfully!"
echo "📊 Verifying upload..."
aws s3 ls s3://${{ secrets.S3_BUCKET }} --recursive | wc -l | xargs echo "Total files in bucket:"
echo "ℹ️ S3 bucket policy and CloudFront are managed by Terraform"
- name: Invalidate CloudFront cache
if: env.CLOUDFRONT_DISTRIBUTION_ID != ''
run: |
echo "🔄 Invalidating CloudFront cache..."
aws cloudfront create-invalidation \
--distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} \
--paths "/*"
echo "✅ CloudFront cache invalidation initiated"
echo "🌐 Website will be updated within 1-5 minutes"
Step 7: GitHub Secrets required
AWS_ACCESS_KEY_ID = iam_access_key
AWS_SECRET_ACCESS_KEY = iam_secret_key
CLOUDFRONT_DISTRIBUTION_ID = you_got_after_running_terraform_apply
S3_BUCKET = my-hello-website
AWS_REGION = ap-south-1
Deployment flow now
Push code to
mainGitHub Actions runs
Build generated
Files uploaded to S3
CloudFront cache invalidated
Website updated globally
No manual steps.
Common mistakes I made (learn from this)
Mixing S3 website hosting with CloudFront
Editing policies in the wrong region
Doing infra manually in AWS console
Not using remote Terraform state
Forgetting CloudFront invalidation
Terraform + GitHub Actions solved all of this.
Final result
The website is HTTPS enabled
Fast for global delivery
Your S3 code in the bucket is safe
Automatic redeployments for further updates
Repeatable setup — you can follow the same approach for any frontend application
No headache of checking whether AWS is billing for unwanted resources; you have clear visibility into the resources used per project, which helps you better understand and manage AWS billing
Conclusion
If you’re stuck after building a website and don’t know how to deploy it properly, this setup will save you days of frustration.
Once automated, shipping a frontend becomes easy and hassle-free.
If you like this approach, don’t forget to like and share the blog.


