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
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
Security Warning
Replace the example SSH key and IP ranges with your actual values. Never commit sensitive data to version control.
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
Deployment Time
The initial deployment typically takes 5-10 minutes. Cloud-Init configuration will continue running for an additional 10-15 minutes after the instance is available.
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
Related Guides
Continue your cloud infrastructure journey
Download Complete Project
Get all the files and configurations used in this guide
This guide was created by RFS Security Research