Skip to content
Mulgamulga

EKS HTTPS Ingress (LBC + ACM)

Serve a Spinifex-themed demo app over HTTPS on EKS using the AWS Load Balancer Controller addon and an ACM certificate — an internet-facing ALB provisioned from a Kubernetes Ingress, using Terraform on Spinifex.

terraformekskubernetesingressalbacmhttpsworkbook

Overview

This is the second rung of the EKS ladder. It takes the cluster + demo app from EKS Quickstart and publishes the app the way you would in production: a Kubernetes Ingress reconciled by the AWS Load Balancer Controller (LBC) into an internet-facing Application Load Balancer that terminates TLS with an ACM certificate.

To make room for an internet-facing ALB with private workers, the network grows from the quickstart's two public subnets to a public/private split: the ALB and a NAT gateway sit in the public subnets, the workers sit in the private subnets and reach ECR through the NAT. The cluster is tagged spinifex.io/managed-ingress = "false", which disables K3s' built-in traefik/servicelb so the LBC owns ingress.

The LBC is installed as a managed addon (aws_eks_addon). Spinifex wires the controller's AWS credentials and the cluster's ELB-eligible subnets at the node level and injects those subnets into the alb IngressClassParams, so the Ingress needs no IRSA role and no subnet tags — just ingressClassName: alb and a handful of annotations.

What you'll learn:

  • Installing the aws-load-balancer-controller addon and letting it own ingress
  • Disabling the built-in K3s ingress with the spinifex.io/managed-ingress tag
  • Importing a self-signed certificate into ACM and attaching it to an ALB listener
  • Driving an ALB entirely from a Kubernetes Ingress (ingressClassName: alb)
  • A public/private VPC with a NAT gateway for private workers

What gets created

ResourceNamePurpose
VPCeks-https-vpcIsolated network (10.31.0.0/16)
Subnetseks-https-public-a/-b, eks-https-private-a/-bPublic (ALB + NAT) and private (workers)
Internet + NAT Gatewayeks-https-igw, eks-https-natPublic egress; private-worker egress to ECR
IAM Roleseks-https-cluster-role, eks-https-node-roleControl-plane and worker roles
ECR Repositoryspinifex-demoHolds the demo image the workers pull
EKS Clustereks-httpsPublic + private endpoints; managed-ingress=false
Node Groupworkersnode_desired_size t3.large worker(s) — 1 or 3
Addonaws-load-balancer-controllerProvisions the ALB from the Ingress
ACM Certificateeks-https-ingressSelf-signed, imported; attached to the HTTPS listener
SG Ingress Ruleeks-https-alb-nodeportAdmits the VPC CIDR to the workers' NodePort
K8s Deployment + Servicespinifex-demoThemed demo image, 2 replicas, NodePort
K8s Ingressspinifex-demoingressClassName: alb, HTTPS via the ACM cert

