Skip to content
Mulgamulga

EKS Quickstart

Stand up a minimal managed-Kubernetes cluster and a browsable Spinifex-themed demo app — VPC, IAM roles, an EKS cluster, a one- or three-worker node group, an ECR repository, and a load-balanced web page — using Terraform on Spinifex.

terraformekskubernetesiamvpcworkbook

Overview

Provision a managed Kubernetes cluster on Spinifex with Terraform/OpenTofu — and finish with something you can actually see. As well as the cluster, this workbook creates an ECR repository, then deploys the Spinifex-themed demo app onto the cluster and publishes it on the worker's public IP. When apply finishes, open the demo_url output: the page reports the pod, node, cluster, and region that answered. Refresh it and the answering pod changes, so even a non-technical viewer can watch Kubernetes scheduling and load-balancing in real time.

Under the hood it keeps things minimal: a VPC with two public subnets, an IAM role for the control plane, an IAM role for the workers with the three managed policies AWS-managed node groups expect, a public-endpoint aws_eks_cluster, and a node group sized by node_desired_size (1 for a single-node demo, or 3 for an HA-shaped cluster). You build the demo image and push it to the ECR repository, then Terraform uses the Kubernetes provider (authenticating exactly like kubectl does, via aws eks get-token) to deploy the Deployment + NodePort Service.

What you'll learn:

  • Configuring the AWS provider to target Spinifex's eks, ec2, iam and sts endpoints
  • Creating the EKS cluster and worker IAM roles with faithful managed-policy attachments
  • Provisioning an aws_eks_cluster and a managed aws_eks_node_group
  • Pointing the Kubernetes provider at the new cluster and deploying a workload from the same apply
  • Opening one rule on the auto-managed worker security group to expose a NodePort

What gets created

ResourceNamePurpose
VPCeks-quickstart-vpcIsolated network (10.30.0.0/16)
Subnetseks-quickstart-subnet-a/-bPublic subnets for the cluster and worker
Internet Gatewayeks-quickstart-igwEgress so the worker can pull the demo image
IAM Roleseks-quickstart-cluster-role, eks-quickstart-node-roleControl-plane and worker roles
ECR Repositoryspinifex-demoHolds the demo image the workers pull
EKS Clustereks-quickstartPublic API endpoint, API auth mode, Kubernetes 1.32
Node Groupdefaultnode_desired_size t3.medium worker(s) — 1 or 3
SG Ingress Ruleeks-quickstart-demo-nodeportOpens the demo NodePort on the auto-managed worker SG
K8s Deploymentspinifex-demoThe themed demo image from ECR, 2 replicas
K8s Servicespinifex-demoNodePort publishing the demo on the worker

Spinifex specifics

  • The cluster's security groups are auto-managedvpc_config.security_group_ids is ignored. To reach a NodePort, this workbook looks the worker SG up by its deterministic name (eks-cluster-<name>-nodegroup-sg) and adds a single ingress rule.
  • The worker AMI is always Spinifex's eks-node image; ami_type is recorded but does not select the image.
  • authentication_mode must be "API" (the API_AND_CONFIG_MAP mode is rejected).
  • The workers need outbound internet (here via the IGW) to pull the demo container image.

Prerequisites:

  • Spinifex installed and running (see Installing Spinifex)
  • The Spinifex eks-node image available on the cluster
  • OpenTofu or Terraform, plus kubectl and the AWS CLI
  • Docker, to build and push the demo image to ECR

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-quickstart

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

hcl
# Example: EKS Quickstart on Spinifex
#
# A minimal managed-Kubernetes cluster that ends in something you can see: a
# VPC, the two IAM roles EKS needs, a one- or three-worker cluster, an ECR
# repository, and the Spinifex-themed demo app deployed onto it by Terraform and
# exposed on the worker's public IP.
#
# Once `apply` finishes, open the demo_url output in a browser. The page reports
# the pod, node, cluster, and region that answered — refresh and it bounces
# between the app's replicas, showing the cluster is really scheduling and
# load-balancing pods.
#
# Demonstrates: VPC + subnets, EKS cluster + node IAM roles, an aws_eks_cluster
# and managed aws_eks_node_group, an ECR repository the workers pull from, the
# Kubernetes provider authenticating to the cluster, and a Deployment + NodePort
# Service reachable from your browser.
#
# Usage:
#   cd spinifex/docs/terraform-workbooks/eks-quickstart
#   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 open the demo_url output

