Skip to main content

GitLab CI Pipeline - AWS CloudFront Distributions: Deploy Static Website to S3 Buckets, Run CloudFront Invalidations, Code Tests for HTML, CSS and JavaScript on Dev Branch

2307 words·
GitLab GitLab CI CI Pipeline AWS AWS CLI CloudFront S3 Terraform Linting
Table of Contents
GitHub Repository Available



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