Showing posts with label Terraform. Show all posts
Showing posts with label Terraform. Show all posts

Friday, 17 July 2026

Accessing AWS EKS Cluster from EC2


When an EC2 instance (acting as a CI/CD runner, bastion, or deployment node) runs Terraform or Helm to interact with a private EKS cluster, it needs two things: 
  • network visibility (which it gets by being in the same VPC or connected network) and 
  • proper IAM-to-Kubernetes mappings

AWS modern access management feature uses EKS Access Entries. This native API feature entirely replaces the messy, deprecated aws-auth ConfigMap.

Here is how to configure the IAM Role and wire it into the cluster's auth configuration using Terraform.

1. The EC2 IAM Role (AWS Side)


The EC2 instance does not need any specific EKS admin permissions attached directly to its IAM role policies. It just needs a standard IAM Role that it can assume via an EC2 Instance Profile. The actual Kubernetes cluster access is granted inside EKS by referencing this role's Amazon Resource Name (ARN).  

# 1. Create the IAM Role for the EC2 Instance
resource "aws_iam_role" "cicd_runner" {
  name = "eks-cicd-runner-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action    = "sts:AssumeRole"
        Effect    = "Allow"
        Principal = { Service = "ec2.amazonaws.com" }
      }
    ]
  })
})

# 2. Create the Instance Profile so the EC2 can use the role
resource "aws_iam_instance_profile" "cicd_runner_profile" {
  name = "eks-cicd-runner-profile"
  role = aws_iam_role.cicd_runner.name
}

2. Wiring it into the Cluster (EKS Side)


To authorize this IAM role to run Helm deployments or manage Kubernetes resources via Terraform, you use EKS Access Entries (aws_eks_access_entry) combined with Access Policy Associations (aws_eks_access_policy_association).  

⚠️ Prerequisite: Ensure your aws_eks_cluster resource has authentication_mode = "API_AND_CONFIG_MAP" or "API" enabled so it accepts Access Entries.

The Terraform Configuration

# 1. Define the Access Entry mapping the IAM Role ARN to EKS
resource "aws_eks_access_entry" "cicd_runner_entry" {
  cluster_name  = "my-cluster-name"
  principal_arn = aws_iam_role.cicd_runner.arn
  type          = "STANDARD" # Standard workflow for users/roles
}

# 2. Attach an AWS-managed access policy to the Access Entry
resource "aws_eks_access_policy_association" "cicd_admin_mapping" {
  cluster_name  = "my-cluster-name"
  principal_arn = aws_iam_role.cicd_runner.arn
  policy_arn    = "arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy"

  access_scope {
    type = "cluster"
  }
}


