As cloud-native architectures continue to gain momentum, Kubernetes has emerged as the de facto standard for container orchestration. Amazon Elastic Kubernetes Service (EKS) is a popular managed Kubernetes service that simplifies the deployment and management of containerized applications on AWS. To streamline the process of provisioning an EKS cluster and automate infrastructure management, developers and DevOps teams often turn to tools like Terraform, Terragrunt, and GitHub Actions.
In this article, we will explore the seamless integration of these tools to provision an EKS cluster on AWS, delving into the benefits of using them in combination, the key concepts involved, and the step-by-step process to set up an EKS cluster using infrastructure-as-code principles.
Whether you are a developer, a DevOps engineer, or an infrastructure enthusiast, this article will serve as a comprehensive guide to help you leverage the power of Terraform, Terragrunt, and GitHub Actions in provisioning and managing your EKS clusters efficiently.
Before diving in though, there are a few things to note.
Disclaimer
a) Given that we'll use Terraform and Terragrunt to provision our infrastructure, familiarity with these two is required to be able to follow along.
b) Given that we'll use GitHub Actions to automate the provisioning of our infrastructure, familiarity with the tool is required to be able to follow along as well.
c) Some basic understanding of Docker and container orchestration with Kubernetes will also help to follow along.
These are the steps we'll follow to provision our EKS cluster:
Write Terraform code for building blocks.
Write Terragrunt code to provision infrastructure.
Create a GitHub Actions workflow and delegate the infrastructure provisioning task to it.
Add a GitHub Actions workflow job to destroy our infrastructure when we're done.
Below is a diagram of the VPC and its components that we'll create, bearing in mind that the control plane components will be deployed in an EKS-managed VPC:
1. Write Terraform code for building blocks
Each building block will have the following files:
main.tf
outputs.tf
provider.tf
variables.tf
We'll be using version 4.x of the AWS provider for Terraform, so the provider.tf file will be the same in all building blocks:
provider.tf
terraform {
required_version = ">= 1.4.2"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 4.0"
}
}
}
provider "aws" {
access_key = var.AWS_ACCESS_KEY_ID
secret_key = var.AWS_SECRET_ACCESS_KEY
region = var.AWS_REGION
token = var.AWS_SESSION_TOKEN
}
We can see a few variables here that will also be used by all building blocks in the variables.tf file:
variables.tf
variable "AWS_ACCESS_KEY_ID" {
type = string
}
variable "AWS_SECRET_ACCESS_KEY" {
type = string
}
variable "AWS_SESSION_TOKEN" {
type = string
default = null
}
variable "AWS_REGION" {
type = string
}
So when defining the building blocks in the following, these variables won't be explicitly defined, but you should have them in your variables.tf file.
a) VPC building block
main.tf
resource "aws_vpc" "vpc" {
cidr_block = var.vpc_cidr
instance_tenancy = var.instance_tenancy
enable_dns_support = var.enable_dns_support
enable_dns_hostnames = var.enable_dns_hostnames
assign_generated_ipv6_cidr_block = var.assign_generated_ipv6_cidr_block
tags = merge(var.vpc_tags, {
Name = var.vpc_name
})
}
variables.tf
variable "vpc_cidr" {
type = string
}
variable "vpc_name" {
type = string
}
variable "instance_tenancy" {
type = string
default = "default"
}
variable "enable_dns_support" {
type = bool
default = true
}
variable "enable_dns_hostnames" {
type = bool
}
variable "assign_generated_ipv6_cidr_block" {
type = bool
default = false
}
variable "vpc_tags" {
type = map(string)
}
outputs.tf
output "vpc_id" {
value = aws_vpc.vpc.id
}
b) Internet Gateway building block
main.tf
resource "aws_internet_gateway" "igw" {
vpc_id = var.vpc_id
tags = merge(var.tags, {
Name = var.name
})
}
variables.tf
variable "vpc_id" {
type = string
}
variable "name" {
type = string
}
variable "tags" {
type = map(string)
}
outputs.tf
output "igw_id" {
value = aws_internet_gateway.igw.id
}
c) Route Table building block
main.tf
resource "aws_route_table" "route_tables" {
for_each = { for rt in var.route_tables : rt.name => rt }
vpc_id = each.value.vpc_id
dynamic "route" {
for_each = { for route in each.value.routes : route.cidr_block => route if each.value.is_igw_rt }
content {
cidr_block = route.value.cidr_block
gateway_id = route.value.igw_id
}
}
dynamic "route" {
for_each = { for route in each.value.routes : route.cidr_block => route if !each.value.is_igw_rt }
content {
cidr_block = route.value.cidr_block
nat_gateway_id = route.value.nat_gw_id
}
}
tags = merge(each.value.tags, {
Name = each.value.name
})
}
variables.tf
variable "route_tables" {
type = list(object({
name = string
vpc_id = string
is_igw_rt = bool
routes = list(object({
cidr_block = string
igw_id = optional(string)
nat_gw_id = optional(string)
}))
tags = map(string)
}))
}
outputs.tf
output "route_table_ids" {
value = values(aws_route_table.route_tables)[*].id
}
d) Subnet building block
main.tf
# Create public subnets
resource "aws_subnet" "public_subnets" {
for_each = { for subnet in var.subnets : subnet.name => subnet if subnet.is_public }
vpc_id = each.value.vpc_id
cidr_block = each.value.cidr_block
availability_zone = each.value.availability_zone
map_public_ip_on_launch = each.value.map_public_ip_on_launch
private_dns_hostname_type_on_launch = each.value.private_dns_hostname_type_on_launch
tags = merge(each.value.tags, {
Name = each.value.name
})
}
# Associate public subnets with their route table
resource "aws_route_table_association" "public_subnets" {
for_each = { for subnet in var.subnets : subnet.name => subnet if subnet.is_public }
subnet_id = aws_subnet.public_subnets[each.value.name].id
route_table_id = each.value.route_table_id
}
# Create private subnets
resource "aws_subnet" "private_subnets" {
for_each = { for subnet in var.subnets : subnet.name => subnet if !subnet.is_public }
vpc_id = each.value.vpc_id
cidr_block = each.value.cidr_block
availability_zone = each.value.availability_zone
private_dns_hostname_type_on_launch = each.value.private_dns_hostname_type_on_launch
tags = merge(each.value.tags, {
Name = each.value.name
})
}
# Associate private subnets with their route table
resource "aws_route_table_association" "private_subnets" {
for_each = { for subnet in var.subnets : subnet.name => subnet if !subnet.is_public }
subnet_id = aws_subnet.private_subnets[each.value.name].id
route_table_id = each.value.route_table_id
}
variables.tf
variable "subnets" {
type = list(object({
name = string
vpc_id = string
cidr_block = string
availability_zone = optional(string)
map_public_ip_on_launch = optional(bool, true)
private_dns_hostname_type_on_launch = optional(string, "resource-name")
is_public = optional(bool, true)
route_table_id = string
tags = map(string)
}))
}
outputs.tf
output "public_subnets" {
value = values(aws_subnet.public_subnets)[*].id
}
output "private_subnets" {
value = values(aws_subnet.private_subnets)[*].id
}
e) Elastic IP building block
main.tf
resource "aws_eip" "eip" {}
outputs.tf
output "eip_id" {
value = aws_eip.eip.allocation_id
}
f) NAT Gateway building block
main.tf
resource "aws_nat_gateway" "nat_gw" {
allocation_id = var.eip_id
subnet_id = var.subnet_id
tags = merge(var.tags, {
Name = var.name
})
}
variables.tf
variable "name" {
type = string
}
variable "eip_id" {
type = string
}
variable "subnet_id" {
type = string
description = "The ID of the public subnet in which the NAT Gateway should be placed"
}
variable "tags" {
type = map(string)
}
outputs.tf
output "nat_gw_id" {
value = aws_nat_gateway.nat_gw.id
}
g) NACL
main.tf
resource "aws_network_acl" "nacls" {
for_each = { for nacl in var.nacls : nacl.name => nacl }
vpc_id = each.value.vpc_id
dynamic "egress" {
for_each = { for rule in each.value.egress : rule.rule_no => rule }
content {
protocol = egress.value.protocol
rule_no = egress.value.rule_no
action = egress.value.action
cidr_block = egress.value.cidr_block
from_port = egress.value.from_port
to_port = egress.value.to_port
}
}
dynamic "ingress" {
for_each = { for rule in each.value.ingress : rule.rule_no => rule }
content {
protocol = ingress.value.protocol
rule_no = ingress.value.rule_no
action = ingress.value.action
cidr_block = ingress.value.cidr_block
from_port = ingress.value.from_port
to_port = ingress.value.to_port
}
}
tags = merge(each.value.tags, {
Name = each.value.name
})
}
resource "aws_network_acl_association" "nacl_associations" {
for_each = { for nacl in var.nacls : "${nacl.name}_${nacl.subnet_id}" => nacl }
network_acl_id = aws_network_acl.nacls[each.value.name].id
subnet_id = each.value.subnet_id
}
variables.tf
variable "nacls" {
type = list(object({
name = string
vpc_id = string
egress = list(object({
protocol = string
rule_no = number
action = string
cidr_block = string
from_port = number
to_port = number
}))
ingress = list(object({
protocol = string
rule_no = number
action = string
cidr_block = string
from_port = number
to_port = number
}))
subnet_id = string
tags = map(string)
}))
}
outputs.tf
output "nacls" {
value = values(aws_network_acl.nacls)[*].id
}
output "nacl_associations" {
value = values(aws_network_acl_association.nacl_associations)[*].id
}
h) Security Group building block
main.tf
resource "aws_security_group" "security_group" {
name = var.name
description = var.description
vpc_id = var.vpc_id
# Ingress rules
dynamic "ingress" {
for_each = var.ingress_rules
content {
from_port = ingress.value.from_port
to_port = ingress.value.to_port
protocol = ingress.value.protocol
cidr_blocks = ingress.value.cidr_blocks
}
}
# Egress rules
dynamic "egress" {
for_each = var.egress_rules
content {
from_port = egress.value.from_port
to_port = egress.value.to_port
protocol = egress.value.protocol
cidr_blocks = egress.value.cidr_blocks
}
}
tags = merge(var.tags, {
Name = var.name
})
}
variables.tf
variable "vpc_id" {
type = string
}
variable "name" {
type = string
}
variable "description" {
type = string
}
variable "ingress_rules" {
type = list(object({
protocol = string
from_port = string
to_port = string
cidr_blocks = list(string)
}))
default = []
}
variable "egress_rules" {
type = list(object({
protocol = string
from_port = string
to_port = string
cidr_blocks = list(string)
}))
default = []
}
variable "tags" {
type = map(string)
}
outputs.tf
output "security_group_id" {
value = aws_security_group.security_group.id
}
i) EC2 building block
main.tf
# AMI
data "aws_ami" "ami" {
most_recent = var.most_recent_ami
owners = var.owners
filter {
name = var.ami_name_filter
values = var.ami_values_filter
}
}
# EC2 Instance
resource "aws_instance" "ec2_instance" {
ami = data.aws_ami.ami.id
iam_instance_profile = var.use_instance_profile ? var.instance_profile_name : null
instance_type = var.instance_type
subnet_id = var.subnet_id
vpc_security_group_ids = var.existing_security_group_ids
associate_public_ip_address = var.assign_public_ip
key_name = var.uses_ssh ? var.keypair_name : null
user_data = var.use_userdata ? file(var.userdata_script_path) : null
user_data_replace_on_change = var.use_userdata ? var.user_data_replace_on_change : null
tags = merge(
{
Name = var.instance_name
},
var.extra_tags
)
}
variables.tf
variable "most_recent_ami" {
type = bool
}
variable "owners" {
type = list(string)
default = ["amazon"]
}
variable "ami_name_filter" {
type = string
default = "name"
}
variable "ami_values_filter" {
type = list(string)
default = ["al2023-ami-2023.*-x86_64"]
}
variable "use_instance_profile" {
type = bool
default = false
}
variable "instance_profile_name" {
type = string
}
variable "instance_name" {
description = "Name of the instance"
type = string
}
variable "subnet_id" {
description = "ID of the subnet"
type = string
}
variable "instance_type" {
description = "Type of EC2 instance"
type = string
default = "t2.micro"
}
variable "assign_public_ip" {
type = bool
default = true
}
variable "extra_tags" {
description = "Additional tags for EC2 instances"
type = map(string)
default = {}
}
variable "existing_security_group_ids" {
description = "security group IDs for EC2 instances"
type = list(string)
}
variable "uses_ssh" {
type = bool
}
variable "keypair_name" {
type = string
}
variable "use_userdata" {
description = "Whether to use userdata"
type = bool
default = false
}
variable "userdata_script_path" {
description = "Path to the userdata script"
type = string
}
variable "user_data_replace_on_change" {
type = bool
}
outputs.tf
output "instance_id" {
value = aws_instance.ec2_instance.id
}
output "instance_arn" {
value = aws_instance.ec2_instance.arn
}
output "instance_private_ip" {
value = aws_instance.ec2_instance.private_ip
}
output "instance_public_ip" {
value = aws_instance.ec2_instance.public_ip
}
output "instance_public_dns" {
value = aws_instance.ec2_instance.public_dns
}
j) IAM Role building block
main.tf
data "aws_iam_policy_document" "assume_role" {
statement {
effect = "Allow"
dynamic "principals" {
for_each = { for principal in var.principals : principal.type => principal }
content {
type = principals.value.type
identifiers = principals.value.identifiers
}
}
actions = ["sts:AssumeRole"]
dynamic "condition" {
for_each = var.is_external ? [var.condition] : []
content {
test = condition.value.test
variable = condition.value.variable
values = condition.value.values
}
}
}
}
data "aws_iam_policy_document" "policy_document" {
dynamic "statement" {
for_each = { for statement in var.policy_statements : statement.sid => statement }
content {
effect = "Allow"
actions = statement.value.actions
resources = statement.value.resources
dynamic "condition" {
for_each = statement.value.has_condition ? [statement.value.condition] : []
content {
test = condition.value.test
variable = condition.value.variable
values = condition.value.values
}
}
}
}
}
resource "aws_iam_role" "role" {
name = var.role_name
assume_role_policy = data.aws_iam_policy_document.assume_role.json
}
resource "aws_iam_role_policy" "policy" {
count = length(var.policy_statements) > 0 && var.policy_name != "" ? 1 : 0
name = var.policy_name
role = aws_iam_role.role.id
policy = data.aws_iam_policy_document.policy_document.json
}
resource "aws_iam_role_policy_attachment" "attachment" {
for_each = { for attachment in var.policy_attachments : attachment.arn => attachment }
policy_arn = each.value.arn
role = aws_iam_role.role.name
}
variables.tf
variable "principals" {
type = list(object({
type = string
identifiers = list(string)
}))
}
variable "is_external" {
type = bool
default = false
}
variable "condition" {
type = object({
test = string
variable = string
values = list(string)
})
default = {
test = "test"
variable = "variable"
values = ["values"]
}
}
variable "role_name" {
type = string
}
variable "policy_name" {
type = string
}
variable "policy_attachments" {
type = list(object({
arn = string
}))
default = []
}
variable "policy_statements" {
type = list(object({
sid = string
actions = list(string)
resources = list(string)
has_condition = optional(bool, false)
condition = optional(object({
test = string
variable = string
values = list(string)
}))
}))
default = [
{
sid = "CloudWatchLogsPermissions"
actions = [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:DescribeLogGroups",
"logs:DescribeLogStreams",
"logs:PutLogEvents",
"logs:GetLogEvents",
"logs:FilterLogEvents",
],
resources = ["*"]
}
]
}
outputs.tf
output "role_arn" {
value = aws_iam_role.role.arn
}
output "role_name" {
value = aws_iam_role.role.name
}
output "unique_id" {
value = aws_iam_role.role.unique_id
}
k) Instance Profile building block
main.tf
# Instance Profile
resource "aws_iam_instance_profile" "instance_profile" {
name = var.instance_profile_name
path = var.path
role = var.iam_role_name
tags = merge(var.instance_profile_tags, {
Name = var.instance_profile_name
})
}
variables.tf
variable "instance_profile_name" {
type = string
description = "(Optional, Forces new resource) Name of the instance profile. If omitted, Terraform will assign a random, unique name. Conflicts with name_prefix. Can be a string of characters consisting of upper and lowercase alphanumeric characters and these special characters: _, +, =, ,, ., @, -. Spaces are not allowed."
}
variable "iam_role_name" {
type = string
description = "(Optional) Name of the role to add to the profile."
}
variable "path" {
type = string
default = "/"
description = "(Optional, default ' / ') Path to the instance profile. For more information about paths, see IAM Identifiers in the IAM User Guide. Can be a string of characters consisting of either a forward slash (/) by itself or a string that must begin and end with forward slashes. Can include any ASCII character from the ! (\u0021) through the DEL character (\u007F), including most punctuation characters, digits, and upper and lowercase letters."
}
variable "instance_profile_tags" {
type = map(string)
}
outputs.tf
output "arn" {
value = aws_iam_instance_profile.instance_profile.arn
}
output "name" {
value = aws_iam_instance_profile.instance_profile.name
}
output "id" {
value = aws_iam_instance_profile.instance_profile.id
}
output "unique_id" {
value = aws_iam_instance_profile.instance_profile.unique_id
}
l) EKS Cluster building block
main.tf
# EKS Cluster
resource "aws_eks_cluster" "cluster" {
name = var.name
enabled_cluster_log_types = var.enabled_cluster_log_types
role_arn = var.cluster_role_arn
version = var.cluster_version
vpc_config {
subnet_ids = var.subnet_ids
}
}
variables.tf
variable "name" {
type = string
description = "(Required) Name of the cluster. Must be between 1-100 characters in length. Must begin with an alphanumeric character, and must only contain alphanumeric characters, dashes and underscores (^[0-9A-Za-z][A-Za-z0-9\\-_]+$)."
}
variable "enabled_cluster_log_types" {
type = list(string)
description = "(Optional) List of the desired control plane logging to enable."
default = []
}
variable "cluster_role_arn" {
type = string
description = "(Required) ARN of the IAM role that provides permissions for the Kubernetes control plane to make calls to AWS API operations on your behalf."
}
variable "subnet_ids" {
type = list(string)
description = "(Required) List of subnet IDs. Must be in at least two different availability zones. Amazon EKS creates cross-account elastic network interfaces in these subnets to allow communication between your worker nodes and the Kubernetes control plane."
}
variable "cluster_version" {
type = string
description = "(Optional) Desired Kubernetes master version. If you do not specify a value, the latest available version at resource creation is used and no upgrades will occur except those automatically triggered by EKS. The value must be configured and increased to upgrade the version when desired. Downgrades are not supported by EKS."
default = null
}
outputs.tf
output "arn" {
value = aws_eks_cluster.cluster.arn
}
output "endpoint" {
value = aws_eks_cluster.cluster.endpoint
}
output "id" {
value = aws_eks_cluster.cluster.id
}
output "kubeconfig-certificate-authority-data" {
value = aws_eks_cluster.cluster.certificate_authority[0].data
}
output "name" {
value = aws_eks_cluster.cluster.name
}
output "oidc_tls_issuer" {
value = aws_eks_cluster.cluster.identity[0].oidc[0].issuer
}
output "version" {
value = aws_eks_cluster.cluster.version
}
m) EKS Add-ons building block
main.tf
# EKS Add-On
resource "aws_eks_addon" "addon" {
for_each = { for addon in var.addons : addon.name => addon }
cluster_name = var.cluster_name
addon_name = each.value.name
addon_version = each.value.version
}
variables.tf
variable "addons" {
type = list(object({
name = string
version = string
}))
description = "(Required) Name of the EKS add-on."
}
variable "cluster_name" {
type = string
description = "(Required) Name of the EKS Cluster. Must be between 1-100 characters in length. Must begin with an alphanumeric character, and must only contain alphanumeric characters, dashes and underscores (^[0-9A-Za-z][A-Za-z0-9\\-_]+$)."
}
outputs.tf
output "arns" {
value = values(aws_eks_addon.addon)[*].arn
}
n) EKS Node Group building block
main.tf
# EKS node group
resource "aws_eks_node_group" "node_group" {
cluster_name = var.cluster_name
node_group_name = var.node_group_name
node_role_arn = var.node_role_arn
subnet_ids = var.subnet_ids
version = var.cluster_version
ami_type = var.ami_type
capacity_type = var.capacity_type
disk_size = var.disk_size
instance_types = var.instance_types
scaling_config {
desired_size = var.scaling_config.desired_size
max_size = var.scaling_config.max_size
min_size = var.scaling_config.min_size
}
update_config {
max_unavailable = var.update_config.max_unavailable
max_unavailable_percentage = var.update_config.max_unavailable_percentage
}
}
variables.tf
variable "cluster_name" {
type = string
description = "(Required) Name of the EKS Cluster. Must be between 1-100 characters in length. Must begin with an alphanumeric character, and must only contain alphanumeric characters, dashes and underscores (^[0-9A-Za-z][A-Za-z0-9\\-_]+$)."
}
variable "node_group_name" {
type = string
description = "(Optional) Name of the EKS Node Group. If omitted, Terraform will assign a random, unique name. Conflicts with node_group_name_prefix. The node group name can't be longer than 63 characters. It must start with a letter or digit, but can also include hyphens and underscores for the remaining characters."
}
variable "node_role_arn" {
type = string
description = "(Required) Amazon Resource Name (ARN) of the IAM Role that provides permissions for the EKS Node Group."
}
variable "scaling_config" {
type = object({
desired_size = number
max_size = number
min_size = number
})
default = {
desired_size = 1
max_size = 1
min_size = 1
}
description = "(Required) Configuration block with scaling settings."
}
variable "subnet_ids" {
type = list(string)
description = "(Required) Identifiers of EC2 Subnets to associate with the EKS Node Group. These subnets must have the following resource tag: kubernetes.io/cluster/CLUSTER_NAME (where CLUSTER_NAME is replaced with the name of the EKS Cluster)."
}
variable "update_config" {
type = object({
max_unavailable_percentage = optional(number)
max_unavailable = optional(number)
})
}
variable "cluster_version" {
type = string
description = "(Optional) Kubernetes version. Defaults to EKS Cluster Kubernetes version. Terraform will only perform drift detection if a configuration value is provided."
default = null
}
variable "ami_type" {
type = string
description = "(Optional) Type of Amazon Machine Image (AMI) associated with the EKS Node Group. Valid values are: AL2_x86_64 | AL2_x86_64_GPU | AL2_ARM_64 | CUSTOM | BOTTLEROCKET_ARM_64 | BOTTLEROCKET_x86_64 | BOTTLEROCKET_ARM_64_NVIDIA | BOTTLEROCKET_x86_64_NVIDIA | WINDOWS_CORE_2019_x86_64 | WINDOWS_FULL_2019_x86_64 | WINDOWS_CORE_2022_x86_64 | WINDOWS_FULL_2022_x86_64 | AL2023_x86_64_STANDARD | AL2023_ARM_64_STANDARD"
default = "AL2023_x86_64_STANDARD"
}
variable "capacity_type" {
type = string
description = "(Optional) Type of capacity associated with the EKS Node Group. Valid values: ON_DEMAND, SPOT."
default = "ON_DEMAND"
}
variable "disk_size" {
type = number
description = "(Optional) Disk size in GiB for worker nodes. Defaults to 20."
default = 20
}
variable "instance_types" {
type = list(string)
description = "(Required) Set of instance types associated with the EKS Node Group. Defaults to [\"t3.medium\"]."
default = ["t3.medium"]
}
outputs.tf
output "arn" {
value = aws_eks_node_group.node_group.arn
}
o) IAM OIDC building block (to allow pods to assume IAM roles)
main.tf
data "tls_certificate" "tls" {
url = var.oidc_issuer
}
resource "aws_iam_openid_connect_provider" "provider" {
client_id_list = var.client_id_list
thumbprint_list = data.tls_certificate.tls.certificates[*].sha1_fingerprint
url = data.tls_certificate.tls.url
}
data "aws_iam_policy_document" "assume_role_policy" {
statement {
actions = ["sts:AssumeRoleWithWebIdentity"]
effect = "Allow"
condition {
test = "StringEquals"
variable = "${replace(aws_iam_openid_connect_provider.provider.url, "https://", "")}:sub"
values = ["system:serviceaccount:kube-system:aws-node"]
}
principals {
identifiers = [aws_iam_openid_connect_provider.provider.arn]
type = "Federated"
}
}
}
resource "aws_iam_role" "role" {
assume_role_policy = data.aws_iam_policy_document.assume_role_policy.json
name = var.role_name
}
variables.tf
variable "role_name" {
type = string
description = "(Required) Name of the IAM role."
}
variable "client_id_list" {
type = list(string)
default = ["sts.amazonaws.com"]
}
variable "oidc_issuer" {
type = string
}
outputs.tf
output "provider_arn" {
value = aws_iam_openid_connect_provider.provider.arn
}
output "provider_id" {
value = aws_iam_openid_connect_provider.provider.id
}
output "provider_url" {
value = aws_iam_openid_connect_provider.provider.url
}
output "role_arn" {
value = aws_iam_role.role.arn
}
With the building blocks defined, we can now version them into GitHub repositories and use them in the next step to develop our Terragrunt code.
2. Write Terragrunt code to provision infrastructure
Our Terragrunt code will have the following directory structure:
infra-live/
<environment>/
<module_1>/
terragrunt.hcl
<module_2>/
terragrunt.hcl
...
<module_n>/
terragrunt.hcl
terragrunt.hcl
For our article, we'll only have a dev directory. This directory will contain directories that will represent the different specific resources we'll want to create.
Our final folder structure will be:
infra-live/
dev/
bastion-ec2/
terragrunt.hcl
user-data.sh
bastion-instance-profile/
terragrunt.hcl
bastion-role/
terragrunt.hcl
eks-addons/
terragrunt.hcl
eks-cluster/
terragrunt.hcl
eks-cluster-role/
terragrunt.hcl
eks-node-group/
terragrunt.hcl
eks-pod-iam/
terragrunt.hcl
internet-gateway/
terragrunt.hcl
nacl/
terragrunt.hcl
nat-gateway/
terragrunt.hcl
nat-gw-eip/
terragrunt.hcl
private-route-table/
terragrunt.hcl
private-subnets/
terragrunt.hcl
public-route-table/
terragrunt.hcl
public-subnets/
terragrunt.hcl
security-group/
terragrunt.hcl
vpc/
terragrunt.hcl
worker-node-role/
terragrunt.hcl
.gitignore
terragrunt.hcl
a) infra-live/terragrunt.hcl
Our root terragrunt.hcl file will contain the configuration for our remote Terraform state. We'll use an S3 bucket in AWS to store our Terraform state file, and the name of our S3 bucket must be unique for it to be successfully created. This bucket must be created before applying any terragrunt configuration. My S3 bucket is in the N. Virginia region (us-east-1).
generate "backend" {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
contents = <<EOF
terraform {
backend "s3" {
bucket = "<s3_bucket_name>"
key = "infra-live/${path_relative_to_include()}/terraform.tfstate"
region = "us-east-1"
encrypt = true
}
}
EOF
}
Make sure you replace with the name of your own S3 bucket.
b) infra-live/dev/vpc/terragrunt.hcl
This module uses the VPC building block to create our VPC.
Our VPC CIDR will be 10.0.0.0/16
.
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git@github.com:<name_or_org>/vpc.git"
}
inputs = {
vpc_cidr = "10.0.0.0/16"
vpc_name = "eks-demo-vpc"
enable_dns_hostnames = true
vpc_tags = {}
}
The values passed in the inputs section are the variables that are defined in the building blocks.
For this module and the following modules, we won't be passing the variables AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_REGION since such credentials (bar the AWS_REGION variable) are sensitive. You'll have to add them as secrets in the GitHub repository you'll create to version your Terragrunt code.
c) infra-live/dev/internet-gateway/terragrunt.hcl
This module uses the Internet Gateway building block as its Terraform source to create our VPC's internet gateway.
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git@github.com:<name_or_org>/internet-gateway.git"
}
dependency "vpc" {
config_path = "../vpc"
}
inputs = {
vpc_id = dependency.vpc.outputs.vpc_id
name = "eks-demo-igw"
tags = {}
}
d) infra-live/dev/public-route-table/terragrunt.hcl
This module uses the Route Table building block as its Terraform source to create our VPC's public route table to be associated with the public subnet we'll create next.
It also adds a route to direct all internet traffic to the internet gateway.
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git@github.com:<name_or_org>/route-table.git"
}
dependency "vpc" {
config_path = "../vpc"
}
dependency "igw" {
config_path = "../internet-gateway"
}
inputs = {
route_tables = [
{
name = "eks-demo-public-rt"
vpc_id = dependency.vpc.outputs.vpc_id
is_igw_rt = true
routes = [
{
cidr_block = "0.0.0.0/0"
igw_id = dependency.igw.outputs.igw_id
}
]
tags = {}
}
]
}
e) infra-live/dev/public-subnets/terragrunt.hcl
This module uses the Subnet building block as its Terraform source to create our VPC's public subnet and associate it with the public route table.
The CIDR for the public subnet will be 10.0.0.0/24.
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git@github.com:<name_or_org>/subnet.git"
}
dependency "vpc" {
config_path = "../vpc"
}
dependency "public-route-table" {
config_path = "../public-route-table"
}
inputs = {
subnets = [
{
name = "eks-demo-public-subnet"
vpc_id = dependency.vpc.outputs.vpc_id
cidr_block = "10.0.0.0/24"
availability_zone = "us-east-1a"
map_public_ip_on_launch = true
private_dns_hostname_type_on_launch = "resource-name"
is_public = true
route_table_id = dependency.public-route-table.outputs.route_table_ids[0]
tags = {}
},
{
name = "eks-demo-rds-subnet-a"
vpc_id = dependency.vpc.outputs.vpc_id
cidr_block = "10.0.1.0/24"
availability_zone = "us-east-1a"
map_public_ip_on_launch = true
private_dns_hostname_type_on_launch = "resource-name"
is_public = true
route_table_id = dependency.public-route-table.outputs.route_table_ids[0]
tags = {}
},
{
name = "eks-demo-rds-subnet-b"
vpc_id = dependency.vpc.outputs.vpc_id
cidr_block = "10.0.2.0/24"
availability_zone = "us-east-1b"
map_public_ip_on_launch = true
private_dns_hostname_type_on_launch = "resource-name"
is_public = true
route_table_id = dependency.public-route-table.outputs.route_table_ids[0]
tags = {}
}
]
}
f) infra-live/dev/nat-gw-eip/terragrunt.hcl
This module uses the Elastic IP building block as its Terraform source to create a static IP in our VPC which we'll associate with the NAT gateway we'll create next.
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git@github.com:<name_or_org>/eip.git"
}
dependency "vpc" {
config_path = "../vpc"
}
inputs = {}
g) infra-live/dev/nat-gateway/terragrunt.hcl
This module uses the NAT Gateway building block as its Terraform to create a NAT Gateway that we'll place in our VPC's public subnet. It will have the previously created elastic IP attached to it.
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git@github.com:<name_or_org>/nat-gateway.git"
}
dependency "eip" {
config_path = "../nat-gw-eip"
}
dependency "public-subnets" {
config_path = "../public-subnets"
}
inputs = {
eip_id = dependency.eip.outputs.eip_id
subnet_id = dependency.public-subnets.outputs.public_subnets[0]
name = "eks-demo-nat-gw"
tags = {}
}
h) infra-live/dev/private-route-table/terragrunt.hcl
This module uses the Route Table building block as its Terraform source to create our VPC's private route table to be associated with the private subnets we'll create next.
It also adds a route to direct all internet traffic to the NAT gateway.
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git@github.com:<name_or_org>/route-table.git"
}
dependency "vpc" {
config_path = "../vpc"
}
dependency "nat-gw" {
config_path = "../nat-gateway"
}
inputs = {
route_tables = [
{
name = "eks-demo-private-rt"
vpc_id = dependency.vpc.outputs.vpc_id
is_igw_rt = false
routes = [
{
cidr_block = "0.0.0.0/0"
nat_gw_id = dependency.nat-gw.outputs.nat_gw_id
}
]
tags = {}
}
]
}
i) infra-live/dev/private-subnets/terragrunt.hcl
This module uses the Subnet building block as its Terraform source to create our VPC's private subnets and associate them with the private route table.
The CIDRs for the app private subnets will be 10.0.100.0/24 (us-east-1a) and 10.0.200.0/24 (us-east-1b), and those for the DB private subnets will be 10.0.10.0/24 (us-east-1a) and 10.0.20.0/24 (us-east-1b).
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git@github.com:<name_or_org>/subnet.git"
}
dependency "vpc" {
config_path = "../vpc"
}
dependency "private-route-table" {
config_path = "../private-route-table"
}
inputs = {
subnets = [
{
name = "eks-demo-app-subnet-a"
vpc_id = dependency.vpc.outputs.vpc_id
cidr_block = "10.0.100.0/24"
availability_zone = "us-east-1a"
map_public_ip_on_launch = false
private_dns_hostname_type_on_launch = "resource-name"
is_public = false
route_table_id = dependency.private-route-table.outputs.route_table_ids[0]
tags = {}
},
{
name = "eks-demo-app-subnet-b"
vpc_id = dependency.vpc.outputs.vpc_id
cidr_block = "10.0.200.0/24"
availability_zone = "us-east-1b"
map_public_ip_on_launch = false
private_dns_hostname_type_on_launch = "resource-name"
is_public = false
route_table_id = dependency.private-route-table.outputs.route_table_ids[0]
tags = {}
},
{
name = "eks-demo-data-subnet-a"
vpc_id = dependency.vpc.outputs.vpc_id
cidr_block = "10.0.10.0/24"
availability_zone = "us-east-1a"
map_public_ip_on_launch = false
private_dns_hostname_type_on_launch = "resource-name"
is_public = false
route_table_id = dependency.private-route-table.outputs.route_table_ids[0]
tags = {}
},
{
name = "eks-demo-data-subnet-b"
vpc_id = dependency.vpc.outputs.vpc_id
cidr_block = "10.0.20.0/24"
availability_zone = "us-east-1b"
map_public_ip_on_launch = false
private_dns_hostname_type_on_launch = "resource-name"
is_public = false
route_table_id = dependency.private-route-table.outputs.route_table_ids[0]
tags = {}
}
]
}
j) infra-live/dev/nacl/terragrunt.hcl
This module uses the NACL building block as its Terraform source to create NACLs for our public and private subnets.
For the sake of simplicity, we'll configure very loose NACL and security group rules, but in the next blog post, we'll enforce security rules for the VPC and cluster.
Note, though, that the data subnet's NACLs only allow traffic on port 5432 from the app subnet CIDRs.
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git@github.com:<name_or_org>/nacl.git"
}
dependency "vpc" {
config_path = "../vpc"
}
dependency "public-subnets" {
config_path = "../public-subnets"
}
dependency "private-subnets" {
config_path = "../private-subnets"
}
inputs = {
_vpc_id = dependency.vpc.outputs.vpc_id
nacls = [
# Public NACL
{
name = "eks-demo-public-nacl"
vpc_id = dependency.vpc.outputs.vpc_id
egress = [
{
protocol = "-1"
rule_no = 500
action = "allow"
cidr_block = "0.0.0.0/0"
from_port = 0
to_port = 0
}
]
ingress = [
{
protocol = "-1"
rule_no = 100
action = "allow"
cidr_block = "0.0.0.0/0"
from_port = 0
to_port = 0
}
]
subnet_id = dependency.public-subnets.outputs.public_subnets[0]
tags = {}
},
# App NACL A
{
name = "eks-demo-nacl-a"
vpc_id = dependency.vpc.outputs.vpc_id
egress = [
{
protocol = "-1"
rule_no = 100
action = "allow"
cidr_block = "0.0.0.0/0"
from_port = 0
to_port = 0
}
]
ingress = [
{
protocol = "-1"
rule_no = 100
action = "allow"
cidr_block = "0.0.0.0/0"
from_port = 0
to_port = 0
}
]
subnet_id = dependency.private-subnets.outputs.private_subnets[0]
tags = {}
},
# App NACL B
{
name = "eks-demo-nacl-b"
vpc_id = dependency.vpc.outputs.vpc_id
egress = [
{
protocol = "-1"
rule_no = 100
action = "allow"
cidr_block = "0.0.0.0/0"
from_port = 0
to_port = 0
}
]
ingress = [
{
protocol = "-1"
rule_no = 100
action = "allow"
cidr_block = "0.0.0.0/0"
from_port = 0
to_port = 0
}
]
subnet_id = dependency.private-subnets.outputs.private_subnets[1]
tags = {}
},
# RDS NACL A
{
name = "eks-demo-rds-nacl-a"
vpc_id = dependency.vpc.outputs.vpc_id
egress = [
{
protocol = "tcp"
rule_no = 100
action = "allow"
cidr_block = "10.0.100.0/24"
from_port = 1024
to_port = 65535
},
{
protocol = "tcp"
rule_no = 200
action = "allow"
cidr_block = "10.0.200.0/24"
from_port = 1024
to_port = 65535
},
{
protocol = "tcp"
rule_no = 300
action = "allow"
cidr_block = "10.0.0.0/24"
from_port = 1024
to_port = 65535
}
]
ingress = [
{
protocol = "tcp"
rule_no = 100
action = "allow"
cidr_block = "10.0.100.0/24"
from_port = 5432
to_port = 5432
},
{
protocol = "tcp"
rule_no = 200
action = "allow"
cidr_block = "10.0.200.0/24"
from_port = 5432
to_port = 5432
},
{
protocol = "tcp"
rule_no = 300
action = "allow"
cidr_block = "10.0.0.0/24"
from_port = 5432
to_port = 5432
}
]
subnet_id = dependency.private-subnets.outputs.private_subnets[1]
tags = {}
},
# RDS NACL B
{
name = "eks-demo-rds-nacl-b"
vpc_id = dependency.vpc.outputs.vpc_id
egress = [
{
protocol = "tcp"
rule_no = 100
action = "allow"
cidr_block = "10.0.100.0/24"
from_port = 1024
to_port = 65535
},
{
protocol = "tcp"
rule_no = 200
action = "allow"
cidr_block = "10.0.200.0/24"
from_port = 1024
to_port = 65535
},
{
protocol = "tcp"
rule_no = 300
action = "allow"
cidr_block = "10.0.0.0/24"
from_port = 1024
to_port = 65535
}
]
ingress = [
{
protocol = "tcp"
rule_no = 100
action = "allow"
cidr_block = "10.0.100.0/24"
from_port = 5432
to_port = 5432
},
{
protocol = "tcp"
rule_no = 200
action = "allow"
cidr_block = "10.0.200.0/24"
from_port = 5432
to_port = 5432
},
{
protocol = "tcp"
rule_no = 300
action = "allow"
cidr_block = "10.0.0.0/24"
from_port = 5432
to_port = 5432
}
]
subnet_id = dependency.private-subnets.outputs.private_subnets[2]
tags = {}
}
]
}
k) infra-live/dev/security-group/terragrunt.hcl
This module uses the Security Group building block as its Terraform source to create a security group for our nodes and bastion host.
Again, its rules are going to be very loose, but we'll correct that in the next article.
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git@github.com:<name_or_org>/security-group.git"
}
dependency "vpc" {
config_path = "../vpc"
}
dependency "public-subnets" {
config_path = "../public-subnets"
}
dependency "private-subnets" {
config_path = "../private-subnets"
}
inputs = {
vpc_id = dependency.vpc.outputs.vpc_id
name = "public-sg"
description = "Open security group"
ingress_rules = [
{
protocol = "-1"
from_port = 0
to_port = 0
cidr_blocks = ["0.0.0.0/0"]
}
]
egress_rules = [
{
protocol = "-1"
from_port = 0
to_port = 0
cidr_blocks = ["0.0.0.0/0"]
}
]
tags = {}
}
l) infra-live/dev/bastion-role/terragrunt.hcl
This module uses the IAM Role building block as its Terraform source to create an IAM role with the permissions that our bastion host will need to perform EKS actions and to be managed by Systems Manager.
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git@github.com:<name_or_org>/iam-role.git"
}
inputs = {
principals = [
{
type = "Service"
identifiers = ["ec2.amazonaws.com"]
}
]
policy_name = "EKSDemoBastionPolicy"
policy_attachments = [
{
arn = "arn:aws:iam::534876755051:policy/AmazonEKSFullAccessPolicy"
},
{
arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
]
policy_statements = []
role_name = "EKSDemoBastionRole"
}
m) infra-live/dev/bastion-instance-profile/terragrunt.hcl
This module uses the *Instance Profile building block as its Terraform source to create an IAM instance profile for our bastion host. The IAM role created in the previous step is attached to this instance profile.
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git@github.com:<name_or_org>/instance-profile.git"
}
dependency "iam-role" {
config_path = "../bastion-role"
}
inputs = {
instance_profile_name = "EKSBastionInstanceProfile"
path = "/"
iam_role_name = dependency.iam-role.outputs.role_name
instance_profile_tags = {}
}
n) infra-live/dev/bastion-ec2/terragrunt.hcl
This module uses the EC2 building block as its Terraform source to create an EC2 instance which we'll use as a jump box (or bastion host) to manage the worker nodes in our EKS cluster.
The bastion host will be placed in our public subnet and will have the instance profile we created in the previous step attached to it, as well as our loose security group.
It is a Linux instance of type t2.micro using the Amazon Linux 2023 AMI with a user data script configured. This script will be defined in the next step.
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git@github.com:<name_or_org>/ec2.git"
}
dependency "public-subnets" {
config_path = "../public-subnets"
}
dependency "instance-profile" {
config_path = "../bastion-instance-profile"
}
dependency "security-group" {
config_path = "../security-group"
}
inputs = {
instance_name = "eks-bastion-host"
use_instance_profile = true
instance_profile_name = dependency.instance-profile.outputs.name
most_recent_ami = true
owners = ["amazon"]
ami_name_filter = "name"
ami_values_filter = ["al2023-ami-2023.*-x86_64"]
instance_type = "t2.micro"
subnet_id = dependency.public-subnets.outputs.public_subnets[0]
existing_security_group_ids = [dependency.security-group.outputs.security_group_id]
assign_public_ip = true
uses_ssh = false
keypair_name = ""
use_userdata = true
userdata_script_path = "user-data.sh"
user_data_replace_on_change = true
extra_tags = {}
}
o) infra-live/dev/bastion-ec2/user-data.sh
This user data script installs the AWS CLI, as well as the kubectl
and eksctl
tools. It also configures an alias for the kubectl
utility (k
), and bash completion for it.
#!/bin/bash
# Become root user
sudo su - ec2-user
# Update software packages
sudo yum update -y
# Download AWS CLI package
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv.zip"
# Unzip file
unzip -q awscli.zip
# Install AWS CLI
./aws/install
# Check AWS CLI version
aws —version
# Download kubectl binary
sudo curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
# Give the binary executable permissions
sudo chmod +x ./kubectl
# Move binary to directory in system’s path
sudo mv kubectl /usr/local/bin/
export PATH=/usr/local/bin:$PATH
# Check kubectl version
kubectl version -—client
# Installing kubectl bash completion on Linux
## If bash-completion is not installed on Linux, install the 'bash-completion' package
## via your distribution's package manager.
## Load the kubectl completion code for bash into the current shell
echo 'source <(kubectl completion bash)' >>~/.bash_profile
## Write bash completion code to a file and source it from .bash_profile
# kubectl completion bash > ~/.kube/completion.bash.inc
# printf "
# # kubectl shell completion
# source '$HOME/.kube/completion.bash.inc'
# " >> $HOME/.bash_profile
# source $HOME/.bash_profile
# Set bash completion for kubectl alias (k)
echo 'alias k=kubectl' >>~/.bashrc
echo 'complete -o default -F __start_kubectl k' >>~/.bashrc
source ~/.bashrc
# Get platform
ARCH=amd64
PLATFORM=$(uname -s)_$ARCH
# Download eksctl tool for platform
curl -sLO "https://github.com/eksctl-io/eksctl/releases/latest/download/eksctl_$PLATFORM.tar.gz"
# (Optional) Verify checksum
curl -sL "https://github.com/eksctl-io/eksctl/releases/latest/download/eksctl_checksums.txt" | grep $PLATFORM | sha256sum --check
# Extract binary
tar -xzf eksctl_$PLATFORM.tar.gz -C /tmp && rm eksctl_$PLATFORM.tar.gz
# Move binary to directory in system’s path
sudo mv /tmp/eksctl /usr/local/bin
# Check eksctl version
eksctl version
# Enable eksctl bash completion
. <(eksctl completion bash)
# Update system
sudo yum update -y
# Install Docker
sudo yum install docker -y
# Start Docker
sudo service docker start
# Add ec2-user to docker group
sudo usermod -a -G docker ec2-user
# Create docker group
newgrp docker
# Ensure docker is on
sudo chkconfig docker on
p) infra-live/dev/eks-cluster-role/terragrunt.hcl
This module uses the IAM Role building block as its Terraform source to create an IAM role for the EKS cluster. It has the managed policy AmazonEKSClusterPolicy attached to it.
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git@github.com:<name_or_org>/iam-role.git"
}
inputs = {
principals = [
{
type = "Service"
identifiers = ["eks.amazonaws.com"]
}
]
policy_name = "EKSDemoClusterRolePolicy"
policy_attachments = [
{
arn = "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy"
}
]
policy_statements = []
role_name = "EKSDemoClusterRole"
}
q) infra-live/dev/eks-cluster/terragrunt.hcl
This module uses the EKS Cluster building block as its Terraform source to create an EKS cluster which uses the IAM role created in the previous step.
The cluster will provision ENIs (Elastic Network Interfaces) in the private subnets we had created, which will be used by the EKS worker nodes.
The cluster also has various cluster log types enabled for auditing purposes.
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git@github.com:gozem-test/eks-cluster.git"
}
dependency "private-subnets" {
config_path = "../private-subnets"
}
dependency "iam-role" {
config_path = "../eks-cluster-role"
}
inputs = {
name = "eks-demo"
subnet_ids = [dependency.private-subnets.outputs.private_subnets[0], dependency.private-subnets.outputs.private_subnets[1]]
cluster_role_arn = dependency.iam-role.outputs.role_arn
enabled_cluster_log_types = ["api", "audit", "authenticator", "controllerManager", "scheduler"]
}
r) infra-live/dev/eks-addons/terragrunt.hcl
This module uses the EKS Add-ons building block as its Terraform source to activate add-ons for our EKS cluster.
This is very important, given that these add-ons can help with networking within the AWS VPC using the VPC features (vpc-cni
), cluster domain name resolution (coredns
), maintaining network connectivity between services and pods in the cluster (kube-proxy
), managing IAM credentials in the cluster (eks-pod-identity-agent
), or allowing EKS to manage the lifecycle of EBS volumes (aws-ebs-csi-driver
).
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git@github.com:<name_or_org>/eks-addon.git"
}
dependency "cluster" {
config_path = "../eks-cluster"
}
inputs = {
cluster_name = dependency.cluster.outputs.name
addons = [
{
name = "vpc-cni"
version = "v1.18.0-eksbuild.1"
},
{
name = "coredns"
version = "v1.11.1-eksbuild.6"
},
{
name = "kube-proxy"
version = "v1.29.1-eksbuild.2"
},
{
name = "aws-ebs-csi-driver"
version = "v1.29.1-eksbuild.1"
},
{
name = "eks-pod-identity-agent"
version = "v1.2.0-eksbuild.1"
}
]
}
s) infra-live/dev/worker-node-role/terragrunt.hcl
This module uses the IAM Role building block as its Terraform source to create an IAM role for the EKS worker nodes.
This role grants the node group permissions to carry out its operations within the cluster, and for its nodes to be managed by Systems Manager.
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git@github.com:<name_or_org>/iam-role.git"
}
inputs = {
principals = [
{
type = "Service"
identifiers = ["ec2.amazonaws.com"]
}
]
policy_name = "EKSDemoWorkerNodePolicy"
policy_attachments = [
{
arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly"
},
{
arn = "arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy"
},
{
arn = "arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy"
},
{
arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
},
{
arn = "arn:aws:iam::aws:policy/AmazonEKSVPCResourceController"
}
]
policy_statements = []
role_name = "EKSDemoWorkerNodeRole"
}
t) infra-live/dev/eks-node-group/terragrunt.hcl
This module uses the EKS Node Group building block as its Terraform source to create a node group in the cluster.
The nodes in the node group will be provisioned in the VPC's private subnets, and we'll be using on-demand Linux instances of type m5.4xlarge
with the AL2_x86_64
AMI and disk size of 20GB
. We use an m5.4xlarge
instance because it supports trunking, which we'll need in the next article to deploy pods and associate security groups to them.
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git@github.com:<name_or_org>/eks-node-group.git"
}
dependency "cluster" {
config_path = "../eks-cluster"
}
dependency "iam-role" {
config_path = "../worker-node-role"
}
dependency "private-subnets" {
config_path = "../private-subnets"
}
inputs = {
cluster_name = dependency.cluster.outputs.name
node_role_arn = dependency.iam-role.outputs.role_arn
node_group_name = "eks-demo-node-group"
scaling_config = {
desired_size = 2
max_size = 4
min_size = 1
}
subnet_ids = [dependency.private-subnets.outputs.private_subnets[0], dependency.private-subnets.outputs.private_subnets[1]]
update_config = {
max_unavailable_percentage = 50
}
ami_type = "AL2_x86_64"
capacity_type = "ON_DEMAND"
disk_size = 20
instance_types = ["m5.4xlarge"]
}
u) infra-live/dev/eks-pod-iam/terragrunt.hcl
This module uses the IAM OIDC building block as its Terraform source to create resources that will allow pods to assume IAM roles and communicate with other AWS services.
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git@github.com:<name_or_org>/iam-oidc.git"
}
dependency "cluster" {
config_path = "../eks-cluster"
}
inputs = {
role_name = "EKSDemoPodIAMAuth"
oidc_issuer = dependency.cluster.outputs.oidc_tls_issuer
client_id_list = ["sts.amazonaws.com"]
}
Having done all this, we now need to create a GitHub repository for our Terragrunt code and push our code to that repository. We should also configure repository secrets for our AWS credentials (AWS_ACCESS_KEY_ID
, AWS_SECRET_ACCESS_KEY
, AWS_REGION
) and an SSH private key that we'll use to access the repositories with our Terraform building blocks.
Once that is done, we can proceed to create a GitHub Actions workflow to automate the provisioning of our infrastructure.
3. Create a GitHub Actions workflow for Automated Infrastructure Provisioning
Now that our code has been versioned, we can write a workflow that will be triggered whenever we push code to the main branch (use whichever branch you prefer, like master).
Ideally, this workflow should only be triggered after a pull request has been approved to merge to the main branch, but we'll keep it simple for illustration purposes.
The first thing will be to create a .github/workflows
in the root directory of your infra-live
project. You can then create a YAML file within this infra-live/.github/workflows
directory called deploy.yml, for example.
We'll add the following code to our infra-live/.github/workflows/configure.yml
file to handle the provisioning of our infrastructure:
name: Deploy
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
terraform:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Setup SSH
uses: webfactory/ssh-agent@v0.4.1
with:
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
- name: Setup Terraform
uses: hashicorp/setup-terraform@v2
with:
terraform_version: 1.5.5
terraform_wrapper: false
- name: Setup Terragrunt
run: |
curl -LO "https://github.com/gruntwork-io/terragrunt/releases/download/v0.48.1/terragrunt_linux_amd64"
chmod +x terragrunt_linux_amd64
sudo mv terragrunt_linux_amd64 /usr/local/bin/terragrunt
terragrunt -v
- name: Apply Terraform changes
run: |
cd dev
terragrunt run-all apply -auto-approve --terragrunt-non-interactive -var AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID -var AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY -var AWS_REGION=$AWS_DEFAULT_REGION
cd bastion-ec2
ip=$(terragrunt output instance_public_ip)
echo "$ip"
echo "$ip" > public_ip.txt
cat public_ip.txt
pwd
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: ${{ secrets.AWS_DEFAULT_REGION }}
Let's break down what this file does:
a) The name: Deploy
line names our workflow Deploy
b) The following lines of code tell GitHub to trigger this workflow whenever code is pushed to the main branch or a pull request is merged to the main branch:
on:
push:
branches:
- main
pull_request:
branches:
- main
c) Then we define our job called terraform using the lines below, telling GitHub to use a runner that runs on the latest version of Ubuntu. Think of a runner as the GitHub server executing the commands in this workflow file for us:
jobs:
terraform:
runs-on: ubuntu-latest
d) We then define a series of steps or blocks of commands that will be executed in order.
The first step uses a GitHub action to checkout our infra-live repository into the runner so that we can start working with it:
- name: Checkout repository
uses: actions/checkout@v2
The next step uses another GitHub action to help us easily set up SSH on the GitHub runner using the private key we had defined as a repository secret:
- name: Setup SSH
uses: webfactory/ssh-agent@v0.4.1
with:
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
The following step uses yet another GitHub action to help us easily install Terraform on the GitHub runner, specifying the exact version that we need:
- name: Setup Terraform
uses: hashicorp/setup-terraform@v2
with:
terraform_version: 1.5.5
terraform_wrapper: false
Then we use another step to execute a series of commands that install Terragrunt on the GitHub runner. We use the command terragrunt -v
to check the version of Terragrunt installed and confirm that the installation was successful:
- name: Setup Terragrunt
run: |
curl -LO "https://github.com/gruntwork-io/terragrunt/releases/download/v0.48.1/terragrunt_linux_amd64"
chmod +x terragrunt_linux_amd64
sudo mv terragrunt_linux_amd64 /usr/local/bin/terragrunt
terragrunt -v
Finally, we use a step to apply our Terraform changes, then we use a series of commands to retrieve the public IP address of our provisioned EC2 instance and save it to a file called public_ip.txt:
- name: Apply Terraform changes
run: |
cd dev
terragrunt run-all apply -auto-approve --terragrunt-non-interactive -var AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID -var AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY -var AWS_REGION=$AWS_DEFAULT_REGION
cd bastion-ec2
ip=$(terragrunt output instance_public_ip)
echo "$ip"
echo "$ip" > public_ip.txt
cat public_ip.txt
pwd
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: ${{ secrets.AWS_DEFAULT_REGION }}
And that's it! We can now watch the pipeline get triggered when we push code to our main branch, and see how our EKS cluster gets provisioned.
In the next article, we'll secure our cluster then access our bastion host and get our hands dirty with real Kubernetes action!
I hope you liked this article. If you have any questions or remarks, please feel free to leave a comment below.
See you soon!