Spinifex specifics

  • The LBC needs no IRSA or subnet tagging here. Credentials and ELB-eligible subnets are injected by Spinifex at the node level and into the alb IngressClassParams. The Ingress just sets ingressClassName: alb plus the alb.ingress.kubernetes.io/* annotations.
  • spinifex.io/managed-ingress = "false" disables the K3s built-in traefik/servicelb so the LBC owns ingress, matching AWS parity. Omit the tag (or set it true) and the built-in ingress stays on.
  • The ALB targets the workers' NodePort (target-type: instance). The one SG rule admits the VPC CIDR on that port so the in-VPC ALB can reach the workers; the worker SG is otherwise auto-managed.
  • Spinifex ACM supports ImportCertificate only — the cert is generated by the tls provider and imported, not requested.
  • Leave addon_version unset (see the quickstart/addons notes) — the AWS provider and the Spinifex catalog disagree on the version string format, so let the server choose.

Prerequisites:

  • Spinifex installed and running, with the eks-node image (carrying the LBC bundle) available
  • OpenTofu or Terraform, plus kubectl, the AWS CLI, and Docker
  • The demo image built and pushed to ECR (see ../demo-app/README.md)

Instructions

Step 1. Get the Template

bash
git clone --depth 1 --filter=blob:none --sparse https://github.com/mulgadc/spinifex.git spinifex-tf
cd spinifex-tf
git sparse-checkout set docs/terraform-workbooks
cd docs/terraform-workbooks/eks-https-ingress

Or create a main.tf file and paste the full configuration below.

hcl
# Example: EKS HTTPS Ingress via the AWS Load Balancer Controller + ACM
#
# Builds on eks-quickstart. Instead of poking a NodePort open on the worker, this
# workbook serves the Spinifex-themed demo app over HTTPS through an Application
# Load Balancer that the AWS Load Balancer Controller (LBC) provisions from a
# Kubernetes Ingress — the same pattern you'd use on AWS EKS.
#
# What it adds over the quickstart:
#   * Public subnets for the ALB + a NAT gateway, private subnets for the workers.
#   * The aws-load-balancer-controller addon, installed through the EKS API.
#   * The cluster tag spinifex.io/managed-ingress = "false", which disables K3s'
#     built-in traefik/servicelb so the LBC owns ingress (AWS parity).
#   * A self-signed certificate imported into ACM and attached to the ALB's HTTPS
#     listener via an Ingress annotation.
#
# The workloads/ module then creates a Deployment, a NodePort Service, and an
# Ingress (ingressClassName: alb). The LBC reconciles that Ingress into an
# internet-facing ALB that terminates TLS with the ACM cert and forwards to the
# workers' NodePort. The cluster's ELB-eligible subnets are injected by Spinifex
# into the alb IngressClassParams, so the Ingress needs no subnet annotations.
#
# Usage:
#   cd spinifex/docs/terraform-workbooks/eks-https-ingress
#   export AWS_PROFILE=spinifex
#   tofu init && tofu apply
#   # build + push the demo image to the ECR repo this creates (see README),
#   # then: cd workloads && tofu init && tofu apply
#   # finally: kubectl get ingress spinifex-demo -o wide  → open https://<address>

terraform {
  required_version = ">= 1.6.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 5.40, < 6.0"
    }
    tls = {
      source  = "hashicorp/tls"
      version = ">= 4.0"
    }
  }
}

# ---------------------------------------------------------------------------
# Variables
# ---------------------------------------------------------------------------

variable "region" {
  type    = string
  default = "ap-southeast-2"
}

variable "cluster_name" {
  type    = string
  default = "eks-https"
}

variable "k8s_version" {
  type    = string
  default = "1.32"
}

variable "node_instance_type" {
  type    = string
  default = "t3.medium"
}

variable "node_desired_size" {
  type        = number
  default     = 1
  description = "Worker count. Use 1 for a single-node demo, or 3 for an HA-shaped cluster."

  validation {
    condition     = var.node_desired_size == 1 || var.node_desired_size == 3
    error_message = "node_desired_size must be 1 or 3."
  }
}

variable "node_port" {
  type        = number
  default     = 30080
  description = "NodePort the demo Service is published on and the ALB forwards to"
}

variable "cert_common_name" {
  type    = string
  default = "eks-https.spinifex.local"
}

variable "api_public_access_cidr" {
  type        = string
  default     = "0.0.0.0/0"
  description = "CIDR allowed to reach the public Kubernetes API endpoint; tighten in production"
}

variable "spinifex_endpoint" {
  type    = string
  default = "https://127.0.0.1:9999"
}

# ---------------------------------------------------------------------------
# Providers
# ---------------------------------------------------------------------------

provider "aws" {
  region = var.region

  endpoints {
    ec2 = var.spinifex_endpoint
    iam = var.spinifex_endpoint
    sts = var.spinifex_endpoint
    eks = var.spinifex_endpoint
    ecr = var.spinifex_endpoint
    acm = var.spinifex_endpoint
  }

  skip_credentials_validation = true
  skip_metadata_api_check     = true
  skip_requesting_account_id  = true
  skip_region_validation      = true
}

data "aws_availability_zones" "available" {
  state = "available"
}

# ---------------------------------------------------------------------------
# VPC — public subnets for the ALB + NAT, private subnets for the workers
# ---------------------------------------------------------------------------

resource "aws_vpc" "main" {
  cidr_block           = "10.31.0.0/16"
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name = "${var.cluster_name}-vpc"
  }
}

resource "aws_internet_gateway" "igw" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "${var.cluster_name}-igw"
  }
}

resource "aws_subnet" "public_a" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.31.1.0/24"
  availability_zone       = data.aws_availability_zones.available.names[0]
  map_public_ip_on_launch = true

  tags = {
    Name = "${var.cluster_name}-public-a"
  }
}

resource "aws_subnet" "public_b" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.31.2.0/24"
  availability_zone       = data.aws_availability_zones.available.names[0]
  map_public_ip_on_launch = true

  tags = {
    Name = "${var.cluster_name}-public-b"
  }
}

resource "aws_subnet" "private_a" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.31.11.0/24"
  availability_zone = data.aws_availability_zones.available.names[0]

  tags = {
    Name = "${var.cluster_name}-private-a"
  }
}

resource "aws_subnet" "private_b" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.31.12.0/24"
  availability_zone = data.aws_availability_zones.available.names[0]

  tags = {
    Name = "${var.cluster_name}-private-b"
  }
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.igw.id
  }

  tags = {
    Name = "${var.cluster_name}-public-rt"
  }
}

resource "aws_route_table_association" "public_a" {
  subnet_id      = aws_subnet.public_a.id
  route_table_id = aws_route_table.public.id
}

resource "aws_route_table_association" "public_b" {
  subnet_id      = aws_subnet.public_b.id
  route_table_id = aws_route_table.public.id
}

# NAT gateway gives the private workers outbound internet to pull the demo image
# from ECR. The workers still join the cluster over the in-VPC private endpoint.
resource "aws_eip" "nat" {
  domain = "vpc"

  tags = {
    Name = "${var.cluster_name}-nat-eip"
  }
}

resource "aws_nat_gateway" "nat" {
  allocation_id = aws_eip.nat.id
  subnet_id     = aws_subnet.public_a.id

  depends_on = [aws_internet_gateway.igw]

  tags = {
    Name = "${var.cluster_name}-nat"
  }
}

resource "aws_route_table" "private" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.nat.id
  }

  tags = {
    Name = "${var.cluster_name}-private-rt"
  }
}

resource "aws_route_table_association" "private_a" {
  subnet_id      = aws_subnet.private_a.id
  route_table_id = aws_route_table.private.id
}

resource "aws_route_table_association" "private_b" {
  subnet_id      = aws_subnet.private_b.id
  route_table_id = aws_route_table.private.id
}

# ---------------------------------------------------------------------------
# IAM — cluster role
# ---------------------------------------------------------------------------

resource "aws_iam_role" "cluster" {
  name = "${var.cluster_name}-cluster-role"

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

resource "aws_iam_role_policy_attachment" "cluster" {
  role       = aws_iam_role.cluster.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy"
}

# ---------------------------------------------------------------------------
# IAM — node role
# ---------------------------------------------------------------------------

resource "aws_iam_role" "node" {
  name = "${var.cluster_name}-node-role"

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

resource "aws_iam_role_policy_attachment" "node_worker" {
  role       = aws_iam_role.node.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy"
}

resource "aws_iam_role_policy_attachment" "node_cni" {
  role       = aws_iam_role.node.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy"
}

resource "aws_iam_role_policy_attachment" "node_ecr" {
  role       = aws_iam_role.node.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly"
}

# The AWS Load Balancer Controller runs with the node's instance-profile
# credentials (Spinifex wires creds at the node level, not IRSA — see the addon
# block below), so the permissions it needs to manage ALBs must live on the node
# role. This mirrors the upstream AWSLoadBalancerControllerIAMPolicy, trimmed to
# the actions an ALB Ingress exercises (Shield/WAF/Cognito are disabled on the
# controller). Resources are "*" since Spinifex evaluates grants by action.
# A customer-managed policy + attachment is used (Spinifex implements
# CreatePolicy/AttachRolePolicy, not inline PutRolePolicy).
resource "aws_iam_policy" "node_lbc" {
  name = "${var.cluster_name}-node-lbc"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "ec2:DescribeAccountAttributes",
          "ec2:DescribeAddresses",
          "ec2:DescribeAvailabilityZones",
          "ec2:DescribeInternetGateways",
          "ec2:DescribeVpcs",
          "ec2:DescribeSubnets",
          "ec2:DescribeSecurityGroups",
          "ec2:DescribeInstances",
          "ec2:DescribeNetworkInterfaces",
          "ec2:DescribeTags",
          "ec2:GetCoipPoolUsage",
          "ec2:DescribeCoipPools",
          "ec2:CreateSecurityGroup",
          "ec2:CreateTags",
          "ec2:DeleteTags",
          "ec2:AuthorizeSecurityGroupIngress",
          "ec2:RevokeSecurityGroupIngress",
          "ec2:DeleteSecurityGroup",
          "elasticloadbalancing:*",
          "acm:ListCertificates",
          "acm:DescribeCertificate",
          "acm:GetCertificate",
          "iam:CreateServiceLinkedRole",
          "iam:ListServerCertificates",
          "iam:GetServerCertificate",
          "tag:GetResources",
          "tag:TagResources",
          "wafv2:GetWebACLForResource",
          "shield:GetSubscriptionState"
        ]
        Resource = "*"
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "node_lbc" {
  role       = aws_iam_role.node.name
  policy_arn = aws_iam_policy.node_lbc.arn
}

# ---------------------------------------------------------------------------
# ECR — repository the workers pull the demo image from
# ---------------------------------------------------------------------------

resource "aws_ecr_repository" "demo" {
  name = "spinifex-demo"

  # tofu destroy must remove the repo even though it still holds the pushed demo
  # image; without this, DeleteRepository (force=false) fails RepositoryNotEmpty.
  force_delete = true
}

# ---------------------------------------------------------------------------
# EKS cluster — public + private endpoints, LBC-owned ingress
#
# The spinifex.io/managed-ingress = "false" tag disables K3s' built-in
# traefik/servicelb so the AWS Load Balancer Controller owns ingress, matching
# how ingress works on AWS EKS.
# ---------------------------------------------------------------------------

resource "aws_eks_cluster" "this" {
  name     = var.cluster_name
  role_arn = aws_iam_role.cluster.arn
  version  = var.k8s_version

  vpc_config {
    subnet_ids              = [aws_subnet.private_a.id, aws_subnet.private_b.id]
    endpoint_public_access  = true
    endpoint_private_access = true
    public_access_cidrs     = [var.api_public_access_cidr]
  }

  access_config {
    authentication_mode                         = "API"
    bootstrap_cluster_creator_admin_permissions = true
  }

  depends_on = [aws_iam_role_policy_attachment.cluster]

  tags = {
    Name                          = var.cluster_name
    "spinifex.io/managed-ingress" = "false"
  }
}

# ---------------------------------------------------------------------------
# Managed node group — workers in the private subnets
# ---------------------------------------------------------------------------

resource "aws_eks_node_group" "workers" {
  cluster_name    = aws_eks_cluster.this.name
  node_group_name = "workers"
  node_role_arn   = aws_iam_role.node.arn
  subnet_ids      = [aws_subnet.private_a.id, aws_subnet.private_b.id]

  scaling_config {
    desired_size = var.node_desired_size
    min_size     = var.node_desired_size
    max_size     = var.node_desired_size * 2
  }

  instance_types = [var.node_instance_type]
  ami_type       = "AL2_x86_64"

  depends_on = [
    aws_iam_role_policy_attachment.node_worker,
    aws_iam_role_policy_attachment.node_cni,
    aws_iam_role_policy_attachment.node_ecr,
  ]

  tags = {
    Name = "${var.cluster_name}-workers"
  }
}

# ---------------------------------------------------------------------------
# Addon — AWS Load Balancer Controller
#
# Spinifex wires the controller's AWS credentials and ELB-eligible subnets at the
# node level, so no IRSA role or subnet tagging is needed here. addon_version is
# omitted: the AWS provider demands a v-prefixed version that the catalog rejects,
# so let Spinifex default to its catalog version.
# ---------------------------------------------------------------------------

resource "aws_eks_addon" "lbc" {
  cluster_name                = aws_eks_cluster.this.name
  addon_name                  = "aws-load-balancer-controller"
  resolve_conflicts_on_create = "OVERWRITE"

  depends_on = [aws_eks_node_group.workers]

  tags = {
    Name = "${var.cluster_name}-lbc"
  }
}

# ---------------------------------------------------------------------------
# TLS — self-signed certificate imported into ACM
#
# Spinifex ACM supports ImportCertificate (not RequestCertificate), so the cert
# is generated locally by the tls provider and imported. The workloads Ingress
# attaches it to the ALB's HTTPS listener by ARN.
# ---------------------------------------------------------------------------

resource "tls_private_key" "ingress" {
  algorithm = "RSA"
  rsa_bits  = 2048
}

resource "tls_self_signed_cert" "ingress" {
  private_key_pem = tls_private_key.ingress.private_key_pem

  subject {
    common_name  = var.cert_common_name
    organization = "Spinifex EKS Demo"
  }

  dns_names             = [var.cert_common_name]
  validity_period_hours = 8760
  early_renewal_hours   = 720

  allowed_uses = [
    "key_encipherment",
    "digital_signature",
    "server_auth",
  ]
}

resource "aws_acm_certificate" "ingress" {
  private_key      = tls_private_key.ingress.private_key_pem
  certificate_body = tls_self_signed_cert.ingress.cert_pem

  tags = {
    Name = "${var.cluster_name}-ingress"
  }
}

# ---------------------------------------------------------------------------
# Let the ALB reach the workers' NodePort
#
# Spinifex auto-manages the nodegroup SG and admits only intra-cluster traffic.
# The LBC-provisioned ALB lives in this VPC, so admit the VPC CIDR on the
# NodePort. Look the SG up by its deterministic name and add one rule.
# ---------------------------------------------------------------------------

data "aws_security_group" "nodegroup" {
  filter {
    name   = "group-name"
    values = ["eks-cluster-${var.cluster_name}-nodegroup-sg"]
  }

  filter {
    name   = "vpc-id"
    values = [aws_vpc.main.id]
  }

  depends_on = [aws_eks_node_group.workers]
}

resource "aws_vpc_security_group_ingress_rule" "nodeport_from_vpc" {
  security_group_id = data.aws_security_group.nodegroup.id
  cidr_ipv4         = aws_vpc.main.cidr_block
  from_port         = var.node_port
  to_port           = var.node_port
  ip_protocol       = "tcp"

  tags = {
    Name = "${var.cluster_name}-alb-nodeport"
  }
}

# ---------------------------------------------------------------------------
# Outputs
# ---------------------------------------------------------------------------

output "cluster_name" {
  value = aws_eks_cluster.this.name
}

output "region" {
  value = var.region
}

output "node_port" {
  value = var.node_port
}

output "node_desired_size" {
  value = var.node_desired_size
}

output "certificate_arn" {
  value = aws_acm_certificate.ingress.arn
}

output "ecr_repository_url" {
  value       = aws_ecr_repository.demo.repository_url
  description = "Push the demo-app image here, then apply the workloads module"
}

output "ingress_address_hint" {
  value = "After applying workloads: kubectl get ingress spinifex-demo -o jsonpath='{.status.loadBalancer.ingress[0].hostname}{\"\\n\"}' — then open https://<that-address> (self-signed cert: curl -k)."
}

output "update_kubeconfig" {
  value = "aws eks update-kubeconfig --name ${aws_eks_cluster.this.name} --region ${var.region}"
}

Step 2. Deploy the Cluster

The cluster and the demo app are two root modules with separate state. Apply the cluster first (set node_desired_size=3 for an HA-shaped cluster):

bash
export AWS_PROFILE=spinifex
tofu init
tofu apply

This brings up the cluster, the workers, the NAT gateway, the LBC addon, the ACM cert, and the spinifex-demo ECR repository. Give it a few minutes to reach ACTIVE and for the LBC addon to report healthy.

Step 3. Build and Push the Demo Image

bash
cd ../demo-app
REGISTRY=$(cd ../eks-https-ingress && tofu output -raw ecr_repository_url)
REGISTRY_HOST=${REGISTRY%%/*}
aws ecr get-login-password | docker login --username AWS --password-stdin "$REGISTRY_HOST"
docker build -t "${REGISTRY}:latest" .
docker push "${REGISTRY}:latest"
cd ../eks-https-ingress

Step 4. Deploy the App and Ingress

bash
cd workloads
tofu init
tofu apply

Why two modules? The Kubernetes provider in workloads/ reads the cluster endpoint from a live data "aws_eks_cluster" source, so it's only configured while the cluster exists. Always destroy workloads/ before the cluster, or the provider falls back to http://localhost:80 and reports connection refused. Keep AWS_PROFILE=spinifex exported for both applies.

hcl
# Demo workload for eks-https-ingress — separate root module / state.
#
# Kept apart from the cluster root module on purpose: the kubernetes provider
# below reads the cluster endpoint from a *live* data source, so it is only ever
# configured while the cluster exists. Destroy this module before the parent and
# the provider never falls back to localhost.
#
# Creates the Spinifex-themed demo Deployment, a NodePort Service, and an Ingress
# (ingressClassName: alb). The AWS Load Balancer Controller reconciles the
# Ingress into an internet-facing ALB that terminates TLS with the imported ACM
# certificate and forwards to the workers' NodePort.
#
# Usage:
#   cd spinifex/docs/terraform-workbooks/eks-https-ingress
#   tofu init && tofu apply                    # parent: cluster + LBC + ACM
#   cd workloads && tofu init && tofu apply    # this module: demo app + Ingress
#   # teardown is the reverse — destroy here first, then the parent.

terraform {
  required_version = ">= 1.6.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 5.40, < 6.0"
    }
    kubernetes = {
      source  = "hashicorp/kubernetes"
      version = ">= 2.20"
    }
  }
}

variable "spinifex_endpoint" {
  type    = string
  default = "https://127.0.0.1:9999"
}

variable "replicas" {
  type    = number
  default = 2
}

variable "demo_image" {
  type        = string
  default     = ""
  description = "Demo image ref. Defaults to the parent's ECR repository URL at :latest."
}

variable "inbound_cidr" {
  type        = string
  default     = "0.0.0.0/0"
  description = "CIDR allowed to reach the ALB HTTPS listener"
}

provider "aws" {
  region = data.terraform_remote_state.infra.outputs.region

  endpoints {
    ec2 = var.spinifex_endpoint
    iam = var.spinifex_endpoint
    sts = var.spinifex_endpoint
    eks = var.spinifex_endpoint
  }

  skip_credentials_validation = true
  skip_metadata_api_check     = true
  skip_requesting_account_id  = true
  skip_region_validation      = true
}

# Cluster identity comes from the parent module's state; the live endpoint/CA
# come from a data source so the provider is only configured while the cluster
# is up.
data "terraform_remote_state" "infra" {
  backend = "local"

  config = {
    path = "../terraform.tfstate"
  }
}

locals {
  cluster_name = data.terraform_remote_state.infra.outputs.cluster_name
  region       = data.terraform_remote_state.infra.outputs.region
  node_port    = data.terraform_remote_state.infra.outputs.node_port
  cert_arn     = data.terraform_remote_state.infra.outputs.certificate_arn
  demo_image   = var.demo_image != "" ? var.demo_image : "${data.terraform_remote_state.infra.outputs.ecr_repository_url}:latest"
}

data "aws_eks_cluster" "this" {
  name = local.cluster_name
}

provider "kubernetes" {
  host                   = data.aws_eks_cluster.this.endpoint
  cluster_ca_certificate = base64decode(data.aws_eks_cluster.this.certificate_authority[0].data)

  exec {
    api_version = "client.authentication.k8s.io/v1beta1"
    command     = "aws"
    args        = ["eks", "get-token", "--cluster-name", local.cluster_name, "--region", local.region]
  }
}

# The Spinifex-themed demo app reports the pod, node, cluster, and region that
# served the request. The pod and node names come from the downward API.
resource "kubernetes_deployment_v1" "demo" {
  metadata {
    name      = "spinifex-demo"
    namespace = "default"
    labels    = { app = "spinifex-demo" }
  }

  spec {
    replicas = var.replicas

    selector {
      match_labels = { app = "spinifex-demo" }
    }

    template {
      metadata {
        labels = { app = "spinifex-demo" }
      }

      spec {
        container {
          name  = "spinifex-demo"
          image = local.demo_image

          port {
            container_port = 8080
          }

          env {
            name = "POD_NAME"
            value_from {
              field_ref {
                field_path = "metadata.name"
              }
            }
          }

          env {
            name = "NODE_NAME"
            value_from {
              field_ref {
                field_path = "spec.nodeName"
              }
            }
          }

          env {
            name = "POD_NAMESPACE"
            value_from {
              field_ref {
                field_path = "metadata.namespace"
              }
            }
          }

          env {
            name  = "CLUSTER_NAME"
            value = local.cluster_name
          }

          env {
            name  = "AWS_REGION"
            value = local.region
          }

          env {
            name  = "APP_TITLE"
            value = "Spinifex EKS — HTTPS Ingress"
          }

          readiness_probe {
            http_get {
              path = "/healthz"
              port = 8080
            }
            initial_delay_seconds = 3
            period_seconds        = 10
          }
        }
      }
    }
  }
}

# NodePort Service: the ALB target group registers the workers on this port.
resource "kubernetes_service_v1" "demo" {
  metadata {
    name      = "spinifex-demo"
    namespace = "default"
  }

  spec {
    selector = { app = "spinifex-demo" }
    type     = "NodePort"

    port {
      port        = 80
      target_port = 8080
      node_port   = local.node_port
    }
  }

  depends_on = [kubernetes_deployment_v1.demo]
}

# Ingress reconciled by the AWS Load Balancer Controller into an internet-facing
# ALB. target-type instance registers the workers' NodePort; the ACM cert ARN
# attaches to the HTTPS:443 listener; HTTP is redirected to HTTPS. ELB subnets
# are injected by Spinifex into the alb IngressClassParams, so none are set here.
resource "kubernetes_ingress_v1" "demo" {
  metadata {
    name      = "spinifex-demo"
    namespace = "default"

    annotations = {
      "alb.ingress.kubernetes.io/scheme"           = "internet-facing"
      "alb.ingress.kubernetes.io/target-type"      = "instance"
      "alb.ingress.kubernetes.io/listen-ports"     = "[{\"HTTP\":80},{\"HTTPS\":443}]"
      "alb.ingress.kubernetes.io/certificate-arn"  = local.cert_arn
      "alb.ingress.kubernetes.io/ssl-redirect"     = "443"
      "alb.ingress.kubernetes.io/healthcheck-path" = "/healthz"
      "alb.ingress.kubernetes.io/inbound-cidrs"    = var.inbound_cidr
    }
  }

  spec {
    ingress_class_name = "alb"

    rule {
      http {
        path {
          path      = "/"
          path_type = "Prefix"

          backend {
            service {
              name = kubernetes_service_v1.demo.metadata[0].name
              port {
                number = 80
              }
            }
          }
        }
      }
    }
  }
}

output "ingress_name" {
  value = kubernetes_ingress_v1.demo.metadata[0].name
}

Step 5. Open the Demo over HTTPS

The LBC takes a minute or two to provision the ALB after the Ingress is created. The Ingress publishes the ALB's DNS name, which has no resolver yet (northstar will add one) — resolve it to the ALB's public IP with describe-load-balancers so you can reach it without DNS or /etc/hosts:

bash
# Point kubectl at the cluster first (writes ~/.kube/config) — without this every
# kubectl call fails to connect:
aws eks update-kubeconfig --name eks-https --region ap-southeast-2

kubectl get ingress spinifex-demo -o wide
DNSNAME=$(kubectl get ingress spinifex-demo -o jsonpath='{.status.loadBalancer.ingress[0].hostname}')
IP=$(aws elbv2 describe-load-balancers \
  --query "LoadBalancers[?DNSName=='${DNSNAME}'].AvailabilityZones[].LoadBalancerAddresses[].IpAddress | [0]" \
  --output text)
curl -k "https://$IP"          # self-signed cert: -k skips verification

Open https://$IP in a browser (accept the self-signed certificate). The Spinifex-themed page reports the pod, node, cluster, and region that answered — refresh to watch requests land on different replicas, now over HTTPS through the ALB.

Cleanup

Destroy in reverse — the app/Ingress first (so the LBC tears the ALB down while the cluster is up), then the cluster:

bash
cd workloads
tofu destroy
cd ..
tofu destroy

Troubleshooting

The Ingress Has No Address

The LBC populates status.loadBalancer once it has provisioned the ALB. If it stays empty:

bash
kubectl describe ingress spinifex-demo            # events show LBC reconcile errors
kubectl -n kube-system logs deploy/aws-load-balancer-controller --tail=50
aws eks describe-addon --cluster-name eks-https --addon-name aws-load-balancer-controller --query 'addon.status'

Confirm the addon is ACTIVE and the cluster carries spinifex.io/managed-ingress = "false" (otherwise the built-in ingress competes).

HTTPS Loads but Returns 502 / 504

The ALB reached the workers but the backend didn't answer. Check the demo pods and the NodePort SG rule:

bash
kubectl get pods -o wide
kubectl get svc spinifex-demo
aws ec2 describe-security-groups --filters "Name=group-name,Values=eks-cluster-eks-https-nodegroup-sg" \
  --query 'SecurityGroups[0].IpPermissions'

The eks-https-alb-nodeport rule must admit the VPC CIDR on the NodePort, and the target group health check (/healthz) must pass.

Addon Fails With "no baked bundle"

Spinifex only installs addons whose manifests are baked into the eks-node AMI. If describe-addon reports CREATE_FAILED with no baked bundle, the running worker image predates the LBC bundle — rebuild and republish the eks-node image, then recreate the addon.

Pods Stuck in ImagePullBackOff

The private workers pull from ECR through the NAT gateway. Confirm the image was pushed (tofu output ecr_repository_url), the NAT route is in place, and the node role carries AmazonEC2ContainerRegistryReadOnly.

Provider Connection Refused

bash
sudo systemctl status spinifex.target
curl -k https://localhost:9999/