CloudFront Terraform Configuration #
File and Folder Structure #
The file and folder structure of the Terraform project looks like this:
aws-cloudfront-terraform-loop
├── certificate.tf
├── cloudfront.tf
├── iam_gitlab.tf
├── outputs.tf
├── s3_bucket.tf
├── terraform.tf
└── variables.tf
Project Folder #
# Create Terraform project folder
TF_PROJECT_NAME=aws-cloudfront-terraform-loop
mkdir $TF_PROJECT_NAME && cd $TF_PROJECT_NAME
Terraform Provider #
- terraform.tf
# Terraform Provider
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
# Provider AWS Region: us-east-1
provider "aws" {
region = "us-east-1"
}
Variables #
- variables.tf
# CloudFront Domains
variable "domains" {
description = "List of domain names"
type = list(string)
default = ["cf-dev.jklug.work", "cf-prod.jklug.work"]
}
# Route 53 Hosted Zone ID
variable "route53_zone_id" {
description = "Route 53 Hosted Zone ID"
type = string
default = "Z05838622L1SISL2KGD4M"
}
# IAM User for GitLab CI
variable "gitlab_ci_iam_user" {
description = "IAM User for GitLab CI"
type = string
default = "gitlab-ci-cf-s3-deploy"
}
# IAM Policy for GitLab CI
variable "gitlab_ci_iam_policy" {
description = "IAM Policy for GitLab CI"
type = string
default = "gitlab-ci-cf-s3-deploy"
}
Managed TLS Certificate #
- certificate.tf
# AWS Managed TLS Certificates
resource "aws_acm_certificate" "managed_cert" {
for_each = toset(var.domains)
domain_name = each.key
validation_method = "DNS"
lifecycle {
create_before_destroy = true
}
}
# Route 53 Entries for Certificate Validation
resource "aws_route53_record" "cert_validation" {
for_each = { for domain in var.domains : domain => tolist(aws_acm_certificate.managed_cert[domain].domain_validation_options) }
zone_id = var.route53_zone_id
name = each.value[0].resource_record_name
type = each.value[0].resource_record_type
records = [each.value[0].resource_record_value]
ttl = 300
}
# Certificate Validation Status
resource "aws_acm_certificate_validation" "cert_validation" {
for_each = aws_acm_certificate.managed_cert
certificate_arn = each.value.arn
validation_record_fqdns = [for record in aws_route53_record.cert_validation : record.fqdn if record.name == each.value.domain_name]
}
S3 Buckets #
- s3_bucket.tf
# S3 Buckets for each domain
resource "aws_s3_bucket" "s3bucket" {
for_each = toset(var.domains)
bucket = replace(each.key, ".", "-") # Replace dots with hyphens for valid S3 names
tags = {
Name = "Bucket for ${each.key}"
}
}
# Bucket Ownership
resource "aws_s3_bucket_ownership_controls" "s3_ownership" {
for_each = aws_s3_bucket.s3bucket
bucket = each.value.id
rule {
object_ownership = "BucketOwnerEnforced"
}
}
object_ownership = "BucketOwnerEnforced"
Bucket owner has full control over uploaded objects in the S3 bucket
CloudFront Distribution #
- cloudfront.tf
# CloudFront Distributions
resource "aws_cloudfront_distribution" "s3_distribution" {
for_each = aws_s3_bucket.s3bucket
origin {
domain_name = each.value.bucket_regional_domain_name
origin_access_control_id = aws_cloudfront_origin_access_control.default.id
origin_id = each.value.id
}
enabled = true
is_ipv6_enabled = false
comment = "CloudFront for ${each.key}"
default_root_object = "index.html"
aliases = [each.key]
default_cache_behavior {
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
target_origin_id = each.value.id
forwarded_values {
query_string = false
cookies {
forward = "none"
}
}
viewer_protocol_policy = "redirect-to-https"
min_ttl = 0
default_ttl = 3600
max_ttl = 86400
}
price_class = "PriceClass_200"
restrictions {
geo_restriction {
restriction_type = "whitelist"
locations = ["AT", "DE"]
}
}
tags = {
Environment = "production"
}
depends_on = [aws_acm_certificate_validation.cert_validation]
viewer_certificate {
acm_certificate_arn = aws_acm_certificate.managed_cert[each.key].arn
ssl_support_method = "sni-only"
minimum_protocol_version = "TLSv1.2_2021"
}
}
# Route 53 CNAME Records for CloudFront
resource "aws_route53_record" "cloudfront_cname" {
for_each = aws_cloudfront_distribution.s3_distribution
zone_id = var.route53_zone_id
name = each.key
type = "CNAME"
ttl = 300
records = [each.value.domain_name]
depends_on = [aws_cloudfront_distribution.s3_distribution]
}
## Permissions
# CloudFront Access Control
resource "aws_cloudfront_origin_access_control" "default" {
name = "s3-oac"
description = "CloudFront access to S3"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
# S3 Bucket Policies for CloudFront Access
resource "aws_s3_bucket_policy" "s3bucket_policy" {
for_each = aws_s3_bucket.s3bucket
bucket = each.value.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = {
Service = "cloudfront.amazonaws.com"
}
Action = "s3:GetObject"
Resource = "${each.value.arn}/*"
Condition = {
StringEquals = {
"AWS:SourceArn" = aws_cloudfront_distribution.s3_distribution[each.key].arn
}
}
}]
})
}
IAM for GitLab #
This Terraform configuration creates an IAM user with access keys, which are added as GitLab CI variables.
Two permissions are attached to the IAM user, one allows data synchronization for the S3 buckets and the other allows cache invalidation for the CloudFront distributions.
- iam_gitlab.tf
# IAM User for GitLab CI Pipeline
resource "aws_iam_user" "gitlab_ci_user" {
name = var.gitlab_ci_iam_user
}
# IAM Policy for GitLab CI Pipeline / Access S3 Buckets
resource "aws_iam_policy" "gitlab_ci_s3_access_policy" {
name = var.gitlab_ci_iam_policy
description = "IAM Policy for S3"
depends_on = [aws_s3_bucket.s3bucket]
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"s3:ListBucket",
"s3:GetBucketLocation"
]
Resource = [for bucket in aws_s3_bucket.s3bucket : bucket.arn]
},
{
Effect = "Allow"
Action = [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject"
]
Resource = [for bucket in aws_s3_bucket.s3bucket : "${bucket.arn}/*"]
}
]
})
}
# IAM Policy for GitLab CI Pipeline / Invalidate CloudFront Cache
resource "aws_iam_policy" "gitlab_cf_policy" {
name = "gitlab_cf_policy"
description = "IAM Policy for CloudFront Invalidation "
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = "cloudfront:CreateInvalidation"
Resource = [for distribution in aws_cloudfront_distribution.s3_distribution : distribution.arn]
}
]
})
}
# Attach S3 Policy to IAM User
resource "aws_iam_user_policy_attachment" "gitlab_ci_s3_access_polic_attachment" {
user = aws_iam_user.gitlab_ci_user.name
policy_arn = aws_iam_policy.gitlab_ci_s3_access_policy.arn
}
# Attach CloudFront Policy to IAM User
resource "aws_iam_user_policy_attachment" "gitlab_ci_cf_policy_attachment" {
user = aws_iam_user.gitlab_ci_user.name
policy_arn = aws_iam_policy.gitlab_cf_policy.arn
}
# IAM Access Key IAM User
resource "aws_iam_access_key" "gitlab_ci_user_key" {
user = aws_iam_user.gitlab_ci_user.name
}
Outputs #
# CloudFront Endpoints
output "cloudfront_endpoints" {
description = "CloudFront endpoints for each domain"
value = { for domain in var.domains : domain => aws_cloudfront_distribution.s3_distribution[domain].domain_name }
}
# CloudFront IDs
output "cloudfront_distribution_ids" {
description = "CloudFront distribution IDs"
value = { for domain, cf in aws_cloudfront_distribution.s3_distribution : domain => cf.id }
}
# S3 Bucket ARNs
output "s3bucket_arns" {
description = "S3 bucket ARNs for each domain"
value = { for domain in var.domains : domain => aws_s3_bucket.s3bucket[domain].arn }
}
# ACM Certificate ARNs
output "acm_certificate_arns" {
description = "ACM certificate ARNs for each domain"
value = { for domain in var.domains : domain => aws_acm_certificate.managed_cert[domain].arn }
}
## IAM User Access Keys: Add to GitLab CI Variables
# Output Access Keys (Make sure to securely store them)
output "gitlab_ci_access_key_id" {
value = aws_iam_access_key.gitlab_ci_user_key.id
sensitive = true
}
output "gitlab_ci_secret_access_key" {
value = aws_iam_access_key.gitlab_ci_user_key.secret
sensitive = true
}
Apply Terraform Configuration #
Initialize Terraform Project #
This will download and install the AWS Terraform provider:
# Initialize the Terraform project
terraform init
Validate Configuration Files #
# Validates the syntax and structure of Terraform configuration files
terraform validate
# Shell output:
Success! The configuration is valid.
Plan the Deployment #
# Dry run / preview changes before applying them
terraform plan
Apply the Configuration #
# Create network stack
terraform apply -auto-approve
# Shell output:
Apply complete! Resources: 21 added, 0 changed, 0 destroyed.
Outputs:
acm_certificate_arns = {
"cf-dev.jklug.work" = "arn:aws:acm:us-east-1:073526172187:certificate/a31f0cc2-4f92-44a3-b72d-79ec9127d620"
"cf-prod.jklug.work" = "arn:aws:acm:us-east-1:073526172187:certificate/c089b3d4-cad1-46cd-a357-9859c914f01e"
}
cloudfront_distribution_ids = {
"cf-dev.jklug.work" = "EGPTOV76MDH05"
"cf-prod.jklug.work" = "E3AA1LSGY83MJZ"
}
cloudfront_endpoints = {
"cf-dev.jklug.work" = "d2tt36bb2oryn1.cloudfront.net"
"cf-prod.jklug.work" = "ddf7a17k6okqm.cloudfront.net"
}
gitlab_ci_access_key_id = <sensitive>
gitlab_ci_secret_access_key = <sensitive>
s3bucket_arns = {
"cf-dev.jklug.work" = "arn:aws:s3:::cf-dev-jklug-work"
"cf-prod.jklug.work" = "arn:aws:s3:::cf-prod-jklug-work"
}
Output IAM Access Keys #
Note: It’s only possible to output the keys once!
# Output Access Key ID and Secret Access Key
terraform output -raw gitlab_ci_access_key_id
terraform output -raw gitlab_ci_secret_access_key
# Shell output:
AKIARCHUALINaccesskey
mHNffr9uEt3PxnYC/piCNpZM+VQsecretaccesskey
GitLab Repository #
CI Variables #
Add the IAM user access keys as GitLab CI variables:
-
Go to: (Project) “Settings” > “CI/CD”
-
Expand the “Variables” section
-
Click “Add variable”
-
Select type: “Variable (default)”
-
Select “Visibility” type “Masked”
-
Unflag “Protect variable”
-
Define the key name:
AWS_ACCESS_KEY_ID
-
Paste the value of the IAM user access key
AKIARCHUALINaccesskey
-
Click “Add variable”
Repeat the same for the secret access key:
-
key
AWS_SECRET_ACCESS_KEY
-
value
mHNffr9uEt3PxnYC/piCNpZM+VQsecretaccesskey
And the AWS region:
-
key
AWS_REGION
-
value
us-east-1
The CI variable section should look like this:

