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.
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-controlleraddon and letting it own ingress - Disabling the built-in K3s ingress with the
spinifex.io/managed-ingresstag - 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
| Resource | Name | Purpose |
|---|---|---|
| VPC | eks-https-vpc | Isolated network (10.31.0.0/16) |
| Subnets | eks-https-public-a/-b, eks-https-private-a/-b | Public (ALB + NAT) and private (workers) |
| Internet + NAT Gateway | eks-https-igw, eks-https-nat | Public egress; private-worker egress to ECR |
| IAM Roles | eks-https-cluster-role, eks-https-node-role | Control-plane and worker roles |
| ECR Repository | spinifex-demo | Holds the demo image the workers pull |
| EKS Cluster | eks-https | Public + private endpoints; managed-ingress=false |
| Node Group | workers | node_desired_size t3.large worker(s) — 1 or 3 |
| Addon | aws-load-balancer-controller | Provisions the ALB from the Ingress |
| ACM Certificate | eks-https-ingress | Self-signed, imported; attached to the HTTPS listener |
| SG Ingress Rule | eks-https-alb-nodeport | Admits the VPC CIDR to the workers' NodePort |
| K8s Deployment + Service | spinifex-demo | Themed demo image, 2 replicas, NodePort |
| K8s Ingress | spinifex-demo | ingressClassName: 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
albIngressClassParams. The Ingress just setsingressClassName: albplus thealb.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 ittrue) 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
tlsprovider and imported, not requested. - Leave
addon_versionunset (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-nodeimage (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
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.
# 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):
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
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
cd workloads
tofu init
tofu apply
Why two modules? The Kubernetes provider in
workloads/reads the cluster endpoint from a livedata "aws_eks_cluster"source, so it's only configured while the cluster exists. Always destroyworkloads/before the cluster, or the provider falls back tohttp://localhost:80and reportsconnection refused. KeepAWS_PROFILE=spinifexexported for both applies.
# 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:
# 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:
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:
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:
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
sudo systemctl status spinifex.target
curl -k https://localhost:9999/