Simplifying Infrastructure Deployment on AWS with GitOps and GitHub Actions - Part 1

What is GitOps?

GitOps is an operational framework that puts Git version control at the center of your operations and software. This means every change you want to make to your code or your infrastructure results in a change being pushed to your version control. GitHub is one of the most popular platforms for Git version control.

In this article, we will learn how to create a reusable and scalable Infrastructure as Code ( IAC ) using Terraform and how to deploy it using the principles of CI/CD ( Continuous Integration and Continuous Deployment ). In the first half of this blog, we will cover how to create a reusable infrastructure as code. Although we use Terraform as a tool to create infrastructure as code, the same principles can be applied to other IAC tools like AWS CloudFormation, Azure Resource Manager Templates, etc.

A sample Infrastructure as Code using Terraform

While there are numerous types of infrastructure and services one can deploy, In this blog let's try to develop a terraform configuration to deploy Route 53 hosted zone and Route 53 records. To get started with Terraform, you can go through the tutorials provided by Hashicorp at https://developer.hashicorp.com/terraform/tutorials/aws-get-started

These are the goals I had in mind while creating the terraform configuration.

  • Keep it simple

  • Keep it generic and follow the principle of 'do not repeat yourself' (DRY)

  • Keep it scalable

Modules enable us to create reusable code in Terraform. First, we will create a module for Route 53 hosted zone and records. A typical directory structure for Terraform IAC is as shown below:

├── modules
│   └── r53_records
│       ├── main.tf
│       └── variables.tf

The code for our infrastructure is created main.tf while all the variables are defined in variables.tf. Depending on the infrastructure you are trying to create you can have multiple .tf files within your module. It could be data sources, locals, etc.

Now let us go ahead and create the code. As described at the beginning of this section, the infrastructure created within this article includes:

  1. Route 53 hosted zone

  2. One or more Route53 records within the hosted zone

While Route 53 supports most of the DNS record types, it also offers a special type of record called alias records. This is a Route 53-specific extension to DNS functionality. Alias records have several advantages:

  1. It can be used to route traffic to other AWS services like CloudFront distributions, S3 buckets, etc.

  2. Unlike CNAME records, alias records can be used for the top node of the domain workspace.

Here is our main.tf

resource "aws_route53_zone" "hosted_zone" {
  name = var.domain_name
}

resource "aws_route53_record" "non_alias_records" {
  count = length(var.non_alias_records)

  zone_id = aws_route53_zone.hosted_zone.zone_id
  name    = var.subdomains[count.index].name
  records = var.subdomains[count.index].records
  ttl     = 300
  type    = var.subdomains[count.index].type
}

resource "aws_route53_record" "alias_records" {
  count = length(var.aliases)
  zone_id = aws_route53_zone.hosted_zone.zone_id
  name    = var.aliases[count.index].name
  type    = "A"

  alias {
    name                   = var.aliases[count.index].target.name
    zone_id                = var.aliases[count.index].target.zone_id
    evaluate_target_health = false
  }
}

The variables.tf is as shown below

# ---------------------------------------------------------------------------------------------------------------------
# Variables
# ---------------------------------------------------------------------------------------------------------------------
variable "domain_name" {
    type = string
    description = "domain name for the hosted zone"
}

variable "non_alias_records" {
    type = list(object({
        name = string
        records = list(string)
        type = string
    }
    ))
    description = "List of non alias records"
    default = []
}
variable "alias_records" {
    type = list(object({
        name = string
        target = object({
            name = string
            zone_id = string
        })
    }
    ))
    description = "List of alias records"
    default = []
}

To keep it simple, not all options available have been included here. For more options, you can refer to the documentation. https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route53_record . For example, you might want to change your variable formats if you want to include an NS record within your IAC.

Upon defining our module, the next step involves deploying an example domain and a few associated records within it. To achieve this, we create a directory called records and define the domains and corresponding records we want to create within it.

Now our directory structure will look like this

├── modules
│   └── r53_records
│       ├── main.tf
│       └── variables.tf
└── records
    ├── domain1.example.com
    │   ├── backend.tf
    │   ├── main.tf
    │   ├── provider.tf
    │   └── records.tf

This is a typical file structure for any terraform configuration. Let us walk through each of the files.

backend.tf - In this file, we will define the backend where the terraform state file will be stored. While Terraform supports different types of backends, we will use the s3 backend in this blog. Here are the contents of this file:

terraform {
  backend "s3"{
    region         = "us-east-1"
    bucket         = "gitops-demo-195368226277"
    key            = "domain1.example.com/terraform.tfstate"
    dynamodb_table = "gitops-demo-195368226277-state-lock"
    encrypt        = true
  }
}

provider.tf - This file describes the provider. Since we will be deploying infrastructure within AWS, we will use the AWS provider. Here is what this file will look like:

provider "aws" {
  region = "us-east-1"
}

main.tf - In the file, we will invoke the module we defined earlier by providing relevant values.

module "r53_records" {
    source  = "../../modules/r53_records"
    domain_name = local.domain_name
    non_alias_records = local.non_alias_records
    alias_records = local.alias_records
}

Key considerations include:

  1. Ease of Expansion: Adding new domains and records to the system is exceptionally straightforward.

  2. Isolated Terraform State: Our approach involves isolating the Terraform state file for each domain. As the number of records and domains increases, this strategy becomes increasingly valuable for efficient management.

The full code can be found at https://github.com/sampritavh/terraform-deployment-demo.

In the next part, we will go through the process of creating a CI/CD pipeline to deploy this using GitHub Actions.