Main Branch #
File and Folder Structure #
The file and folder structure of the main branch looks like this:
aws-cloudfront-multibranch
├── .gitlab-ci.yml # Main CI pipeline manifest
└── README.md
CI Pipeline Manifest #
- .gitlab-ci.yml
stages:
- test
- deploy
variables:
DEV_BUCKET: "cf-dev-jklug-work" # S3 bucket for dev branch
PROD_BUCKET: "cf-prod-jklug-work" # S3 bucket for main branch
DEV_CF_DISTRIBUTION_ID: "EGPTOV76MDH05" # Define CloudFront ID
PROD_CF_DISTRIBUTION_ID: "E3AA1LSGY83MJZ" # Define CloudFront ID
# Tests for the dev branch
test-html:
stage: test
rules:
- if: '$CI_COMMIT_BRANCH == "dev"'
image: node:latest
script:
- npm install -g htmlhint
- htmlhint "public/**/*.html"
test-css:
stage: test
rules:
- if: '$CI_COMMIT_BRANCH == "dev"'
image: node:latest
script:
- npm install -g stylelint stylelint-config-standard
- stylelint "public/css/**/*.css"
test-js:
stage: test
rules:
- if: '$CI_COMMIT_BRANCH == "dev"'
image: node:latest
script:
- npm install -g eslint
- eslint "public/js/**/*.js" --config eslint.config.js
deploy_dev:
stage: deploy
rules:
- if: '$CI_COMMIT_BRANCH == "dev"'
needs: # Run this job only if the 'test' jobs succeed
- job: test-html
- job: test-css
- job: test-js
image:
name: amazon/aws-cli
entrypoint: [""]
script:
# Export varivables
- export AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID
- export AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY
- export AWS_REGION=$AWS_REGION
# Verify AWS CLI version
- echo "Verify AWS CLI version:"
- aws --version
# Configure AWS CLI
- aws configure set aws_access_key_id $AWS_ACCESS_KEY_ID
- aws configure set aws_secret_access_key $AWS_SECRET_ACCESS_KEY
- aws configure set region $AWS_REGION
# Sync data
- if [ "$CI_COMMIT_BRANCH" == "dev" ]; then BUCKET=$DEV_BUCKET; fi
- aws s3 sync public/ s3://$BUCKET --delete
# Invalidate CloudFront Cache
- if [ "$CI_COMMIT_BRANCH" == "dev" ]; then CF_DISTRIBUTION=$DEV_CF_DISTRIBUTION_ID; fi
- aws cloudfront create-invalidation --distribution-id $CF_DISTRIBUTION --paths "/*"
deploy_prod:
stage: deploy
rules:
- if: '$CI_COMMIT_BRANCH == "prod"'
image:
name: amazon/aws-cli
entrypoint: [""]
script:
# Export varivables
- export AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID
- export AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY
- export AWS_REGION=$AWS_REGION
# Verify AWS CLI version
- echo "Verify AWS CLI version:"
- aws --version
# Configure AWS CLI
- aws configure set aws_access_key_id $AWS_ACCESS_KEY_ID
- aws configure set aws_secret_access_key $AWS_SECRET_ACCESS_KEY
- aws configure set region $AWS_REGION
# Sync data
- if [ "$CI_COMMIT_BRANCH" == "prod" ]; then BUCKET=$PROD_BUCKET; fi
- aws s3 sync public/ s3://$BUCKET --delete
# Invalidate CloudFront Cache
- if [ "$CI_COMMIT_BRANCH" == "prod" ]; then CF_DISTRIBUTION=$PROD_CF_DISTRIBUTION_ID; fi
- aws cloudfront create-invalidation --distribution-id $CF_DISTRIBUTION --paths "/*"
Dev Branch #
File and Folder Structure #
The file and folder structure of the dev branch looks like this:
aws-cloudfront-multibranch
├── eslint.config.js # JavaScript test configuration
├── .gitlab-ci.yml # Reference for CI pipeline manifest in main branch
├── public
│ ├── css
│ │ └── styles.css
│ ├── index.html
│ └── js
│ └── scripts.js
├── README.md
└── .stylelintrc.json # CSS test configuration
Create the Dev Branch #
- Create the local “dev” branch
# Create a new "dev" branch and switch to it
git switch --create dev
# Shell output:
Switched to a new branch 'dev'
- Push local “dev” branch to remote repository
# Push local branch and establish tracking relationship between local branch and it's remote counterpart
git push -u origin dev
# Shell output:
To gitlab.jklug.work:static-websites/aws-cloudfront.git
* [new branch] dev -> dev
Branch 'dev' set up to track remote branch 'dev' from 'origin'.
- Verify branches
# Lists local branches
git branch
# Shell output:
* dev
main
# Lists remote branches
git branch -r
# Shell output:
origin/HEAD -> origin/main
origin/dev
origin/main
CI Pipeline Manifest #
- .gitlab-ci.yml
include:
- project: 'static-websites/aws-cloudfront-multibranch'
file: '/.gitlab-ci.yml'
ref: main
CSS Test Configuration #
.stylelintrc.json
{
"extends": "stylelint-config-standard"
}
JavaScript Test Configuration #
- eslint.config.js
export default [
{
ignores: ["node_modules"],
},
{
files: ["public/js/**/*.js"],
languageOptions: {
sourceType: "module",
ecmaVersion: "latest",
},
rules: {
"no-unused-vars": "warn",
"no-console": "off",
"indent": ["warn", 4],
"quotes": ["error", "double"],
"semi": ["error", "always"]
},
},
];
Prod Branch #
File and Folder Structure #
The file and folder structure of the prod branch looks like this:
aws-cloudfront-multibranch
├── eslint.config.js
├── .gitlab-ci.yml # Reference for CI pipeline manifest in main branch
├── public
│ ├── css
│ │ └── styles.css
│ ├── index.html
│ └── js
│ └── scripts.js
├── README.md
└── .stylelintrc.json
Create the Prod Branch #
- Create the local “prod” branch
# Create a new "dev" branch and switch to it
git switch --create prod
# Shell output:
Switched to a new branch 'dev'
- Push local “prod” branch to remote repository
# Push local branch and establish tracking relationship between local branch and it's remote counterpart
git push -u origin prod
# Shell output:
To gitlab.jklug.work:static-websites/aws-cloudfront-multibranch.git
* [new branch] prod -> prod
Branch 'prod' set up to track remote branch 'prod' from 'origin'.
- Verify branches
# Lists local branches
git branch
# Shell output:
dev
main
* prod
# Lists remote branches
git branch -r
# Shell output:
origin/HEAD -> origin/main
origin/dev
origin/main
origin/prod
Static Website Example #
HTML File #
- public/index.html
<!DOCTYPE html>
<html>
<head>
<title>jklug.work</title>
<link rel="stylesheet" type="text/css" href="css/styles.css">
</head>
<body>
<h1>Some HTML</h1>
<p>Hi there, click me.</p>
<!-- Add a button -->
<button id="myButton">Click Me</button>
<!-- Link to the JavaScript file -->
<script src="js/scripts.js"></script>
</body>
</html>
CSS File #
- public/css/styles.css
/* General styling for the body */
body {
font-family: Arial, sans-serif;
background-color: #f0f0f0;
margin: 0;
padding: 0;
/* Flexbox centering */
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh; /* Full viewport height */
}
/* Header styling */
h1 {
color: #333;
margin: 0 0 10px; /* Remove default margin and add spacing */
}
/* Paragraph styling */
p {
color: #666;
font-size: 18px;
margin: 10px 0; /* Add some spacing */
}
/* Button styling */
button {
background-color: #d2691e;
color: white;
border: none;
padding: 10px 20px;
font-size: 16px;
border-radius: 5px;
cursor: pointer;
margin-top: 20px; /* Add space above the button */
}
/* Button hover effect */
button:hover {
background-color: #8b4513;
}
JavaScript File #
- public/js/scripts.js
// Function to handle button clicks
function handleButtonClick() {
const paragraph = document.querySelector("p");
// Toggle text between text
if (paragraph.textContent === "Hi there, click me.") {
paragraph.textContent = "Button was clicked.";
} else {
paragraph.textContent = "Hi there, click me.";
}
}
// Attach event listener when the DOM is ready
document.addEventListener("DOMContentLoaded", () => {
const button = document.querySelector("#myButton");
button.addEventListener("click", handleButtonClick);
});
Verify CloudFront Websites #
# Curl the CloudFront domain name: Dev
curl https://cf-dev.jklug.work
# Curl the CloudFront domain name: Prod
curl https://cf-prod.jklug.work
More #
Manually Trigger Invalidation #
Remove cached files from CloudFront distribution:
# Manually trigger a invalidation
aws cloudfront create-invalidation --distribution-id <DISTRIBUTION_ID> --paths "/*"
Links #
# GitHub repository
https://github.com/jueklu/gitlab-ci-aws-cloudfront/
# Terraform Provider for AWS CloudFront Docu
https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudfront_distribution