terraform {
  required_version = ">= 1.6.0"

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

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

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

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

variable "k8s_version" {
  type        = string
  default     = "1.32"
  description = "Kubernetes minor version for the control plane and workers"
}

variable "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"
}

variable "browse_cidr" {
  type        = string
  default     = "0.0.0.0/0"
  description = "CIDR allowed to reach the demo NodePort; tighten to your own IP in production"
}

variable "spinifex_endpoint" {
  type        = string
  default     = "https://127.0.0.1:9999"
  description = "Spinifex AWS gateway endpoint"
}

# ---------------------------------------------------------------------------
# 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
  }

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

# ---------------------------------------------------------------------------
# Data sources
# ---------------------------------------------------------------------------

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

# ---------------------------------------------------------------------------
# VPC + two public subnets (workers get public IPs to pull the demo image)
# ---------------------------------------------------------------------------

resource "aws_vpc" "main" {
  cidr_block           = "10.30.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" "a" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.30.1.0/24"
  availability_zone       = data.aws_availability_zones.available.names[0]
  map_public_ip_on_launch = true

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

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

  tags = {
    Name = "${var.cluster_name}-subnet-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" "a" {
  subnet_id      = aws_subnet.a.id
  route_table_id = aws_route_table.public.id
}

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

# ---------------------------------------------------------------------------
# IAM — EKS 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 — worker 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"
}

# ---------------------------------------------------------------------------
# ECR — repository the workers pull the demo image from
#
# Build and push the demo-app image here before applying the workloads module
# (see ../demo-app/README.md). The node role already carries
# AmazonEC2ContainerRegistryReadOnly, so workers can pull from it.
# ---------------------------------------------------------------------------

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 API endpoint, API (access-entry) auth mode
# ---------------------------------------------------------------------------

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.a.id, aws_subnet.b.id]
    endpoint_public_access  = true
    endpoint_private_access = false
  }

  access_config {
    authentication_mode                         = "API"
    bootstrap_cluster_creator_admin_permissions = true
  }

  depends_on = [aws_iam_role_policy_attachment.cluster]

  tags = {
    Name = var.cluster_name
  }
}

# ---------------------------------------------------------------------------
# Managed node group — one worker
# ---------------------------------------------------------------------------

resource "aws_eks_node_group" "default" {
  cluster_name    = aws_eks_cluster.this.name
  node_group_name = "default"
  node_role_arn   = aws_iam_role.node.arn
  subnet_ids      = [aws_subnet.a.id, aws_subnet.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.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}-default"
  }
}

# ---------------------------------------------------------------------------
# Open the NodePort on the auto-managed nodegroup SG
#
# Spinifex creates the worker SG itself (vpc_config.security_group_ids is
# ignored) and admits only intra-cluster traffic. To reach the demo NodePort
# from a browser, 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.default]
}

resource "aws_vpc_security_group_ingress_rule" "nodeport" {
  security_group_id = data.aws_security_group.nodegroup.id
  cidr_ipv4         = var.browse_cidr
  from_port         = var.node_port
  to_port           = var.node_port
  ip_protocol       = "tcp"

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

# ---------------------------------------------------------------------------
# Discover the worker's public IP for the demo URL
# ---------------------------------------------------------------------------

data "aws_instances" "workers" {
  instance_tags = {
    "spinifex:eks-cluster" = aws_eks_cluster.this.name
  }

  depends_on = [aws_eks_node_group.default]
}

# ---------------------------------------------------------------------------
# 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 "ecr_repository_url" {
  value       = aws_ecr_repository.demo.repository_url
  description = "Push the demo-app image here, then apply the workloads module"
}

output "demo_url" {
  value       = "http://${data.aws_instances.workers.public_ips[0]}:${var.node_port}"
  description = "Open in a browser; refresh to see different pods answer"
}

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

Step 2. Deploy

The cluster and the demo app are two root modules with separate state. Apply the cluster first:

bash
export AWS_PROFILE=spinifex
tofu init
tofu apply

