Skip to content
mulga

Nginx Web Server (Load Balanced)

Deploy Nginx EC2 instances behind an internet-facing Application Load Balancer on Spinifex with Terraform, then spread them across nodes and test failover.

terraformnginxec2elbv2albvpcplacement-groupresiliencyworkbook

Overview

Deploy Nginx web servers behind an internet-facing Application Load Balancer on Spinifex using Terraform or OpenTofu. This workbook provisions a VPC with two public subnets, an internet gateway, a route table, a security group, an SSH key pair, an Application Load Balancer (ALB) and two or more EC2 instances with cloud-init user-data that installs and starts Nginx. The web servers get public IPs so cloud-init can install Nginx, and the ALB reaches them over the VPC by their private addresses.

On a cluster of three or more nodes, a spread placement group puts each web server on a different node. Losing a node then takes out at most one server, the ALB stops sending it traffic, and Spinifex restarts the lost instances on the surviving nodes. Step 6 shows how to watch that happen.

Nginx + ALB VPC — IGW, two public subnets with the ALB and the nginx web servers

What you'll learn:

  • Configuring the AWS Terraform provider to target Spinifex
  • Creating a VPC with public subnets and an internet gateway
  • Provisioning an internet-facing ALB in front of a group of web servers
  • Provisioning EC2 instances with cloud-init user-data
  • Spreading instances across nodes with a placement group
  • Generating SSH key pairs with the TLS provider

What gets created

ResourceNamePurpose
VPCnginx-alb-vpcIsolated network (10.20.0.0/16)
Public Subnetsnginx-alb-public-a, nginx-alb-public-bHost the ALB and the web servers
Internet Gatewaynginx-alb-igwRoutes internet traffic for the public subnets
Security Groupnginx-alb-sgAllows SSH (22) and HTTP (80) inbound
Placement Groupnginx-alb-spreadOnly with placement_strategy; spread puts one web server per node
EC2 Instancesnginx-alb-1, nginx-alb-2, …Ubuntu with Nginx via cloud-init, one per server_count
ALBnginx-albInternet-facing Application Load Balancer on port 80
Target Groupnginx-alb-tgHTTP health-checked group of every web server
ListenerHTTP :80Forwards traffic to the target group

Variables

VariableDefaultPurpose
instance_typet3.smallInstance type of the web servers
server_count2Number of web servers behind the ALB
placement_strategynonespread puts each web server on a different node; cluster puts them all on one
regionap-southeast-2Spinifex region
spinifex_endpointhttps://127.0.0.1:9999Spinifex AWS endpoint

A spread placement group on Spinifex spreads across nodes, so server_count can be at most the number of nodes in the cluster.

Prerequisites:


Instructions

Step 1. Get the Template

Clone the Terraform examples from the Spinifex repository:

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/nginx-alb

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

hcl
# Example: Nginx Web Servers with ALB on Spinifex
#
# Deploys a VPC with two public subnets hosting Nginx EC2 instances and an
# internet-facing Application Load Balancer. Workers sit in public subnets
# with auto-assigned public IPs purely so cloud-init can apt-install nginx.
# The ALB targets them by primary private IP (target_type=instance), so
# load-balanced traffic stays on the private VPC network.
#
# In a production deployment, workers would be in private subnets with
# nginx baked into a custom AMI (or installed via a private repository
# mirror), and a NAT Gateway would be unnecessary for this workload.
#
# Demonstrates: VPC, public subnets, internet gateway, route tables,
# security group, key pair, cloud-init user-data, EC2 instances with
# auto-assigned public IPs, an optional placement group, ALB, target group,
# and listener.
#
# server_count sets how many web servers launch (default 2). Setting
# placement_strategy = "spread" puts each one on a different Spinifex node,
# so a three-node cluster with server_count = 3 can lose any node and keep
# serving through the ALB.
#
# Usage:
#   cd spinifex/docs/terraform-workbooks/nginx-alb
#   export AWS_PROFILE=spinifex
#   tofu init && tofu apply
#
# After apply, fetch the ALB's public IP (the *.elb.spinifex.local DNS
# name does not resolve from your host):
#
#   aws elbv2 describe-load-balancers --names nginx-alb \
#     --query 'LoadBalancers[0].AvailabilityZones[].LoadBalancerAddresses[].IpAddress' \
#     --output text
#
# Then:
#   curl http://<alb_public_ip>    # Load-balanced Nginx (alternates between instances)
#
# Three servers, one per node:
#   tofu apply -var server_count=3 -var placement_strategy=spread

