Deploy Ubuntu Cloud-Init with AWS EC2 and Terraform

Complete guide for deploying secure, production-ready Ubuntu instances on AWS EC2 using Terraform and Cloud-Init templates with enterprise-grade security hardening.

30-45 minutes
Intermediate
Security Hardened
Production Ready
RFS Security Research
RFS Security Research
Senior Penetration Tester • eJPT, eCPPTv2, CRTP, ADCS CESP
Prerequisites

Required Tools

  • • Terraform ≥ 1.0
  • • AWS CLI configured
  • • SSH key pair generated
  • • Basic Linux knowledge

AWS Requirements

  • • AWS account with appropriate permissions
  • • VPC creation permissions
  • • EC2 instance launch permissions
  • • Security group management
Architecture Overview
This deployment creates a secure, scalable infrastructure on AWS with the following components:

VPC & Networking

Isolated network with public subnets across AZs

EC2 Instances

Ubuntu servers with Cloud-Init configuration

Security Hardening

Multi-layered security with monitoring

Step 1: Project Setup
Create the project structure and initialize Terraform configuration
# Create project structure
mkdir aws-ec2-terraform-deployment
cd aws-ec2-terraform-deployment
mkdir -p {terraform,scripts,docs}
touch terraform/{main.tf,variables.tf,outputs.tf,terraform.tfvars}
touch cloud-init.yaml
Step 2: Terraform Configuration
Configure the main Terraform infrastructure

main.tf

# main.tf
terraform {
  required_version = ">= 1.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

# Data sources
data "aws_availability_zones" "available" {
  state = "available"
}

data "aws_ami" "ubuntu" {
  most_recent = true
  owners      = ["099720109477"] # Canonical

  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-22.04-amd64-server-*"]
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }
}

# VPC Configuration
resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name        = "${var.project_name}-vpc"
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

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

  tags = {
    Name        = "${var.project_name}-igw"
    Environment = var.environment
  }
}

resource "aws_subnet" "public" {
  count = length(var.public_subnet_cidrs)

  vpc_id                  = aws_vpc.main.id
  cidr_block              = var.public_subnet_cidrs[count.index]
  availability_zone       = data.aws_availability_zones.available.names[count.index]
  map_public_ip_on_launch = true

  tags = {
    Name        = "${var.project_name}-public-subnet-${count.index + 1}"
    Environment = var.environment
    Type        = "public"
  }
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.main.id
  }

  tags = {
    Name        = "${var.project_name}-public-rt"
    Environment = var.environment
  }
}

resource "aws_route_table_association" "public" {
  count = length(aws_subnet.public)

  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public.id
}