This creates the cluster (which bootstraps a control-plane VM and brings up k3s — a few minutes in CREATING), launches the worker(s), creates the spinifex-demo ECR repository, and opens the NodePort.

Set the worker count with node_desired_size (1 for a single node, 3 for an HA-shaped cluster):

bash
tofu apply -var node_desired_size=3

Once the cluster is ACTIVE, build and push the demo image to the ECR repository this created (full commands in ../demo-app/README.md):

bash
cd ../demo-app
REGISTRY=$(cd ../eks-quickstart && 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-quickstart

Then deploy the demo app from the nested workloads/ module — it defaults demo_image to the parent's ECR repository at :latest:

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 ever configured while the cluster exists. Keeping it out of the cluster module means destroy never tries to refresh a workload against a cluster that's already gone — the failure mode where the provider falls back to http://localhost:80 and reports connection refused. Always destroy workloads/ before the cluster.

Same profile for the Kubernetes provider. The Kubernetes provider authenticates by shelling out to aws eks get-token, which has to reach the Spinifex STS endpoint. Keep AWS_PROFILE=spinifex exported for both applies.

hcl
# Demo workload for eks-quickstart — 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.
#
# Usage:
#   cd spinifex/docs/terraform-workbooks/eks-quickstart
#   tofu init && tofu apply                    # parent: cluster + infra
#   cd workloads && tofu init && tofu apply    # this module: demo app
#   # 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"
  description = "Spinifex AWS gateway endpoint"
}

variable "replicas" {
  type        = number
  default     = 2
  description = "Demo app replicas; refresh the page to see requests land on different pods"
}

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

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
  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
}

# Authenticates with the same `aws eks get-token` exec flow the generated
# kubeconfig uses, so the Kubernetes provider can deploy the demo app.
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. With multiple replicas, refreshing the demo_url alternates
# between them. 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
          }

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

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]
}

Step 3. Open the Demo

Run from the cluster module directory (cd .. if you're still in workloads/):

bash
tofu output demo_url

Open that URL in a browser. You'll see the Spinifex-themed page reporting the pod, node, cluster, and region that handled the request. Refresh a few times — with two replicas, the pod name alternates, demonstrating that the Service is load-balancing across the cluster.

Step 4. Inspect with kubectl (optional)

bash
aws eks update-kubeconfig --name eks-quickstart --region ap-southeast-2
kubectl get nodes
kubectl get pods -o wide

kubectl get pods -o wide shows the demo pods and which node each landed on.

Cleanup

Destroy in reverse — the demo app first (while the cluster is still up), then the cluster:

bash
cd workloads
tofu destroy
cd ..
tofu destroy

Troubleshooting

Demo URL Doesn't Load

The page is served from a NodePort on the worker's public IP. Work through the chain:

bash
# Is the worker running with a public IP?
aws ec2 describe-instances --filters "Name=tag:spinifex:eks-cluster,Values=eks-quickstart" \
  --query 'Reservations[].Instances[].[InstanceId,State.Name,PublicIpAddress]' --output text

# Did the demo pods roll out?
kubectl get pods -o wide

# Is the NodePort rule on the worker SG?
aws ec2 describe-security-groups --filters "Name=group-name,Values=eks-cluster-eks-quickstart-nodegroup-sg" \
  --query 'SecurityGroups[0].IpPermissions'

If the pods are Pending, the worker may not be Ready yet — give it a moment. If they're ImagePullBackOff, the worker can't pull from ECR: confirm you built and pushed the image (tofu output ecr_repository_url), that the IGW route and the worker's public IP are in place, and that the node role carries AmazonEC2ContainerRegistryReadOnly.

kubernetes provider: connection refused / Unauthorized

The provider runs aws eks get-token against the Spinifex STS endpoint. Confirm the AWS CLI is still pointed at Spinifex and the cluster is ACTIVE:

bash
aws sts get-caller-identity
aws eks describe-cluster --name eks-quickstart --query 'cluster.status'

If the cluster was still CREATING when the provider first tried to connect, just re-run tofu apply.

Cluster Stuck in CREATING

Control-plane bootstrap takes a few minutes. Confirm the underlying VM came up:

bash
aws eks describe-cluster --name eks-quickstart --query 'cluster.status'
aws ec2 describe-instances --profile spinifex

Provider Connection Refused

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