terraform {
  required_version = ">= 1.6.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "5.65.0"
    }
    tls = {
      source  = "hashicorp/tls"
      version = "4.4.1"
    }
    local = {
      source  = "hashicorp/local"
      version = "2.9.1"
    }
  }
}

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

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

variable "instance_type" {
  type    = string
  default = "t3.small"
}

variable "server_count" {
  type        = number
  default     = 2
  description = "Number of Nginx web servers behind the ALB"

  validation {
    condition     = var.server_count >= 1
    error_message = "server_count must be at least 1."
  }
}

variable "placement_strategy" {
  type        = string
  default     = ""
  description = "Placement group strategy for the web servers: spread (one per node), cluster (all on one node), or empty for none"

  validation {
    condition     = contains(["", "spread", "cluster"], var.placement_strategy)
    error_message = "placement_strategy must be spread, cluster, or empty."
  }
}

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

# ---------------------------------------------------------------------------
# Provider — point the AWS provider at Spinifex
# ---------------------------------------------------------------------------

provider "aws" {
  region = var.region

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

  skip_metadata_api_check = true
  skip_region_validation  = true
}

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

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

data "aws_ami" "ubuntu" {
  most_recent = true
  owners      = ["000000000000"] # Spinifex system images

  filter {
    name   = "name"
    values = ["*ubuntu-26.04*", "*ubuntu-24.04*"]
  }
}

# ---------------------------------------------------------------------------
# SSH Key Pair
# ---------------------------------------------------------------------------

resource "tls_private_key" "nginx" {
  algorithm = "ED25519"
}

resource "aws_key_pair" "nginx" {
  key_name   = "nginx-alb-demo"
  public_key = tls_private_key.nginx.public_key_openssh
}

resource "local_file" "nginx_pem" {
  filename        = "${path.module}/nginx-alb-demo.pem"
  content         = tls_private_key.nginx.private_key_openssh
  file_permission = "0600"
}

# ---------------------------------------------------------------------------
# VPC
# ---------------------------------------------------------------------------

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

  tags = {
    Name = "nginx-alb-vpc"
  }
}

# ---------------------------------------------------------------------------
# Internet Gateway
# ---------------------------------------------------------------------------

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

  tags = {
    Name = "nginx-alb-igw"
  }
}

# ---------------------------------------------------------------------------
# Public Subnets (two AZs for the ALB and NAT Gateway)
# ---------------------------------------------------------------------------

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

  tags = {
    Name = "nginx-alb-public-a"
  }
}

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

  tags = {
    Name = "nginx-alb-public-b"
  }
}

# ---------------------------------------------------------------------------
# Route Table — public subnets egress via IGW
# ---------------------------------------------------------------------------

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 = "nginx-alb-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
}

# ---------------------------------------------------------------------------
# Security Group — SSH + HTTP inbound, all outbound
# ---------------------------------------------------------------------------

resource "aws_security_group" "web" {
  name        = "nginx-alb-sg"
  description = "Allow SSH and HTTP inbound"
  vpc_id      = aws_vpc.main.id

  ingress {
    description = "SSH"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    description = "HTTP"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    description = "All outbound"
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "nginx-alb-sg"
  }
}

# ---------------------------------------------------------------------------
# Placement Group — optional; spread puts each web server on its own node
# ---------------------------------------------------------------------------

resource "aws_placement_group" "web" {
  count    = var.placement_strategy == "" ? 0 : 1
  name     = "nginx-alb-${var.placement_strategy}"
  strategy = var.placement_strategy
}

# ---------------------------------------------------------------------------
# EC2 Instances — Nginx servers with distinct landing pages, alternating
# between the two public subnets
# ---------------------------------------------------------------------------

resource "aws_instance" "nginx" {
  count = var.server_count

  ami             = data.aws_ami.ubuntu.id
  instance_type   = var.instance_type
  placement_group = one(aws_placement_group.web[*].name)

  subnet_id              = count.index % 2 == 0 ? aws_subnet.public_a.id : aws_subnet.public_b.id
  vpc_security_group_ids = [aws_security_group.web.id]
  key_name               = aws_key_pair.nginx.key_name

  user_data_base64 = base64encode(<<-USERDATA
    #!/bin/bash
    set -euo pipefail

    apt-get update -y
    apt-get install -y nginx

    INSTANCE_ID=$(cat /var/lib/cloud/data/instance-id 2>/dev/null || hostname)
    cat > /var/www/html/index.html <<HTML
    <!DOCTYPE html>
    <html>
    <head><title>Spinifex ALB Demo</title></head>
    <body style="font-family: sans-serif; max-width: 600px; margin: 80px auto;">
      <h1>Hello from Spinifex!</h1>
      <p><strong>Instance:</strong> $INSTANCE_ID (Server ${count.index + 1})</p>
      <p>This Nginx server is behind an Application Load Balancer.</p>
      <hr>
      <p><small>Provisioned via cloud-init user-data.</small></p>
    </body>
    </html>
    HTML

    systemctl enable nginx
    systemctl restart nginx
  USERDATA
  )

  tags = {
    Name = "nginx-alb-${count.index + 1}"
  }
}