Another example (in the cluster's Terraform source code):

# read-only access entry for the in-VPC runner's instance role, so operators can kubectl the
# private cluster via SSM-on-the-runner for diagnostics without an out-of-band `aws eks create-access-entry` grant each time. AmazonEKSAdminViewPolicy = read all resources incl.
# CRDs, excluding Secrets — safe for diagnostics. The runner role is defined in another repo
# (applications/test/github-runner); it also gets eks:DescribeCluster there so `aws eks update-kubeconfig`
# works. Writes still require github-actions-role (the CD/harness OIDC principal above).

resource "aws_eks_access_entry" "runner_readonly" {
  cluster_name  = local.cluster_name
  principal_arn = "arn:aws:iam::1234567890123:role/github-runner-role"
  type          = "STANDARD"

  depends_on = [module.eks]
}

resource "aws_eks_access_policy_association" "runner_readonly" {
  cluster_name  = local.cluster_name
  policy_arn    = "arn:aws:eks::aws:cluster-access-policy/AmazonEKSAdminViewPolicy"
  principal_arn = aws_eks_access_entry.runner_readonly.principal_arn

  access_scope {
    type = "cluster"
  }
}

and in EC2 Terraform repo:

# eks:DescribeCluster so `aws eks update-kubeconfig` works from the runner. Paired with the
# read-only EKS access entry for this role on test-default-eks (infra-k8s), it lets operators
# kubectl the private cluster via SSM for diagnostics without a hand-built kubeconfig or an ad-hoc grant.
# IAM (this) authorises the DescribeCluster call; the access entry (RBAC) authorises what kubectl can read.
data "aws_iam_policy_document" "eks_describe" {
  statement {
    sid       = "EksDescribeCluster"
    actions   = ["eks:DescribeCluster"]
    resources = ["arn:aws:eks:${var.aws_region}:${data.aws_caller_identity.current.account_id}:cluster/test-default-eks"]
  }
}

resource "aws_iam_role_policy" "eks_describe" {
  name   = "${local.name}-eks-describe"
  role   = aws_iam_role.runner.id
  policy = data.aws_iam_policy_document.eks_describe.json
}




Alternative: Custom Kubernetes Groups


If you don't want to grant full AmazonEKSClusterAdminPolicy and prefer mapping the EC2 instance to custom Kubernetes RBAC roles, you can pass target groups directly via the access entry:


resource "aws_eks_access_entry" "cicd_runner_entry" {
  cluster_name      = "my-cluster-name"
  principal_arn     = aws_iam_role.cicd_runner.arn
  kubernetes_groups = ["my-custom-helm-deployers-group"]
  type              = "STANDARD"
}

(You would then manage a native Kubernetes ClusterRoleBinding pointing to my-custom-helm-deployers-group using the kubernetes provider.)

3. How the EC2 Instance Connects


Once the IAM and EKS access configurations are applied, you don't need to pass raw AWS access keys to the EC2 instance. The tools leverage the Instance Profile automatically.
For Helm & kubectl:Log onto the EC2 instance (or configure your shell script/user-data) to update the local kubeconfig using the AWS CLI:

aws eks update-kubeconfig --region us-east-1 --name my-cluster-name

When Helm or kubectl is invoked, it calls the aws eks get-token command under the hood, uses the EC2's IAM profile to generate a signed token, routes over the private VPC endpoint network, and authenticates flawlessly.

For the Terraform Kubernetes/Helm Providers:


If Terraform itself is running on that EC2 instance and deploying helm charts into EKS, configure your Terraform provider block to pull tokens dynamically using the instance profile credentials:

data "aws_eks_cluster_auth" "cluster" {
  name = "my-cluster-name"
}

provider "helm" {
  kubernetes {
    host                   = "https://ABC123XYZ.gr7.us-east-1.eks.amazonaws.com"
    cluster_ca_certificate = base64decode("CLUSTER_CA_DATA")
    token                  = data.aws_eks_cluster_auth.cluster.token
  }
}


Just-In-Time (JIT) Access (Least Privilege Workflow)


Another approach is a Just-In-Time (JIT) access or Least Privilege workflow. It is highly secure, but it can quickly become an operational headache for a DevOps engineer if it isn't completely automated.

Instead of giving your GitHub Actions runner continuous, permanent admin access to your private EKS cluster, you only grant access for the exact duration of an ad-hoc task, and then immediately rip it away.

Here is exactly how that breakdown works mechanically step-by-step, why someone would build it, and the hidden risks you should look out for.

The Workflow Cycle Breakdown


When you trigger a workflow or step that needs to run an ad-hoc kubectl command, an automation tool (like a pipeline script, a step function, or a privileged security wrapper) executes this lifecycle:

[Pipeline Starts] 
       │
       ▼
1. CREATE ACCESS ENTRY ──► (Allows 'github-runner-role' network identity into EKS)
       │
       ▼
2. ASSOCIATE POLICY    ──► (Binds ClusterAdmin or specific RBAC permissions)
       │
       ▼
3. RUN KUBECTL COMMAND ──► (The runner executes its ad-hoc tasks safely)
       │
       ▼
4. DISASSOCIATE POLICY ──► (Strip away the RBAC permissions)
       │
       ▼
5. DELETE ACCESS ENTRY ──► (Completely remove the IAM role mapping from EKS)
       │
       ▼
[Pipeline Finishes]


1. The Gate: create-access-entry

By default, your runner's IAM role (github-runner-role) is completely blocked at the EKS front door. Even if it can physically route to the API endpoint, EKS will return a 401 Unauthorized.

The setup script calls the AWS API to create an EKS Access Entry. This tells EKS: "Be ready to recognize this specific IAM role ARN."

2. The Permission: associate-access-policy

Creating the entry just gets the runner past the front door; it still has zero privileges inside the cluster. The next API call binds an access policy (like AmazonEKSClusterAdminPolicy or a custom namespace policy) to that entry. Now kubectl commands will actually work.

3. The Execution: Ad-hoc Commands

The runner runs aws eks update-kubeconfig, grabs its temporary token, and runs the necessary kubectl or helm actions.

4. The Clean-up: Revoking Access

Once the kubectl step finishes, the pipeline runs a cleanup block (usually wrapped in a always() or finally clause to ensure it runs even if the deployment fails). It reverses the process by disassociating the policy and deleting the access entry entirely. The runner is now unauthorized again.

Why would someone architecture it this way?


  • Blast Radius Reduction: If that specific GitHub runner instance or the AWS role credentials are ever compromised, the attacker cannot access your Kubernetes cluster because the role has no standing permissions.
  • Audit Trails: Every single time access is granted, used, and revoked, a permanent trail is logged in AWS CloudTrail. It makes passing compliance checks exceptionally easy because you can prove no system has persistent, unchallenged access.

The Catch: Why this pattern can trip you up


While it sounds bulletproof on paper, this setup has a few major operational trade-offs that you have to manage closely:
  • Pipeline Failures Leave Backdoors: If the runner gets abruptly killed (e.g., the GitHub Actions job is manually canceled mid-run, or the runner machine loses power), the cleanup step might never run. The role will remain an admin of your cluster until someone goes in and manually deletes the entry.
  • Race Conditions on Concurrent Runs: If you have multiple workflows trying to use that same runner role at the same time, Run A might delete the access entry while Run B is right in the middle of executing a kubectl apply.
  • AWS API Throttling: If you scale up your CI/CD pipelines and are constantly creating and destroying access entries every few minutes, you can easily hit AWS API rate limits (throttled EKS control plane requests).

Thursday, 16 July 2026

Helm and Infrastructure

 


Does Helm provision aws infrastructure?



The short answer is no, Helm does not natively provision AWS cloud infrastructure.

Helm is a package manager designed specifically for Kubernetes. Its job is to template, package, and deploy Kubernetes-native YAML manifests (like Deployments, Services, ConfigMaps, and Ingresses) onto an already-running cluster. It does not natively understand or manage AWS resources like VPCs, EC2 instances, RDS databases, or S3 buckets.  However, because the cloud native ecosystem is highly integrated, the line can sometimes look blurry. Here is how that boundary gets crossed and how the two worlds interact.

The "Grey Area": When Helm Indirectly Triggers AWS Provisioning

While Helm itself only talks to the Kubernetes API, certain Kubernetes operators and controllers can watch for Helm-deployed resources and provision AWS infrastructure in response:

1. Cloud-Provider Load Balancers

If your Helm chart deploys a Kubernetes Service of type: LoadBalancer:
  • Helm simply submits that Service YAML to the EKS control plane.
  • The cloud-controller-manager inside EKS sees this and automatically provisions an AWS Classic Load Balancer or Network Load Balancer (NLB) in your AWS account.
  • Similarly, deploying an Ingress resource alongside the AWS Load Balancer Controller (often installed via Helm itself) triggers the creation of an AWS Application Load Balancer (ALB).  

2. Custom Resource Definitions (CRDs) & Operators

You can use Kubernetes operators that are explicitly designed to manage cloud infrastructure through Kubernetes. If you install these operators (often using Helm), you can then use Helm to deploy manifests that provision AWS resources:
  • AWS Controllers for Kubernetes (ACK): This framework allows you to define AWS resources (like an S3 bucket or an RDS instance) directly as Kubernetes custom resources. If you bundle an ACK custom resource inside a Helm chart, running helm install will tell the ACK operator to provision that infrastructure in AWS.
  • Crossplane: Similar to ACK, Crossplane turns your cluster into a control plane. You can package Crossplane claims inside Helm charts to spin up databases, networks, and queues.


The Standard Best Practice: "Separation of Concerns"

Because managing cloud infrastructure via Kubernetes manifests can get complex quickly, the industry-standard pattern is to keep these layers strictly separated:


┌────────────────────────────────────────────────────────┐
│                   App Layer (Helm)                     │
│  - Deployments, Services, Ingress, ConfigMaps, etc.    |
└───────────────────────────┬────────────────────────────┘
                            │ Runs inside
┌───────────────────────────▼────────────────────────────┐
│                Kubernetes Cluster (EKS)                │
└───────────────────────────┬────────────────────────────┘
                            │ Provisioned by
┌───────────────────────────▼────────────────────────────┐
│               Infra Layer (IaC - Terraform)            │
│  - VPC, Subnets, EKS Cluster, IAM, RDS, S3, etc.       │
└────────────────────────────────────────────────────────┘



  • Infrastructure as Code (Terraform, OpenTofu, CloudFormation, Pulumi): Used to provision the underlying AWS infrastructure—the VPC, subnets, IAM roles, EKS cluster, and managed node groups.
  • Helm: Used once the cluster is up and running to deploy and configure applications, ingress controllers, logging agents, and service meshes on top of that infrastructure.

Note: If you want a single tool to bridge the gap, Terraform actually has a Helm Provider. This allows you to use Terraform to provision your EKS cluster first, and then immediately call Helm charts within the same Terraform code to bootstrap your cluster applications.


How to use Terraform to install a Helm chart?


To bridge the gap between infrastructure and application deployment, you can use the HashiCorp Helm Provider directly inside your Terraform configuration.

This approach ensures strict ordering: Terraform first provisions the AWS network and EKS cluster, and only after the cluster is fully active does it initialize Helm to install your charts.

Here is a clean, production-ready example showing how to connect your AWS provider, configure the Kubernetes/Helm providers dynamically using the cluster's output token, and deploy a chart.


# 1. PROVIDERS CONFIGURATION
# We configure Kubernetes and Helm providers dynamically using tokens 
# generated from the newly created AWS EKS cluster.

provider "aws" {
  region = "eu-west-2" # London
}

provider "kubernetes" {
  host                   = aws_eks_cluster.main.endpoint
  cluster_ca_certificate = base64decode(aws_eks_cluster.main.certificate_authority[0].data)
  token                  = data.aws_eks_cluster_auth.main.token
}

provider "helm" {
  kubernetes {
    host                   = aws_eks_cluster.main.endpoint
    cluster_ca_certificate = base64decode(aws_eks_cluster.main.certificate_authority[0].data)
    token                  = data.aws_eks_cluster_auth.main.token
  }
}

# Data source to fetch authentication tokens for EKS dynamically
data "aws_eks_cluster_auth" "main" {
  name = aws_eks_cluster.main.name
}

# 2. AWS EKS CLUSTER PROVISIONING (Simplified)
resource "aws_eks_cluster" "main" {
  name     = "production-core-cluster"
  role_arn = aws_iam_role.eks_cluster.arn

  vpc_config {
    subnet_ids = ["subnet-12345678", "subnet-87654321"] # Replace with your VPC subnet IDs
  }
}

# 3. HELM RELEASE PROVISIONING
# Terraform treats this Helm chart as a resource managed within its state file.

resource "helm_release" "ingress_nginx" {
  name             = "ingress-nginx"
  repository       = "https://kubernetes.github.io/ingress-nginx"
  chart            = "ingress-nginx"
  version          = "4.10.0"
  namespace        = "ingress-system"
  create_namespace = true

  # Explicit dependency guarantees the cluster exists before Helm tries to talk to it
  depends_on = [aws_eks_cluster.main]

  # Passing configuration values into the Helm chart
  set {
    name  = "controller.replicaCount"
    value = "3"
  }

  set {
    name  = "controller.service.annotations.service\\.beta\\.kubernetes\\.io/aws-load-balancer-type"
    value = "nlb"
  }
}

Why this pattern works so well:
  • Token-Based Auth Lifecycle: By using the aws_eks_cluster_auth data source, Terraform fetches a short-lived administrative token on the fly during the apply phase, ensuring Helm can authenticate without storing static kubeconfig files on disk or in version control.
  • Escaping Helm Keys: Note the double backslash (\\) in the annotation key. Helm uses dot notation to traverse nested values, so if an AWS annotation key contains a dot (like aws-load-balancer-type), you must escape it in Terraform so Helm interprets it as a single key string rather than a nested map.
  • State Management: When you run terraform destroy, Terraform is smart enough to reverse the order: it calls Helm to cleanly uninstall the charts, deletes any cloud-provider resources (like NLBs) created by those charts, and then tears down the EKS cluster.



Tuesday, 21 April 2026

Provisioning AWS EKS Cluster with terraform-aws-modules/eks/aws





In this article we want to explore and breakdown its key components and their purposes.

We'd typically use this module like here:

module "eks" {
  source  = "terraform-aws-modules/eks/aws"
  version = "21.15.1"
  ...
}


Let's explore this module's attributes.

1. Cluster Configuration


name,  version

Sets the name and Kubernetes version for the EKS cluster. Use local and variable values for flexibility.

endpoint_public_access

Set public (Internet) access to the Kubernetes API endpoint (via kubectl). Disable it for enhanced security. 

endpoint_private_access

Set private access to the API endpoint, whether only resources within the VPC can access it. If enabled, it is only reachable from within the VPC (Virtual Private Cloud) where your EKS cluster is deployed. There are few ways to access it:

How to Access the Kubernetes API from VPC

1. Use a Bastion Host or EC2 Instance in the VPC

Launch an EC2 instance (bastion host or jump box) in a subnet within the same VPC as your EKS cluster.
SSH into this instance, and from there, use kubectl to access the cluster.
Alternatively, use SSH port forwarding or a VPN to proxy kubectl commands from your local machine through the bastion.

2. Use AWS Systems Manager (SSM) Session Manager

If your EC2 instances have the SSM agent and the necessary IAM permissions, you can use AWS SSM Session Manager to start a shell session on an instance in the VPC, then run kubectl from there.

3. Use a VPN Connection

Set up a VPN (such as AWS Client VPN or OpenVPN, or Site-to-site VPN for office LAN) that connects your local network to the VPC. Once connected, your local machine will be able to reach the private endpoint.

4. Use AWS PrivateLink (Interface VPC Endpoints)

For advanced scenarios, you can use AWS PrivateLink to expose the Kubernetes API endpoint privately to other VPCs or on-premises networks.


enable_cluster_creator_admin_permissions


If enabled, grants admin permissions to the user who creates the cluster.


2. Logging and Add-ons


enabled_log_types

Enables logging for various Kubernetes components (API, audit, authenticator, controllerManager, scheduler) for monitoring and troubleshooting.

Example:

  enabled_log_types = [
    "api",
    "audit",
    "authenticator",
    "controllerManager",
    "scheduler"
  ]

addons

A dictionary-type attribute which installs and configures essential Kubernetes add-ons. Dictionary keys are addon names like:
  • coredns
  • kube-proxy
  • aws-ebs-csi-driver
  • vpc-cni

Dictionary values are objects which attributes are:
  • most_recent - to set using the latest version (set it to false for version pinning)
  • version - addon version (use it for version pinning)
  • before_compute - set it to true if addon should be installed and set before nodes (compute layer)
  • service_account_role_arn - to configure addon with IAM roles for service accounts, enabling secure integration with AWS services.

Example:

addons = {
    ...
    vpc-cni = {
      most_recent              = false
      version                  = "v1.21.1-eksbuild.7"
      before_compute           = true
      service_account_role_arn = module.k8s_default_vpc_cni_irsa.iam_role_arn
    }
    ...
}

VPC CNI (Container networking interface) is responsible for allocating IP addresses to the Kubernetes nodes and provides networking to pods. The plugin manage network interfaces (ENIs) on the nodes and uses it to assign IP addresses to pods.



3. Networking

We need to integrate the EKS cluster with existing VPC and subnets:

vpc_id 

VPC ID

subnet_ids

Subnets in which nodes (EC2 instances) will be created.
Where your worker nodes (EC2 instances) run.

control_plane_subnet_ids

Where the EKS control plane ENIs (network interfaces) are placed
Defines where the EKS control plane creates its Elastic Network Interfaces (ENIs)

What it controls:
  • The EKS control plane runs in an AWS-managed VPC (you don't see it)
  • To communicate with your worker nodes, it creates ENIs in your VPC
  • These ENIs are placed in the subnets you specify here

Typical configuration:

module "eks" {
  source  = "terraform-aws-modules/eks/aws"
  
  name = "my-cluster"
  
  # Control plane ENIs go here
  control_plane_subnet_ids = [
    "subnet-private-1a",
    "subnet-private-1b",
    "subnet-private-1c"
  ]
}

Best practices:
  • Usually private subnets
  • Should span multiple AZs for high availability (AWS requires at least 2)
  • Minimum of 2 subnets, maximum of 16
  • Each subnet needs at least 5 available IP addresses

What these ENIs do:
  • Allow the control plane to communicate with worker nodes
  • Allow worker nodes to communicate with the API server
  • Handle API server endpoint traffic


security_group_additional_rules


Adds custom security group rules for the cluster, such as allowing node-to-node communication and VPN access for kubectl.

node_security_group_additional_rules


Further customizes node security groups, allowing all node-to-node traffic and all outbound traffic.



Understanding EKS Architecture

An EKS cluster has two main components:

┌─────────────────────────────────────────────────────────┐
│                    EKS Cluster                          │
│                                                         │
│  ┌───────────────────────────────────────┐              │
│  │   Control Plane (AWS Managed)         │              │
│  │   - API Server                        │              │
│  │   - etcd                              │              │
│  │   - Scheduler                         │              │
│  │   - Controller Manager                │              │
│  │                                       │              │
│  │   Runs in AWS-managed account         │              │
│  └──────────────┬────────────────────────┘              │
│                 │                                       │
│                 │ ENIs in your VPC                      │
│                 │ (control_plane_subnet_ids)            │
│  ┌──────────────▼────────────────────────┐              │
│  │   Your VPC                            │              │
│  │   ┌─────────────────────────────┐     │              │
│  │   │  Worker Nodes (subnet_ids)  │     │              │
│  │   │  - EC2 instances            │     │              │
│  │   │  - Your pods run here       │     │              │
│  │   └─────────────────────────────┘     │              │
│  └───────────────────────────────────────┘              │
└─────────────────────────────────────────────────────────┘

ENI: elastic network interface. It is a logical networking component in a VPC that represents a virtual network card.



4. Node Group Configuration


node_security_group_tags


Adds a tag for Karpenter (an open-source Kubernetes node autoscaler) discovery.

eks_managed_node_group_defaults


Sets default properties for all managed node groups, including:
  • Attaching the CNI policy for networking.
  • Using a specific SSH key.
  • Associating additional security groups.
  • Defining block device mappings for EBS volumes.
  • Attaching the AmazonSSMManagedInstanceCore policy for SSM access.

eks_managed_node_groups


Defines a default managed node group with:
  • A specific AMI type.
  • Desired, minimum, and maximum node counts.
  • Instance types from a variable.
  • On-demand capacity, EBS optimization, and disk size.
  • Custom labels for node identification and environment.

The gold standard for production environments is explicit pinning. This ensures that our infrastructure only changes when we decide to change the code. In order to pin AMI version used in node groups we need to set two attributes:
  • ami_release_version needs to be set. This prevents nodes from cycling unexpectedly during a routine deployment.
  • use_latest_ami_release_version needs to be set to false (without this, terraform plan will still show that it wants to upgrade AMI version, even if we've set ami_release_version)

Example:

  eks_managed_node_groups = {
    "${local.cluster_name}-v1_33" = {
      ...
      ami_release_version            = "1.33.8-20260224"
      use_latest_ami_release_version = false
      ...


5. Tagging


tags


Applies custom tags to all AWS resources created by the module, supporting cost allocation and resource management.


Summary



Our configuration sets up a secure, private, and production-ready EKS cluster with managed node groups, essential add-ons, robust logging, and fine-grained network and IAM controls. It leverages best practices for security (private endpoints, IAM roles for service accounts), scalability (managed node groups, Karpenter tags), and maintainability (modular, versioned, and tagged infrastructure).


---

Thursday, 26 February 2026

Where to keep Helm chart values in Terraform projects


If we use Terraform to deploy Helm charts, we might be using one of these strategies to keep chart values:

  1. Values are in inline YAML string
  2. Values in separate .yaml file
  3. Values in separate YAML Template files (.yaml.tpl)
  4. Use Helm's set for Dynamic Values
  5. Multiple Values Files

(1) Values in inline YAML string


This is not ideal as problems with Inline YAML in Terraform include:
  • No syntax highlighting or validation - Easy to break YAML formatting
  • Hard to review in diffs - Changes are messy in PRs
  • Can't use standard tooling - No yamllint, Pluto, or other YAML tools
  • Mixing concerns - Infrastructure code mixed with application config
  • Escaping nightmares - Terraform string interpolation conflicts with Helm templating

Example:

resource "helm_release" "app" {
  values = [<<-EOT
    replicaCount: ${var.replicas}
    image:
      repository: myapp
      tag: ${var.tag}
    service:
      type: LoadBalancer
  EOT
  ]
}


(2) Separate Values Files 


Keep values in YAML files, reference them in Terraform.
This is a better approach because:
  • Clean separation
  • Easy to validate with standard tools
  • Better diffs
  • Can use Pluto directly: pluto detect-files -d .

Example:

main.tf:

resource "helm_release" "my_app" {
  name       = "my-app"
  chart      = "my-chart"
  repository = "https://charts.example.com"
  
  values = [
    file("${path.module}/helm-values.yaml")
  ]
}


(3) Templated Values Files


Use Terraform's templatefile() to inject dynamic values:


helm-values.yaml.tpl:

replicaCount: ${replica_count}
image:
  repository: ${image_repo}
  tag: ${image_tag}
ingress:
  enabled: ${enable_ingress}
  host: ${hostname}

main.tf:

resource "helm_release" "my_app" {
  name  = "my-app"
  chart = "my-chart"
  
  values = [
    templatefile("${path.module}/helm-values.yaml.tpl", {
      replica_count  = var.replica_count
      image_repo     = var.image_repository
      image_tag      = var.image_tag
      enable_ingress = var.enable_ingress
      hostname       = var.hostname
    })
  ]
}

Pros:

  • Still gets variable injection
  • Can be validated as YAML (with placeholders)
  • Clean and readable


(4) Use Helm's set for Dynamic Values


Keep static config in files, override specific values:


resource "helm_release" "my_app" {
  name       = "my-app"
  chart      = "my-chart"
  
  # Base values from file
  values = [
    file("${path.module}/helm-values.yaml")
  ]
  
  # Override specific values dynamically
  set {
    name  = "image.tag"
    value = var.image_tag
  }
  
  set {
    name  = "replicaCount"
    value = var.replica_count
  }
  
  set_sensitive {
    name  = "secret.password"
    value = var.db_password
  }
}

Pros:
  • Clear what's dynamic vs static
  • Base values file can be validated
  • Sensitive values handled properly

Here is the example how we can migrate inline YAML from the above to templated file:

helm-values.yaml:

image:
  repository: myapp
service:
  type: LoadBalancer


main.tf:

resource "helm_release" "app" {
  values = [
    file("${path.module}/helm-values.yaml")
  ]
  
  set {
    name  = "replicaCount"
    value = var.replicas
  }
  
  set {
    name  = "image.tag"
    value = var.tag
  }
}

Now we can run: 

% pluto detect-files -f helm-values.yaml



(5) Multiple Values Files


We can layer our configuration:

resource "helm_release" "my_app" {
  name  = "my-app"
  chart = "my-chart"
  
  values = [
    file("${path.module}/helm-values-base.yaml"),
    file("${path.module}/helm-values-${var.environment}.yaml")
  ]
}

---

Tuesday, 17 February 2026

How to use terraform-docs automatically generate Terraform code documentation

 

terraform-docs is a tool used to automatically generate Terraform code documentation.

To install it on Mac:

% brew install terraform-docs 

To verify installation:

% terraform-docs --version                                        
terraform-docs version v0.21.0 darwin/arm64

To generate a documentation for a module in the current directory and append it to the README file (which is in the same directory):

% terraform-docs markdown table --output-file README.md --output-mode inject ./


How to install Terraform on Mac



First add Hashicorp's package repository:

% brew tap hashicorp/tap

Then install the Terraform:

% brew install hashicorp/tap/terraform

If Terraform was already installed, the command above will update it.

To verify installation, we can check its version:

% terraform --version                                                                                    
Terraform v1.14.5
on darwin_arm64

Wednesday, 31 July 2024

How to use HashiCorp Cloud as a remote storage for Terraform state file



Terraform state file keeps track of the infrastructure which is under Terraform's control. Terraform compares resource configuration files against it in order to find out which resource needs to be added, edited or deleted. If state file gets lost, Terraform will try to re-create all resources. 

By default Terraform state file (terraform.tfstate) is stored locally, on the machine where we initialize Terraform. But this carries the risk of adding this file (which may contain sensitive data) to the repository and pushing it to remote which can be a security risk or deleting it by chance which can be painful experience - see Lessons learned after losing the Terraform state file | Trying things.

To minimize chances of losing the Terraform state file and enable multiple contributors to work on the same infrastructure in parallel we should define a remote storage for it. We can store it in AWS S3 bucket, Google Cloud etc...but one of the totally free options, which also includes the shared state file locking mechanism, is Terraform Cloud.

Here are the steps which explain how to do it.

Sign Up for HashiCorp Cloud Platform (HCP):
  • Go to Terraform Cloud (https://app.terraform.io/) and create an account.
  • Create an organization (e.g. terraform-states) and a workspace (e.g. remote-state-demo) within Terraform Cloud. Workspaces are where state files are stored and managed.
 Configure Terraform Cloud Backend:
  • Add the following backend configuration to e.g. terraform.tf file:

terraform {
  backend "remote" {
    organization = "terraform-states"

    workspaces {
      name = "remote-state-demo"
    }
  }
}

Login to Terraform Cloud:

$ terraform login
Terraform will request an API token for app.terraform.io using your browser.

If login is successful, Terraform will store the token in plain text in
the following file for use by subsequent commands:
    /home/<user>/.terraform.d/credentials.tfrc.json

Do you want to proceed?
  Only 'yes' will be accepted to confirm.

  Enter a value: yes


---------------------------------------------------------------------------------

Terraform must now open a web browser to the tokens page for app.terraform.io.

If a browser does not open this automatically, open the following URL to proceed:
    https://app.terraform.io/app/settings/tokens?source=terraform-login


---------------------------------------------------------------------------------

Generate a token using your browser, and copy-paste it into this prompt.

Terraform will store the token in plain text in the following file
for use by subsequent commands:
    /home/<user>/.terraform.d/credentials.tfrc.json

Token for app.terraform.io:
  Enter a value: Opening in existing browser session.



Retrieved token for user <tf_user>


---------------------------------------------------------------------------------

                                          -                                
                                          -----                           -
                                          ---------                      --
                                          ---------  -                -----
                                           ---------  ------        -------
                                             -------  ---------  ----------
                                                ----  ---------- ----------
                                                  --  ---------- ----------
   Welcome to HCP Terraform!                       -  ---------- -------
                                                      ---  ----- ---
   Documentation: terraform.io/docs/cloud             --------   -
                                                      ----------
                                                      ----------
                                                       ---------
                                                           -----
                                                               -


   New to HCP Terraform? Follow these steps to instantly apply an example configuration:

   $ git clone https://github.com/hashicorp/tfc-getting-started.git
   $ cd tfc-getting-started
   $ scripts/setup.sh

During this process a Terraform Cloud token generation page opens in browser:


terraform login should automatically pick the token and save it but in case this fails, you can copy the token and paste it here:

/home/<user>/.terraform.d/credentials.tfrc.json:

{
  "credentials": {
    "app.terraform.io": {
      "token": "1kLiQ....h3A"
    }
  }
}


This authentication is necessary for the next step:

Initialize the Backend:
  • Run terraform init to initialize the backend configuration
If we don't login to Terraform first we'll get:

$ terraform init
Initializing HCP Terraform...
│ Error: Required token could not be found
│ 
│ Run the following command to generate a token for app.terraform.io:
│     terraform login

If we're authenticated with Terraform:

$ terraform init
Initializing the backend...

Successfully configured the backend "remote"! Terraform will automatically
use this backend unless the backend configuration changes.
Initializing provider plugins...
- Finding latest version of hashicorp/local...
- Installing hashicorp/local v2.5.1...
- Installed hashicorp/local v2.5.1 (signed by HashiCorp)
Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.

Terraform has been successfully initialized!

You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.

If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.

Let's assume we have the following resource:

main.tf:

resource "local_file" "foo" {
  filename = "${path.cwd}/temp/foo.txt"
  content = "This is a text content of the foo file!"
}


We can now see the plan:

$ terraform plan
Running plan in the remote backend. Output will stream here. Pressing Ctrl-C
will stop streaming the logs, but will not stop the plan running remotely.

Preparing the remote plan...

To view this run in a browser, visit:
https://app.terraform.io/app/terraform-states/remote-state-demo/runs/run-nbxxG2TBxSYGEgCm

Waiting for the plan to start...

Terraform v1.9.3
on linux_amd64
Initializing plugins and modules...

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # local_file.foo will be created
  + resource "local_file" "foo" {
      + content              = "This is a text content of the foo file!"
      + content_base64sha256 = (known after apply)
      + content_base64sha512 = (known after apply)
      + content_md5          = (known after apply)
      + content_sha1         = (known after apply)
      + content_sha256       = (known after apply)
      + content_sha512       = (known after apply)
      + directory_permission = "0777"
      + file_permission      = "0777"
      + filename             = "/home/tfc-agent/.tfc-agent/component/terraform/runs/run-nbxxG2TBxSYGEgCm/config/temp/foo.txt"
      + id                   = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.


Notice that plan is running in remote backend and file path is also the one on the remote Terraform cloud machine. This is because we left our workspace to use organisation's Execution Mode which is Remote - all resources will be created on the remote machine. But this is not what we want, we want remote to contain only state file. Therefore we need to change the setting:




We can now apply the configuration (after executing terraform init so the new Execution Mode gets picked):


$ terraform plan
local_file.foo: Refreshing state... [id=db5ca40b5588d44e9ec6c1b4005e11a6fd0c910e]

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # local_file.foo will be created
  + resource "local_file" "foo" {
      + content              = "This is a text content of the foo file!"
      + content_base64sha256 = (known after apply)
      + content_base64sha512 = (known after apply)
      + content_md5          = (known after apply)
      + content_sha1         = (known after apply)
      + content_sha256       = (known after apply)
      + content_sha512       = (known after apply)
      + directory_permission = "0777"
      + file_permission      = "0777"
      + filename             = "/home/<user>/...hcp-cloud-state-storage-demo/temp/foo.txt"
      + id                   = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.


We can now execute terraform apply and changes will be done on the local machine.

If we create a resource on the remote (cloud), we can see it in web console:




If we by mistake create a resource on the remote (cloud), we can delete it by removing it from the state:

$ terraform state list
local_file.foo

$ terraform state rm local_file.foo
Removed local_file.foo
Successfully removed 1 resource instance(s).


All revisions of the state file are listed in Terraform Cloud. 






We can also roll back to some of the previous versions:










After this we need to unlock the state file: