Skip to main content

Overview

The EC2 module creates Amazon EC2 instances running Amazon Linux 2023 with optional Application Load Balancer, automatic Tailscale installation, HTTPS support via ACM certificates, and CloudFlare DNS integration.

Features

Amazon Linux 2023

Automatically uses the latest AL2023 AMI with security updates

Optional Tailscale

Automatic Tailscale installation for secure remote access

Optional ALB

Application Load Balancer with health checks and target groups

HTTPS Support

Automatic ACM certificate creation and CloudFlare DNS validation

Multiple Instances

Create multiple instances with a single module call

Security Hardening

IMDSv2 enforcement, encrypted storage, and security groups

Architecture

Without ALB

┌─────────────┐
│  Internet   │
└──────┬───────┘

   ┌───┴────┐
   │  EIP   │ (optional)
   └───┬────┘

   ┌───┴───────────────────┐
   │  EC2 Instance          │
   │  - AL2023              │
   │  - Tailscale           │
   │  - Your App            │
   └─────────────────────┘

With ALB

┌─────────────┐
│  Internet   │
└──────┬───────┘

   ┌───┴────────────────┐
   │      ALB             │
   │  HTTP/HTTPS          │
   └───┬────────────────┘

   ┌───┴────────────────┐
   │ Target Group        │
   └───┬────────────────┘

   ┌───┴───────────────────┐
   │  EC2 Instance          │
   │  - AL2023              │
   │  - Tailscale           │
   │  - Your App            │
   └─────────────────────┘

Usage Examples

Basic EC2 Instance

module "ec2" {
  source = "[email protected]:opsnorth/terraform-modules.git//ec2?ref=v1.0.0"

  name      = "my-app-server"
  vpc_id    = module.vpc.vpc_id
  subnet_id = module.vpc.public_subnet_ids[0]

  instance_type = "t3.small"
  key_name      = "my-keypair"

  ingress_rules = [
    {
      description = "SSH from my IP"
      from_port   = 22
      to_port     = 22
      protocol    = "tcp"
      cidr_blocks = ["1.2.3.4/32"]
    },
    {
      description = "HTTP from anywhere"
      from_port   = 80
      to_port     = 80
      protocol    = "tcp"
      cidr_blocks = ["0.0.0.0/0"]
    }
  ]

  tags = {
    Environment = "production"
    Application = "my-app"
  }
}

Multiple Instances

module "ec2_cluster" {
  source = "[email protected]:opsnorth/terraform-modules.git//ec2?ref=v1.0.0"

  name           = "app-server"
  instance_count = 3  # Creates: app-server-1, app-server-2, app-server-3

  vpc_id    = module.vpc.vpc_id
  subnet_id = module.vpc.public_subnet_ids[0]

  instance_type = "t3.small"
  key_name      = "my-keypair"

  # Allocate EIP for each instance
  allocate_eip = true

  ingress_rules = [
    {
      description = "SSH"
      from_port   = 22
      to_port     = 22
      protocol    = "tcp"
      cidr_blocks = ["10.0.0.0/16"]
    }
  ]

  tags = {
    Environment = "production"
    Cluster     = "app-cluster"
  }
}

# Access individual instance IPs
output "instance_ips" {
  value = module.ec2_cluster.public_ips
}

With Tailscale

module "ec2_tailscale" {
  source = "[email protected]:opsnorth/terraform-modules.git//ec2?ref=v1.0.0"

  name      = "my-app-server"
  vpc_id    = module.vpc.vpc_id
  subnet_id = module.vpc.public_subnet_ids[0]

  instance_type = "t3.small"
  key_name      = "my-keypair"

  # Enable Tailscale (requires manual authentication after boot)
  enable_tailscale = true

  ingress_rules = [
    {
      description = "SSH"
      from_port   = 22
      to_port     = 22
      protocol    = "tcp"
      cidr_blocks = ["0.0.0.0/0"]
    },
    {
      description = "Tailscale UDP"
      from_port   = 41641
      to_port     = 41641
      protocol    = "udp"
      cidr_blocks = ["0.0.0.0/0"]
    }
  ]

  tags = {
    Environment = "production"
  }
}
After the instance boots, SSH to it and run sudo tailscale up to authenticate.

With Application Load Balancer

module "ec2_with_alb" {
  source = "[email protected]:opsnorth/terraform-modules.git//ec2?ref=v1.0.0"

  name      = "my-web-app"
  vpc_id    = module.vpc.vpc_id
  subnet_id = module.vpc.private_subnet_ids[0]

  instance_type = "t3.medium"
  key_name      = "my-keypair"

  # Enable ALB
  enable_alb     = true
  alb_subnet_ids = module.vpc.public_subnet_ids  # Must be in 2+ AZs

  # Application configuration
  application_port     = 8080
  health_check_path    = "/health"
  health_check_matcher = "200"

  tags = {
    Environment = "production"
  }
}

With Custom Domain and HTTPS

module "api_server" {
  source = "[email protected]:opsnorth/terraform-modules.git//ec2?ref=v1.0.0"

  name      = "api-server"
  vpc_id    = module.vpc.vpc_id
  subnet_id = module.vpc.private_subnet_ids[0]

  instance_type = "t3.small"
  key_name      = "my-keypair"

  # Enable ALB with custom domain
  enable_alb     = true
  alb_subnet_ids = module.vpc.public_subnet_ids

  # Automatic HTTPS with CloudFlare
  domain_name          = "api.example.com"
  cloudflare_zone_id   = "abc123xyz"
  cloudflare_api_token = var.cloudflare_api_token

  # Application configuration
  application_port     = 8080
  health_check_path    = "/health"
  health_check_matcher = "200"

  ingress_rules = [
    {
      description = "SSH from bastion"
      from_port   = 22
      to_port     = 22
      protocol    = "tcp"
      cidr_blocks = ["10.0.1.0/24"]
    }
  ]

  tags = {
    Environment = "production"
  }
}

# Access via custom domain
output "api_url" {
  value = module.api_server.domain_url  # https://api.example.com
}
The module automatically creates ACM certificate, validates via CloudFlare DNS, and creates a DNS record pointing to the ALB.

Inputs

NameDescriptionTypeDefaultRequired
nameName prefix for EC2 instancesstringn/ayes
instance_countNumber of EC2 instances to createnumber1no
vpc_idVPC IDstringn/ayes
subnet_idSubnet ID for the instance(s)stringn/ayes
instance_typeEC2 instance typestring"t3.micro"no
ami_idCustom AMI ID (leave empty for latest AL2023)string""no
key_nameEC2 key pair namestring""no
associate_public_ipAssign public IPbooltrueno
root_volume_sizeRoot volume size in GBnumber20no
root_volume_typeRoot volume typestring"gp3"no
root_volume_encryptedEncrypt root volumebooltrueno
enable_detailed_monitoringEnable detailed monitoringboolfalseno
enable_termination_protectionEnable termination protectionboolfalseno
iam_policy_arnsAdditional IAM policieslist(string)[]no
ingress_rulesIngress rules for EC2 security grouplist(object)SSH onlyno
allocate_eipAllocate Elastic IPboolfalseno
enable_tailscaleEnable Tailscale installationboolfalseno
enable_albEnable Application Load Balancerboolfalseno
alb_subnet_idsALB subnet IDs (2+ AZs)list(string)[]no
alb_internalMake ALB internalboolfalseno
alb_certificate_arnACM certificate ARN for HTTPSstring""no
application_portApplication port on EC2number80no
health_check_pathHealth check pathstring"/"no
health_check_matcherHTTP status codes for healthystring"200"no
domain_nameDomain name for ALB (enables auto HTTPS)string""no
cloudflare_zone_idCloudFlare Zone IDstring""no
cloudflare_api_tokenCloudFlare API tokenstring""no
additional_user_dataAdditional setup commandsstring""no
tagsResource tagsmap(string){}no

Outputs

NameDescription
instance_idEC2 instance ID(s)
instance_arnEC2 instance ARN(s)
private_ipPrivate IP address(es)
public_ipPublic IP address(es)
ami_idAMI ID used
security_group_idSecurity group ID
iam_role_arnIAM role ARN
alb_dns_nameALB DNS name (if enabled)
alb_arnALB ARN (if enabled)
target_group_arnTarget group ARN (if enabled)
certificate_arnACM certificate ARN (if domain configured)
domain_nameConfigured domain name
domain_urlFull HTTPS URL (if domain configured)
certificate_statusACM certificate status
ssh_commandSSH command to connect
http_endpointHTTP/HTTPS endpoint URL
instance_summarySummary of configuration

Security Group Configuration

By default, only SSH (port 22) is allowed. Customize with ingress_rules:
ingress_rules = [
  {
    description = "SSH from bastion"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["10.0.1.0/24"]
  },
  {
    description = "HTTP from anywhere"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  },
  {
    description = "HTTPS from anywhere"
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  },
  {
    description = "Custom UDP service"
    from_port   = 8125
    to_port     = 8125
    protocol    = "udp"
    cidr_blocks = ["10.0.0.0/16"]
  }
]
When ALB is enabled, the module automatically adds an ingress rule to allow traffic from the ALB to the application port.

Best Practices

Use Latest AMI

Leave ami_id empty to automatically use the latest Amazon Linux 2023 AMI with security patches.

Encrypt Storage

Root volume encryption is enabled by default. Keep it enabled for compliance.

Restrict SSH

Limit SSH access to specific IP ranges or use Tailscale for secure access.

Use ALB for Production

ALB provides health checks, auto-recovery, and HTTPS termination.

Enable Monitoring

Use enable_detailed_monitoring = true for production workloads.

Right-Size Instances

Start with smaller instances (t3.micro/small) and scale based on metrics.

Troubleshooting

Cannot SSH to Instance

Check security group:
aws ec2 describe-security-groups --group-ids <security-group-id>
Verify key pair:
aws ec2 describe-key-pairs --key-names <key-name>
Check instance status:
aws ec2 describe-instance-status --instance-ids <instance-id>

Tailscale Not Connecting

Check if installed:
ssh ec2-user@<instance-ip>
which tailscale
sudo systemctl status tailscaled
Authenticate:
sudo tailscale up
# Follow the authentication URL
Check logs:
sudo journalctl -u tailscaled -f

ALB Health Checks Failing

Check application is running:
ssh ec2-user@<instance-ip>
curl localhost:<application_port>/<health_check_path>
Verify target health:
aws elbv2 describe-target-health \
  --target-group-arn <target-group-arn>
Check security group: Ensure the EC2 security group allows traffic from the ALB security group on the application port.

Certificate Validation Stuck

Check ACM certificate status:
aws acm describe-certificate --certificate-arn <certificate-arn>
Verify CloudFlare DNS records:
  • Check that CNAME validation records were created
  • DNS propagation can take 5-10 minutes
Common issues:
  • CloudFlare API token missing DNS edit permissions
  • Zone ID incorrect
  • Domain already has conflicting records

VPC Module

Create VPC and subnets for EC2

Usage Guide

Common patterns and best practices

Infrastructure Guide

Complete deployment workflow

EKS Module

Deploy Kubernetes cluster alongside EC2

Build docs developers (and LLMs) love