# Earlier versions declared the two servers separately.
moved {
  from = aws_instance.nginx_1
  to   = aws_instance.nginx[0]
}

moved {
  from = aws_instance.nginx_2
  to   = aws_instance.nginx[1]
}

# ---------------------------------------------------------------------------
# Application Load Balancer
# ---------------------------------------------------------------------------

resource "aws_lb" "web" {
  name               = "nginx-alb"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.web.id]
  subnets            = [aws_subnet.public_a.id, aws_subnet.public_b.id]

  tags = {
    Name = "nginx-alb"
  }
}

# ---------------------------------------------------------------------------
# Target Group — HTTP health-checked on port 80
# ---------------------------------------------------------------------------

resource "aws_lb_target_group" "nginx" {
  name     = "nginx-alb-tg"
  port     = 80
  protocol = "HTTP"
  vpc_id   = aws_vpc.main.id

  health_check {
    path                = "/"
    protocol            = "HTTP"
    healthy_threshold   = 2
    unhealthy_threshold = 3
    timeout             = 5
    interval            = 10
  }

  tags = {
    Name = "nginx-alb-tg"
  }
}

# ---------------------------------------------------------------------------
# Register every web server as a target
# ---------------------------------------------------------------------------

resource "aws_lb_target_group_attachment" "nginx" {
  count = var.server_count

  target_group_arn = aws_lb_target_group.nginx.arn
  target_id        = aws_instance.nginx[count.index].id
  port             = 80
}

moved {
  from = aws_lb_target_group_attachment.nginx_1
  to   = aws_lb_target_group_attachment.nginx[0]
}

moved {
  from = aws_lb_target_group_attachment.nginx_2
  to   = aws_lb_target_group_attachment.nginx[1]
}

# ---------------------------------------------------------------------------
# Listener — forward HTTP :80 to the target group
# ---------------------------------------------------------------------------

resource "aws_lb_listener" "http" {
  load_balancer_arn = aws_lb.web.arn
  port              = 80
  protocol          = "HTTP"

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.nginx.arn
  }
}

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

output "note" {
  value = <<-EOT
    EC2 instances can take 30+ seconds to boot after apply, and Nginx installs
    after that — if HTTP is unreachable, wait and retry.

    The ALB DNS name ends in .elb.spinifex.local and will not resolve from
    your host, so fetch the ALB's public IP with:

      aws elbv2 describe-load-balancers --names nginx-alb \
        --query 'LoadBalancers[0].AvailabilityZones[].LoadBalancerAddresses[].IpAddress' \
        --output text

    Then: curl http://<that-ip>
  EOT
}

output "alb_name" {
  value = aws_lb.web.name
}

output "alb_arn" {
  value = aws_lb.web.arn
}

output "alb_dns_name" {
  value = aws_lb.web.dns_name
}

output "instance_ids" {
  value = aws_instance.nginx[*].id
}

output "instance_private_ips" {
  value = aws_instance.nginx[*].private_ip
}

output "instance_public_ips" {
  value = aws_instance.nginx[*].public_ip
}

output "placement_group" {
  value = one(aws_placement_group.web[*].name)
}

Step 2. Import the Ubuntu AMI

Import the Ubuntu 26.04 AMI the web servers run on, from any node of the cluster:

bash
spx admin images import --name ubuntu-26.04-x86_64

Note: The load balancer itself runs as a direct-boot QEMU microvm using the kernel + initramfs bundled with the Spinifex distribution (/usr/share/spinifex/microvm/), so no separate LB AMI import is required.

Step 3. Deploy

bash
export AWS_PROFILE=spinifex
tofu init

Step 4. Specify instance and apply

Next, depending on your architecture and CPU/memory requirements you must specify an instance type to launch.

Either specify an instance type directly (e.g Intel)

bash
# AMD instance
export TF_VAR_instance_type="t3a.small"