# Security Groups
resource "aws_security_group" "web" {
  name_prefix = "${var.project_name}-web-"
  vpc_id      = aws_vpc.main.id
  description = "Security group for web servers"

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

  # HTTPS
  ingress {
    description = "HTTPS"
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  # SSH (restricted)
  ingress {
    description = "SSH"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = var.allowed_ssh_cidrs
  }

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

  tags = {
    Name        = "${var.project_name}-web-sg"
    Environment = var.environment
  }

  lifecycle {
    create_before_destroy = true
  }
}

# Key Pair
resource "aws_key_pair" "main" {
  key_name   = "${var.project_name}-key"
  public_key = var.public_key

  tags = {
    Name        = "${var.project_name}-key"
    Environment = var.environment
  }
}

# EC2 Instance
resource "aws_instance" "web" {
  count = var.instance_count

  ami                    = data.aws_ami.ubuntu.id
  instance_type          = var.instance_type
  key_name              = aws_key_pair.main.key_name
  vpc_security_group_ids = [aws_security_group.web.id]
  subnet_id             = aws_subnet.public[count.index % length(aws_subnet.public)].id

  user_data = base64encode(templatefile("${path.module}/cloud-init.yaml", {
    hostname = "${var.project_name}-web-${count.index + 1}"
  }))

  root_block_device {
    volume_type           = "gp3"
    volume_size           = var.root_volume_size
    encrypted             = true
    delete_on_termination = true
  }

  metadata_options {
    http_endpoint = "enabled"
    http_tokens   = "required"
    http_put_response_hop_limit = 1
  }

  tags = {
    Name        = "${var.project_name}-web-${count.index + 1}"
    Environment = var.environment
    Role        = "web-server"
    ManagedBy   = "terraform"
  }

  lifecycle {
    create_before_destroy = true
  }
}

# Elastic IP (optional)
resource "aws_eip" "web" {
  count = var.create_eip ? var.instance_count : 0

  instance = aws_instance.web[count.index].id
  domain   = "vpc"

  tags = {
    Name        = "${var.project_name}-eip-${count.index + 1}"
    Environment = var.environment
  }

  depends_on = [aws_internet_gateway.main]
}

variables.tf

# variables.tf
variable "aws_region" {
  description = "AWS region for resources"
  type        = string
  default     = "us-west-2"
}

variable "project_name" {
  description = "Name of the project"
  type        = string
  default     = "cloud-init-demo"
}

variable "environment" {
  description = "Environment name"
  type        = string
  default     = "production"
}

variable "vpc_cidr" {
  description = "CIDR block for VPC"
  type        = string
  default     = "10.0.0.0/16"
}

variable "public_subnet_cidrs" {
  description = "CIDR blocks for public subnets"
  type        = list(string)
  default     = ["10.0.1.0/24", "10.0.2.0/24"]
}

variable "instance_type" {
  description = "EC2 instance type"
  type        = string
  default     = "t3.micro"
}

variable "instance_count" {
  description = "Number of instances to create"
  type        = number
  default     = 1
}

variable "root_volume_size" {
  description = "Size of root volume in GB"
  type        = number
  default     = 20
}

variable "public_key" {
  description = "Public key for EC2 key pair"
  type        = string
}

variable "allowed_ssh_cidrs" {
  description = "CIDR blocks allowed for SSH access"
  type        = list(string)
  default     = ["0.0.0.0/0"]  # Restrict this in production!
}

variable "create_eip" {
  description = "Whether to create Elastic IP"
  type        = bool
  default     = false
}

outputs.tf

# outputs.tf
output "vpc_id" {
  description = "ID of the VPC"
  value       = aws_vpc.main.id
}

output "public_subnet_ids" {
  description = "IDs of the public subnets"
  value       = aws_subnet.public[*].id
}

output "security_group_id" {
  description = "ID of the security group"
  value       = aws_security_group.web.id
}

output "instance_ids" {
  description = "IDs of the EC2 instances"
  value       = aws_instance.web[*].id
}

output "instance_public_ips" {
  description = "Public IP addresses of the instances"
  value       = aws_instance.web[*].public_ip
}

output "instance_private_ips" {
  description = "Private IP addresses of the instances"
  value       = aws_instance.web[*].private_ip
}

output "elastic_ips" {
  description = "Elastic IP addresses (if created)"
  value       = var.create_eip ? aws_eip.web[*].public_ip : []
}

output "ssh_connection_commands" {
  description = "SSH connection commands"
  value = [
    for i, instance in aws_instance.web :
    "ssh -i ~/.ssh/your-key.pem ubuntu@${instance.public_ip}"
  ]
}

output "load_balancer_dns" {
  description = "DNS name of the load balancer (if created)"
  value       = ""  # Add ALB configuration if needed
}
Step 3: Cloud-Init Configuration
Comprehensive Cloud-Init template with security hardening and monitoring

cloud-init.yaml

#cloud-config
# Ubuntu Cloud-Init Configuration for AWS EC2
# Generated by RFS Security Research Cloud-Init Generator
# https://cloud-init.rfs.pt

# System Configuration
hostname: ${hostname}
fqdn: ${hostname}.localdomain
manage_etc_hosts: true

# User Configuration
users:
  - name: ubuntu
    sudo: ALL=(ALL) NOPASSWD:ALL
    groups: users, admin, docker
    shell: /bin/bash
    lock_passwd: false
    ssh_authorized_keys:
      - ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQC... # Add your public key here

# Package Management
package_update: true
package_upgrade: true
package_reboot_if_required: true

packages:
  # Essential packages
  - curl
  - wget
  - git
  - vim
  - htop
  - tree
  - unzip
  - software-properties-common
  - apt-transport-https
  - ca-certificates
  - gnupg
  - lsb-release
  
  # Security packages
  - ufw
  - fail2ban
  - rkhunter
  - chkrootkit
  - aide
  - auditd
  - apparmor
  - apparmor-utils
  
  # Monitoring packages
  - netdata
  - prometheus-node-exporter
  - rsyslog
  
  # Development tools
  - build-essential
  - python3
  - python3-pip
  - nodejs
  - npm
  
  # Docker
  - docker.io
  - docker-compose

# System Updates and Security
apt:
  sources:
    docker:
      source: "deb [arch=amd64] https://download.docker.com/linux/ubuntu $RELEASE stable"
      keyid: 9DC858229FC7DD38854AE2D88D81803C0EBFCD88

# File System Configuration
disk_setup:
  /dev/xvdf:
    table_type: gpt
    layout: true
    overwrite: false

fs_setup:
  - label: data
    filesystem: ext4
    device: /dev/xvdf1
    partition: auto

mounts:
  - ["/dev/xvdf1", "/data", "ext4", "defaults,nofail", "0", "2"]

# Network Configuration
network:
  version: 2
  ethernets:
    eth0:
      dhcp4: true
      dhcp6: false

# Security Hardening
runcmd:
  # Update system
  - apt-get update && apt-get upgrade -y
  
  # Configure UFW Firewall
  - ufw --force enable
  - ufw default deny incoming
  - ufw default allow outgoing
  - ufw allow ssh
  - ufw allow 80/tcp
  - ufw allow 443/tcp
  
  # Configure Fail2Ban
  - systemctl enable fail2ban
  - systemctl start fail2ban
  
  # SSH Hardening
  - sed -i 's/#PermitRootLogin yes/PermitRootLogin no/' /etc/ssh/sshd_config
  - sed -i 's/#PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config
  - sed -i 's/#PubkeyAuthentication yes/PubkeyAuthentication yes/' /etc/ssh/sshd_config
  - sed -i 's/#AuthorizedKeysFile/AuthorizedKeysFile/' /etc/ssh/sshd_config
  - echo "AllowUsers ubuntu" >> /etc/ssh/sshd_config
  - echo "Protocol 2" >> /etc/ssh/sshd_config
  - echo "ClientAliveInterval 300" >> /etc/ssh/sshd_config
  - echo "ClientAliveCountMax 2" >> /etc/ssh/sshd_config
  - systemctl restart sshd
  
  # System Hardening
  - echo "net.ipv4.ip_forward=0" >> /etc/sysctl.conf
  - echo "net.ipv4.conf.all.send_redirects=0" >> /etc/sysctl.conf
  - echo "net.ipv4.conf.default.send_redirects=0" >> /etc/sysctl.conf
  - echo "net.ipv4.conf.all.accept_redirects=0" >> /etc/sysctl.conf
  - echo "net.ipv4.conf.default.accept_redirects=0" >> /etc/sysctl.conf
  - echo "net.ipv4.conf.all.secure_redirects=0" >> /etc/sysctl.conf
  - echo "net.ipv4.conf.default.secure_redirects=0" >> /etc/sysctl.conf
  - sysctl -p
  
  # Configure Docker
  - systemctl enable docker
  - systemctl start docker
  - usermod -aG docker ubuntu
  
  # Install Docker Compose
  - curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
  - chmod +x /usr/local/bin/docker-compose
  
  # Configure automatic updates
  - echo 'Unattended-Upgrade::Automatic-Reboot "false";' >> /etc/apt/apt.conf.d/50unattended-upgrades
  - echo 'Unattended-Upgrade::Remove-Unused-Dependencies "true";' >> /etc/apt/apt.conf.d/50unattended-upgrades
  - systemctl enable unattended-upgrades
  
  # Configure log rotation
  - echo "/var/log/auth.log { daily missingok rotate 7 compress delaycompress notifempty create 0640 root utmp }" > /etc/logrotate.d/auth
  
  # Install and configure AIDE
  - aideinit
  - mv /var/lib/aide/aide.db.new /var/lib/aide/aide.db
  
  # Configure system monitoring
  - systemctl enable netdata
  - systemctl start netdata
  - systemctl enable prometheus-node-exporter
  - systemctl start prometheus-node-exporter
  
  # Final system cleanup
  - apt-get autoremove -y
  - apt-get autoclean

# Write additional configuration files
write_files:
  - path: /etc/fail2ban/jail.local
    content: |
      [DEFAULT]
      bantime = 3600
      findtime = 600
      maxretry = 3
      
      [sshd]
      enabled = true
      port = ssh
      filter = sshd
      logpath = /var/log/auth.log
      maxretry = 3
      bantime = 3600
    permissions: '0644'
    owner: root:root
  
  - path: /etc/netdata/netdata.conf
    content: |
      [global]
      run as user = netdata
      web files owner = root
      web files group = netdata
      bind socket to IP = 127.0.0.1
      default port = 19999
      
      [web]
      allow connections from = localhost 127.0.0.1
    permissions: '0644'
    owner: root:root
  
  - path: /home/ubuntu/.bashrc
    content: |
      # Custom aliases and functions
      alias ll='ls -alF'
      alias la='ls -A'
      alias l='ls -CF'
      alias ..='cd ..'
      alias ...='cd ../..'
      alias grep='grep --color=auto'
      alias fgrep='fgrep --color=auto'
      alias egrep='egrep --color=auto'
      
      # Docker aliases
      alias dps='docker ps'
      alias dpa='docker ps -a'
      alias di='docker images'
      alias dex='docker exec -it'
      
      # System monitoring
      alias ports='netstat -tulanp'
      alias meminfo='free -m -l -t'
      alias psmem='ps auxf | sort -nr -k 4'
      alias pscpu='ps auxf | sort -nr -k 3'
      
      # Security
      alias chkrootkit='sudo chkrootkit'
      alias rkhunter='sudo rkhunter --check'
    permissions: '0644'
    owner: ubuntu:ubuntu
    append: true

# Final message
final_message: |
  Cloud-Init configuration completed successfully!
  
  System Information:
  - Hostname: ${hostname}
  - Ubuntu Version: $(lsb_release -d | cut -f2)
  - Kernel: $(uname -r)
  - Uptime: $(uptime)
  
  Security Features Enabled:
  - UFW Firewall configured
  - Fail2Ban active
  - SSH hardened
  - Automatic security updates
  - System monitoring with Netdata
  - Docker installed and configured
  
  Access your system:
  - SSH: ssh ubuntu@<public-ip>
  - Netdata: http://<public-ip>:19999 (localhost only)
  
  Generated by RFS Security Research
  https://cloud-init.rfs.pt
Step 4: Configure Variables
Set up your deployment-specific variables

terraform.tfvars

# terraform.tfvars.example
aws_region    = "us-west-2"
project_name  = "my-cloud-init-project"
environment   = "production"

# Network Configuration
vpc_cidr             = "10.0.0.0/16"
public_subnet_cidrs  = ["10.0.1.0/24", "10.0.2.0/24"]

# Instance Configuration
instance_type    = "t3.small"
instance_count   = 2
root_volume_size = 30

# Security Configuration
public_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQC... your-public-key-here"
allowed_ssh_cidrs = ["203.0.113.0/24"]  # Replace with your IP range

# Optional Features
create_eip = true
Step 5: Deploy Infrastructure
Execute the Terraform deployment
# Initialize Terraform
terraform init

# Plan the deployment
terraform plan -var-file="terraform.tfvars"

# Apply the configuration
terraform apply -var-file="terraform.tfvars"

# Get outputs
terraform output
Security Features Implemented
Comprehensive security hardening included in this deployment

✅ Network Security

  • • VPC with isolated subnets
  • • Security groups with minimal access
  • • UFW firewall configuration
  • • Network hardening via sysctl

✅ System Security

  • • SSH hardening and key-only access
  • • Fail2Ban intrusion prevention
  • • Encrypted EBS volumes
  • • IMDSv2 enforcement

✅ Monitoring & Logging

  • • Netdata system monitoring
  • • Prometheus node exporter
  • • Enhanced logging configuration
  • • AIDE file integrity monitoring

✅ Maintenance

  • • Automatic security updates
  • • Log rotation configuration
  • • System cleanup automation
  • • Docker and container support
Troubleshooting
Common issues and their solutions

Cloud-Init not completing

Check the Cloud-Init logs: sudo cloud-init status and sudo tail -f /var/log/cloud-init-output.log

SSH connection refused

Verify security group rules allow SSH (port 22) from your IP address and ensure the instance has a public IP.

Terraform apply fails

Check AWS credentials, permissions, and ensure the specified region and availability zones are available.

Next Steps
Enhance your deployment with additional features

Infrastructure Enhancements

  • • Add Application Load Balancer
  • • Implement Auto Scaling Groups
  • • Set up RDS database
  • • Configure CloudWatch monitoring

Security Improvements

  • • Implement AWS WAF
  • • Add AWS Config rules
  • • Set up CloudTrail logging
  • • Configure AWS GuardDuty
Download Complete Project
Get all the files and configurations used in this guide

This guide was created by RFS Security Research

Last updated: 7/10/2025
Report an issue
More guides