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.
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.
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
| Resource | Name | Purpose |
|---|---|---|
| VPC | nginx-alb-vpc | Isolated network (10.20.0.0/16) |
| Public Subnets | nginx-alb-public-a, nginx-alb-public-b | Host the ALB and the web servers |
| Internet Gateway | nginx-alb-igw | Routes internet traffic for the public subnets |
| Security Group | nginx-alb-sg | Allows SSH (22) and HTTP (80) inbound |
| Placement Group | nginx-alb-spread | Only with placement_strategy; spread puts one web server per node |
| EC2 Instances | nginx-alb-1, nginx-alb-2, … | Ubuntu with Nginx via cloud-init, one per server_count |
| ALB | nginx-alb | Internet-facing Application Load Balancer on port 80 |
| Target Group | nginx-alb-tg | HTTP health-checked group of every web server |
| Listener | HTTP :80 | Forwards traffic to the target group |
Variables
| Variable | Default | Purpose |
|---|---|---|
instance_type | t3.small | Instance type of the web servers |
server_count | 2 | Number of web servers behind the ALB |
placement_strategy | none | spread puts each web server on a different node; cluster puts them all on one |
region | ap-southeast-2 | Spinifex region |
spinifex_endpoint | https://127.0.0.1:9999 | Spinifex 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:
- Spinifex installed and running (see Installing Spinifex)
- An Ubuntu 26.04 or 24.04 AMI imported (see Setting Up Your Cluster)
- OpenTofu or Terraform installed
- For Step 6, a cluster of three nodes
Instructions
Step 1. Get the Template
Clone the Terraform examples from the Spinifex repository:
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.
# 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:
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
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)
# 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.
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:
tofu apply
On a three-node cluster, launch three web servers instead, one on each node:
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:
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:
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:
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:
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:
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
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:
aws ec2 describe-images --owners 000000000000 --profile spinifex
If missing import:
spx admin images import --name ubuntu-26.04-x86_64
Provider Connection Refused
Verify Spinifex services are running:
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:
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:
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:
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.