Day 18 - Deploy a Static Site
Deploy a static website to AWS S3 with CloudFront CDN for global performance and HTTPS support.
Description
You have a static website (HTML, CSS, JavaScript) and need to host it reliably with global CDN performance and HTTPS. Deploy it to AWS S3 with CloudFront for a production-ready static site hosting solution.
Task
Deploy a static website to AWS S3 with CloudFront CDN.
Requirements:
- Create S3 bucket for hosting
- Configure bucket for static website hosting
- Set up CloudFront distribution
- Configure custom domain (optional)
- Automate deployment with GitHub Actions
Target
- ✅ Website accessible via HTTP/HTTPS
- ✅ CloudFront CDN serving content globally
- ✅ Automated deployment on push
- ✅ Custom domain configured (optional)
- ✅ SSL certificate active
Sample App
Static Website Files
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Advent of DevOps - Day 18</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="container">
<header>
<h1>🎄 Advent of DevOps</h1>
<p>Day 18: Static Site Deployment</p>
</header>
<main>
<section class="hero">
<h2>Welcome to My Static Site</h2>
<p>Deployed with AWS S3 + CloudFront</p>
</section>
<section class="features">
<div class="feature">
<h3>⚡ Fast</h3>
<p>Served from CloudFront edge locations</p>
</div>
<div class="feature">
<h3>🔒 Secure</h3>
<p>HTTPS enabled by default</p>
</div>
<div class="feature">
<h3>📈 Scalable</h3>
<p>Handles millions of requests</p>
</div>
</section>
<section class="info">
<h3>Deployment Info</h3>
<ul id="deployment-info">
<li>Build: <span id="build-id">Loading...</span></li>
<li>Deployed: <span id="deploy-time">Loading...</span></li>
<li>Version: <span id="version">1.0.0</span></li>
</ul>
</section>
</main>
<footer>
<p>© 2025 Advent of DevOps</p>
</footer>
</div>
<script src="app.js"></script>
</body>
</html>
styles.css
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
line-height: 1.6;
color: #333;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
header {
text-align: center;
color: white;
padding: 40px 20px;
}
header h1 {
font-size: 3rem;
margin-bottom: 10px;
}
main {
background: white;
border-radius: 10px;
padding: 40px;
margin: 20px 0;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
}
.hero {
text-align: center;
padding: 40px 0;
}
.hero h2 {
font-size: 2.5rem;
color: #667eea;
margin-bottom: 20px;
}
.features {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 30px;
margin: 40px 0;
}
.feature {
text-align: center;
padding: 30px;
background: #f8f9fa;
border-radius: 8px;
transition: transform 0.3s;
}
.feature:hover {
transform: translateY(-5px);
}
.feature h3 {
font-size: 2rem;
margin-bottom: 15px;
}
.info {
background: #f8f9fa;
padding: 30px;
border-radius: 8px;
margin-top: 40px;
}
.info ul {
list-style: none;
font-size: 1.1rem;
}
.info li {
padding: 10px 0;
border-bottom: 1px solid #ddd;
}
.info li:last-child {
border-bottom: none;
}
footer {
text-align: center;
color: white;
padding: 20px;
}
@media (max-width: 768px) {
header h1 {
font-size: 2rem;
}
.hero h2 {
font-size: 1.8rem;
}
.features {
grid-template-columns: 1fr;
}
}
app.js
// Display deployment information
document.addEventListener('DOMContentLoaded', () => {
// Fetch deployment metadata
fetch('/metadata.json')
.then(response => response.json())
.then(data => {
document.getElementById('build-id').textContent = data.buildId || 'N/A';
document.getElementById('deploy-time').textContent = new Date(data.deployedAt).toLocaleString();
document.getElementById('version').textContent = data.version;
})
.catch(error => {
console.error('Failed to load metadata:', error);
document.getElementById('build-id').textContent = 'Unknown';
document.getElementById('deploy-time').textContent = 'Unknown';
});
// Log to console
console.log('🎄 Advent of DevOps - Day 18');
console.log('Deployed with AWS S3 + CloudFront');
});
metadata.json
{
"buildId": "BUILD_ID_PLACEHOLDER",
"deployedAt": "DEPLOY_TIME_PLACEHOLDER",
"version": "1.0.0",
"environment": "production"
}
View Solution
Solution
1. Terraform Infrastructure
main.tf
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
# S3 bucket for website
resource "aws_s3_bucket" "website" {
bucket = var.bucket_name
}
# S3 bucket public access block
resource "aws_s3_bucket_public_access_block" "website" {
bucket = aws_s3_bucket.website.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# S3 bucket policy for CloudFront
resource "aws_s3_bucket_policy" "website" {
bucket = aws_s3_bucket.website.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AllowCloudFrontAccess"
Effect = "Allow"
Principal = {
Service = "cloudfront.amazonaws.com"
}
Action = "s3:GetObject"
Resource = "${aws_s3_bucket.website.arn}/*"
Condition = {
StringEquals = {
"AWS:SourceArn" = aws_cloudfront_distribution.website.arn
}
}
}
]
})
}
# CloudFront Origin Access Control
resource "aws_cloudfront_origin_access_control" "website" {
name = "website-oac"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
# CloudFront distribution
resource "aws_cloudfront_distribution" "website" {
enabled = true
is_ipv6_enabled = true
default_root_object = "index.html"
price_class = "PriceClass_100" # US, Canada, Europe
comment = "Static website distribution"
origin {
domain_name = aws_s3_bucket.website.bucket_regional_domain_name
origin_id = "S3-${aws_s3_bucket.website.id}"
origin_access_control_id = aws_cloudfront_origin_access_control.website.id
}
default_cache_behavior {
allowed_methods = ["GET", "HEAD", "OPTIONS"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "S3-${aws_s3_bucket.website.id}"
forwarded_values {
query_string = false
cookies {
forward = "none"
}
}
viewer_protocol_policy = "redirect-to-https"
min_ttl = 0
default_ttl = 3600
max_ttl = 86400
compress = true
}
# Custom error responses
custom_error_response {
error_code = 404
response_code = 404
response_page_path = "/404.html"
}
custom_error_response {
error_code = 403
response_code = 403
response_page_path = "/404.html"
}
restrictions {
geo_restriction {
restriction_type = "none"
}
}
viewer_certificate {
cloudfront_default_certificate = true
}
tags = {
Environment = var.environment
Project = "advent-of-devops"
}
}
# Outputs
output "website_bucket" {
value = aws_s3_bucket.website.id
}
output "cloudfront_domain" {
value = aws_cloudfront_distribution.website.domain_name
}
output "cloudfront_distribution_id" {
value = aws_cloudfront_distribution.website.id
}
variables.tf
variable "aws_region" {
description = "AWS region"
type = string
default = "us-east-1"
}
variable "bucket_name" {
description = "S3 bucket name for website"
type = string
}
variable "environment" {
description = "Environment name"
type = string
default = "production"
}
2. GitHub Actions Deployment
.github/workflows/deploy.yml
name: Deploy Static Site
on:
push:
branches: [ main ]
workflow_dispatch:
env:
AWS_REGION: us-east-1
S3_BUCKET: ${{ secrets.S3_BUCKET }}
CLOUDFRONT_DISTRIBUTION_ID: ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }}
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- 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: ${{ env.AWS_REGION }}
- name: Update metadata
run: |
sed -i "s/BUILD_ID_PLACEHOLDER/${{ github.run_number }}/g" metadata.json
sed -i "s/DEPLOY_TIME_PLACEHOLDER/$(date -u +%Y-%m-%dT%H:%M:%SZ)/g" metadata.json
- name: Sync to S3
run: |
aws s3 sync . s3://${{ env.S3_BUCKET }} \
--exclude ".git/*" \
--exclude ".github/*" \
--exclude "README.md" \
--exclude "terraform/*" \
--delete
- name: Invalidate CloudFront cache
run: |
aws cloudfront create-invalidation \
--distribution-id ${{ env.CLOUDFRONT_DISTRIBUTION_ID }} \
--paths "/*"
- name: Deployment summary
run: |
echo "✅ Deployment complete!"
echo "🌐 CloudFront URL: https://${{ secrets.CLOUDFRONT_DOMAIN }}"
echo "📦 Build: ${{ github.run_number }}"
echo "🔖 Commit: ${{ github.sha }}"
3. Deployment Scripts
deploy.sh
#!/bin/bash
set -euo pipefail
BUCKET_NAME="${1:-}"
CLOUDFRONT_ID="${2:-}"
if [ -z "$BUCKET_NAME" ] || [ -z "$CLOUDFRONT_ID" ]; then
echo "Usage: $0 <bucket-name> <cloudfront-distribution-id>"
exit 1
fi
echo "Deploying to S3 bucket: $BUCKET_NAME"
# Update metadata
sed -i.bak "s/BUILD_ID_PLACEHOLDER/manual-$(date +%s)/g" metadata.json
sed -i.bak "s/DEPLOY_TIME_PLACEHOLDER/$(date -u +%Y-%m-%dT%H:%M:%SZ)/g" metadata.json
rm -f metadata.json.bak
# Sync files to S3
aws s3 sync . "s3://${BUCKET_NAME}" \
--exclude ".git/*" \
--exclude "*.sh" \
--exclude "terraform/*" \
--exclude "*.md" \
--delete
echo "Files uploaded to S3"
# Invalidate CloudFront cache
echo "Invalidating CloudFront cache..."
INVALIDATION_ID=$(aws cloudfront create-invalidation \
--distribution-id "$CLOUDFRONT_ID" \
--paths "/*" \
--query 'Invalidation.Id' \
--output text)
echo "Invalidation created: $INVALIDATION_ID"
echo "Waiting for invalidation to complete..."
aws cloudfront wait invalidation-completed \
--distribution-id "$CLOUDFRONT_ID" \
--id "$INVALIDATION_ID"
echo "✅ Deployment complete!"
Explanation
Static Site Hosting Architecture
User → CloudFront (CDN) → S3 Bucket (Website Files)
↓
Edge Locations (Global)
↓
Fast Content Delivery
Key Components
1. S3 Bucket
Static website hosting:
- Stores HTML, CSS, JS files
- Serves content to CloudFront
- Not publicly accessible (via OAC)
2. CloudFront
CDN distribution:
- Caches content at edge locations
- Provides HTTPS
- Reduces latency globally
- Protects against DDoS
3. Origin Access Control (OAC)
Security:
- S3 bucket not public
- Only CloudFront can access
- More secure than OAI (legacy)
Cache Behavior
default_cache_behavior:
min_ttl: 0 # Minimum cache time
default_ttl: 3600 # 1 hour default
max_ttl: 86400 # 24 hours maximum
Cache invalidation:
aws cloudfront create-invalidation \
--distribution-id DIST_ID \
--paths "/*"
Try to solve the challenge yourself first!
Click "Reveal Solution" when you're ready to see the answer.
Result
Deploy Infrastructure
# Initialize Terraform
cd terraform
terraform init
# Plan deployment
terraform plan -var="bucket_name=my-static-site-bucket"
# Apply
terraform apply -var="bucket_name=my-static-site-bucket"
# Output:
# cloudfront_domain = "d123456789.cloudfront.net"
# cloudfront_distribution_id = "E1234567890ABC"
# website_bucket = "my-static-site-bucket"
Deploy Website
# Manual deployment
./deploy.sh my-static-site-bucket E1234567890ABC
# Output:
# Deploying to S3 bucket: my-static-site-bucket
# upload: ./index.html to s3://my-static-site-bucket/index.html
# upload: ./styles.css to s3://my-static-site-bucket/styles.css
# upload: ./app.js to s3://my-static-site-bucket/app.js
# Files uploaded to S3
# Invalidating CloudFront cache...
# Invalidation created: I1234567890ABC
# Waiting for invalidation to complete...
# ✅ Deployment complete!
Access Website
# Get CloudFront URL
CLOUDFRONT_URL=$(terraform output -raw cloudfront_domain)
echo "Website URL: https://$CLOUDFRONT_URL"
# Test website
curl -I "https://$CLOUDFRONT_URL"
# Output:
# HTTP/2 200
# content-type: text/html
# server: CloudFront
# x-cache: Hit from cloudfront
Validation
Testing Checklist
# 1. S3 bucket exists
aws s3 ls s3://my-static-site-bucket/
# Should list files
# 2. CloudFront distribution active
aws cloudfront get-distribution \
--id E1234567890ABC \
--query 'Distribution.Status'
# Should return "Deployed"
# 3. Website accessible via HTTPS
curl -I https://d123456789.cloudfront.net
# Should return 200 OK
# 4. Content served from CloudFront
curl -I https://d123456789.cloudfront.net | grep x-cache
# Should show "Hit from cloudfront" or "Miss from cloudfront"
# 5. Cache invalidation works
aws cloudfront list-invalidations \
--distribution-id E1234567890ABC
# Should list invalidations
# 6. Custom error pages work
curl -I https://d123456789.cloudfront.net/nonexistent.html
# Should return 404 with custom error page
Best Practices
✅ Do's
- Use CloudFront: Global CDN performance
- Enable HTTPS: Security first
- Set cache headers: Control caching
- Invalidate on deploy: Clear old content
- Use versioned URLs: For assets (app.v1.js)
- Enable compression: Faster transfers
❌ Don'ts
- Don't make S3 public: Use OAC
- Don't skip invalidation: Serve old content
- Don't ignore costs: Monitor usage
- Don't forget 404 pages: User experience
- Don't hardcode URLs: Use environment variables
Links
Share Your Success
Deployed your static site? Share it!
Tag @thedevopsdaily on X with:
- Website URL
- Deployment time
- CloudFront edge locations
- What you deployed
Use hashtags: #AdventOfDevOps #AWS #CloudFront #StaticSite #Day18
Ready to complete this challenge?
Mark this challenge as complete once you've finished the task. We'll track your progress!
Completed this challenge? Share your success!
Tag @thedevopsdaily on X (Twitter) and share your learning journey with the community!
These amazing companies help us create free, high-quality DevOps content for the community
DigitalOcean
Cloud infrastructure for developers
Simple, reliable cloud computing designed for developers
DevDojo
Developer community & tools
Join a community of developers sharing knowledge and tools
Want to support DevOps Daily and reach thousands of developers?
Become a SponsorFound an issue?