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!
We earn commissions when you shop through the links below.
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
Acronis
The most secure backup
Acronis: the most secure backup solution for your data
Pluralsight
Technology skills platform
Expert-led courses in software development, IT ops, data, and cybersecurity
Want to support DevOps Daily and reach thousands of developers?
Become a SponsorFound an issue?