Skip to main content

Command Palette

Search for a command to run...

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

Updated
9 min read
Built a Website but Don’t Know How to Make It Live?

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 build or dist folder)

  • 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 dist or build folder get uploaded to S3

  • CloudFront 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

  1. Push code to main

  2. GitHub Actions runs

  3. Build generated

  4. Files uploaded to S3

  5. CloudFront cache invalidated

  6. 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.

More from this blog

Y

yuvraj-sankilwar

2 posts

This publication shares practical, real-world engineering insights based on hands-on development and implementation experience.The content emphasizes how things work in real projects.