# Or, Intel
export TF_VAR_instance_type="t3.small"

Or alternatively, using the AWS CLI tool query your instance for available types (e.g Intel, AMD, ARM) that support 2 vCPUs and 1 GB RAM.

bash
export TF_VAR_instance_type=$(aws ec2 describe-instance-types \
  --query "sort_by(InstanceTypes[?VCpuInfo.DefaultVCpus==\`2\` && MemoryInfo.SizeInMiB>=\`1024\`], &MemoryInfo.SizeInMiB)[0].InstanceType" \
  --output text)

Next, apply and launch the template:

bash
tofu apply

On a three-node cluster, launch three web servers instead, one on each node:

bash
tofu apply -var server_count=3 -var placement_strategy=spread

Step 5. Verify

Note: EC2 instances can take 30+ seconds to boot after apply, and Nginx installs after that. If the ALB returns 5xx or HTTP is unreachable, wait and retry, since the target group health checks need a moment to mark the instances healthy once Nginx has installed.

The ALB is internet-facing, but the DNS name Spinifex returns (*.elb.spinifex.local) will not resolve from your host. Fetch the ALB's public IP with the AWS CLI:

bash
ALB_IP=$(aws elbv2 describe-load-balancers --names nginx-alb \
  --query 'LoadBalancers[0].AvailabilityZones[].LoadBalancerAddresses[].IpAddress' \
  --output text)

Then hit the ALB, and successive requests rotate between the servers:

bash
curl http://$ALB_IP
curl http://$ALB_IP

Open http://$ALB_IP in your browser and refresh to see the page alternate content served from each instance.

Each web server also has a public IP of its own, listed by tofu output instance_public_ips. The ALB reaches them by their private IPs, listed by tofu output instance_private_ips.

Check target health via AWS CLI:

bash
TG_ARN=$(aws elbv2 describe-target-groups \
  --query 'TargetGroups[0].TargetGroupArn' \
  --output text)

aws elbv2 describe-target-health --target-group-arn $TG_ARN

Step 6. Test resiliency

On a cluster of three nodes, apply with -var server_count=3 -var placement_strategy=spread, then check that each web server is on its own node:

bash
spx get vms

The NODE column names each node once for the three t3.small web servers, and the load balancer is the sys.micro instance.

Request the page once a second in a second terminal:

bash
while :; do
  printf '%s %s\n' "$(date +%T)" "$(curl -s -m2 "http://$ALB_IP/" | grep -o 'Server [0-9]' || echo FAILED)"
  sleep 1
done

Then power off one node, the way a hardware failure would, without shutting it down first. On Vultr, deploy-vultr.sh stop node3 does this, as described in Running Spinifex on Vultr.

Within about a minute the load balancer stops sending requests to the lost web server, and the other two answer every request. About three minutes after the power-off, Spinifex has restarted the lost web server on a surviving node with the same instance ID, private IP and public IP, and the load balancer sends it requests again once its health checks pass. Power the node back on and wait until spx get nodes shows it Ready before you test another node.

Cleanup

bash
tofu destroy

If you applied with -var options, pass the same ones to tofu destroy.


Troubleshooting

AMI Not Found

Ensure you have imported an Ubuntu 26.04 image. Check available AMIs:

bash
aws ec2 describe-images --owners 000000000000 --profile spinifex

If missing import:

bash
spx admin images import --name ubuntu-26.04-x86_64

Provider Connection Refused

Verify Spinifex services are running:

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

ALB Returns 5xx / Targets Unhealthy

Give the instances a moment to finish cloud-init (Nginx has to install before it can answer health checks). Check target health:

bash
TG_ARN=$(aws elbv2 describe-target-groups --names nginx-alb-tg \
  --query 'TargetGroups[0].TargetGroupArn' --output text)
aws elbv2 describe-target-health --target-group-arn "$TG_ARN"

If targets stay unhealthy, verify the instances are running:

bash
aws ec2 describe-instances --profile spinifex

If cloud-init on the workers never finished, SSH to one by its public IP and read its log, as described in Nginx Not Responding.

Nginx Not Responding

SSH to the web server by its public IP, from tofu output instance_public_ips, and check cloud-init and Nginx:

bash
ssh -i nginx-alb-demo.pem ubuntu@<instance_public_ip>
sudo journalctl -u cloud-init --no-pager
sudo systemctl status nginx

Spread Launch Fails With InsufficientInstanceCapacity

A spread placement group puts at most one of its instances on each node. Set server_count no higher than the number of nodes, or leave placement_strategy empty.