mirror of
https://github.com/patrickbeane/terraform-aws-ec2-hardened.git
synced 2026-03-28 04:25:32 +00:00
feat: release v1.0 - hardened EC2 baseline
This commit is contained in:
162
README.md
Normal file
162
README.md
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
# 🚀 Example Terraform EC2 Setup with Hardened Security & Docker
|
||||||
|
|
||||||
|
_A demo project showing Terraform provisioning, instance hardening, and Docker setup on AWS._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📖 Overview
|
||||||
|
While I’m newer to Terraform, this project applies my DevSecOps background to build **hardened**, **reproducible** infrastructure as a teaching artifact.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗺️ Architecture at a Glance
|
||||||
|
```
|
||||||
|
Terraform --> AWS EC2 (Ubuntu)
|
||||||
|
|
|
||||||
|
+--> 🔐 SSH hardened
|
||||||
|
+--> 🛡️ nftables firewall
|
||||||
|
+--> 📦 Docker & Compose plugin
|
||||||
|
+--> 🖥️ Optional Portainer UI
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ Key Features
|
||||||
|
- **Terraform-driven infrastructure**: EC2 instance, Security Group, and Key Pair.
|
||||||
|
- **🔐 Security-first mindset and configuration**:
|
||||||
|
- Custom SSH port
|
||||||
|
- Fail2Ban for brute-force protection
|
||||||
|
- `nftables` firewall rules with least-privilege defaults
|
||||||
|
- Automatic security updates
|
||||||
|
- **🐳 Docker-ready**: Installs Docker, Docker Compose plugin, and optional Portainer UI.
|
||||||
|
- **⚙️ Parameterized & reusable**: All sensitive settings are variables for easy demo → production transitions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Prerequisites
|
||||||
|
- AWS account with IAM user permissions to create EC2, Security Groups, and Key Pairs
|
||||||
|
- [Terraform](https://developer.hashicorp.com/terraform/downloads) installed (v1.5+ recommended)
|
||||||
|
- SSH key pair ready
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 Finding the latest Ubuntu AMI
|
||||||
|
Run this in AWS CLI (replace `us-east-1` with your region) to get the most recent Ubuntu 24.04 LTS AMI ID:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
aws ec2 describe-images \
|
||||||
|
--owners 099720109477 \
|
||||||
|
--filters "Name=name,Values=ubuntu/images/hvm-ssd/ubuntu-noble-24.04-amd64-server-*" \
|
||||||
|
"Name=state,Values=available" \
|
||||||
|
--query 'Images | sort_by(@, &CreationDate)[-1].ImageId' \
|
||||||
|
--region us-east-1 --output text
|
||||||
|
```
|
||||||
|
|
||||||
|
Canonical’s AWS account ID is `099720109477` — this ensures you get the official image.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚡ Quick Start — Demo Mode
|
||||||
|
_Opens ingress for fast testing; **not** production‑safe._
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Clone and enter the project
|
||||||
|
git clone https://github.com/patrickbeane/example-terraform-ec2.git
|
||||||
|
cd example-terraform-ec2
|
||||||
|
|
||||||
|
# 2. Initialize Terraform
|
||||||
|
terraform init
|
||||||
|
|
||||||
|
# 3. Apply with all‑open demo settings
|
||||||
|
terraform apply \
|
||||||
|
-var='allowed_cidr_blocks=["0.0.0.0/0"]' \
|
||||||
|
-var='ami_id=ami-0abcdef1234567890' \
|
||||||
|
-var='public_key_path=~/.ssh/YOUR_PUBLIC_KEY.pub'
|
||||||
|
```
|
||||||
|
|
||||||
|
After apply:
|
||||||
|
- **SSH** -> `ssh -p <ssh_port> ubuntu@<instance_public_ip>`
|
||||||
|
- **Portainer** -> `https://<instance_public_ip>:<portainer_port>` (accept cert warning)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛡️ Full Setup — Production‑Ready
|
||||||
|
_Locks ingress to trusted CIDRs; HTTPS‑only by default._
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy example vars to a working file
|
||||||
|
cp terraform.tfvars.example terraform.tfvars
|
||||||
|
|
||||||
|
# Edit terraform.tfvars for your region, IPs, and key path
|
||||||
|
nano terraform.tfvars
|
||||||
|
|
||||||
|
# Then initialize and apply
|
||||||
|
terraform init
|
||||||
|
terraform apply
|
||||||
|
```
|
||||||
|
|
||||||
|
See [⚠️ Security Notes](#security-notes) before going live.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Demo vs Production at a Glance
|
||||||
|
|
||||||
|
| Setting | Demo Mode | Production‑Ready |
|
||||||
|
|----------------------|-----------------|----------------------------|
|
||||||
|
| `allowed_cidr_blocks`| `["0.0.0.0/0"]` | `["203.0.113.42/32"]` |
|
||||||
|
| `enable_http` | true | false |
|
||||||
|
| Security Group | wide open | CIDR‑restricted |
|
||||||
|
| nftables rules | open ingress | CIDR‑restricted ingress |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Outputs
|
||||||
|
|
||||||
|
After applying, Terraform displays:
|
||||||
|
|
||||||
|
```
|
||||||
|
instance_public_ip = x.x.x.x
|
||||||
|
```
|
||||||
|
|
||||||
|
**Access your instance**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -p <ssh_port> ubuntu@<instance_public_ip>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Access Portainer** (if enabled):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
https://<instance_public_ip>:<portainer_port>
|
||||||
|
```
|
||||||
|
|
||||||
|
If you get a certificate warning on first visit, accept the self‑signed cert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Security Notes
|
||||||
|
|
||||||
|
- **Demo mode**: All ingress rules are `0.0.0.0/0` for testing and demonstration.
|
||||||
|
- **Production**: Restrict SSH/Portainer/HTTP/S ingress to trusted IPs or ranges.
|
||||||
|
- All key paths, ports, and AMIs are configurable via `variables.tf`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📂 user-data Script Highlights
|
||||||
|
- OS updates & package upgrades
|
||||||
|
- SSH port change & password auth disabled
|
||||||
|
- Fail2Ban install & config
|
||||||
|
- `nftables` rules applied and persisted (demo vs production notes in script)
|
||||||
|
- Docker install, enabled, and user permissions set
|
||||||
|
- (Optional) pre‑pull of Portainer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚧 Future Enhancements
|
||||||
|
- Optional TLS for Portainer via Traefik
|
||||||
|
- Pre‑baked AMIs with hardened baselines
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📜 License
|
||||||
|
MIT License
|
||||||
99
main.tf
Normal file
99
main.tf
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
terraform {
|
||||||
|
required_version = ">= 1.5.0"
|
||||||
|
required_providers {
|
||||||
|
aws = {
|
||||||
|
source = "hashicorp/aws"
|
||||||
|
version = ">= 5.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
provider "aws" {
|
||||||
|
region = var.aws_region
|
||||||
|
}
|
||||||
|
|
||||||
|
data "aws_vpc" "default" {
|
||||||
|
default = true
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_key_pair" "example_key" {
|
||||||
|
key_name = "example-aws-key"
|
||||||
|
public_key = file(var.public_key_path)
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "example_aws_key"
|
||||||
|
env = var.env
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_security_group" "example_aws_sg" {
|
||||||
|
name = "example-aws-sg"
|
||||||
|
description = "Restricted ingress for production"
|
||||||
|
vpc_id = data.aws_vpc.default.id
|
||||||
|
|
||||||
|
ingress {
|
||||||
|
description = "SSH"
|
||||||
|
from_port = var.ssh_port
|
||||||
|
to_port = var.ssh_port
|
||||||
|
protocol = "tcp"
|
||||||
|
cidr_blocks = var.allowed_cidr_blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
ingress {
|
||||||
|
description = "HTTPS"
|
||||||
|
from_port = 443
|
||||||
|
to_port = 443
|
||||||
|
protocol = "tcp"
|
||||||
|
cidr_blocks = var.allowed_cidr_blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
ingress {
|
||||||
|
description = "Portainer"
|
||||||
|
from_port = var.portainer_port
|
||||||
|
to_port = var.portainer_port
|
||||||
|
protocol = "tcp"
|
||||||
|
cidr_blocks = var.allowed_cidr_blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
dynamic "ingress" {
|
||||||
|
for_each = var.enable_http ? [1] : []
|
||||||
|
content {
|
||||||
|
description = "HTTP"
|
||||||
|
from_port = 80
|
||||||
|
to_port = 80
|
||||||
|
protocol = "tcp"
|
||||||
|
cidr_blocks = var.allowed_cidr_blocks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
egress {
|
||||||
|
from_port = 0
|
||||||
|
to_port = 0
|
||||||
|
protocol = "-1"
|
||||||
|
cidr_blocks = ["0.0.0.0/0"]
|
||||||
|
}
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "example_aws_sg"
|
||||||
|
env = var.env
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_instance" "example_instance" {
|
||||||
|
ami = var.ami_id
|
||||||
|
instance_type = var.instance_type
|
||||||
|
key_name = aws_key_pair.example_key.key_name
|
||||||
|
vpc_security_group_ids = [aws_security_group.example_aws_sg.id]
|
||||||
|
|
||||||
|
user_data = templatefile("${path.module}/user_data.sh.tmpl", {
|
||||||
|
ssh_port = var.ssh_port
|
||||||
|
portainer_port = var.portainer_port
|
||||||
|
allowed_cidr_blocks = join(" ", var.allowed_cidr_blocks)
|
||||||
|
enable_http = var.enable_http
|
||||||
|
})
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "example_instance"
|
||||||
|
env = var.env
|
||||||
|
}
|
||||||
|
}
|
||||||
24
outputs.tf
Normal file
24
outputs.tf
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
output "instance_public_ip" {
|
||||||
|
description = "Public IP of the instance"
|
||||||
|
value = aws_instance.example_instance.public_ip
|
||||||
|
}
|
||||||
|
|
||||||
|
output "instance_id" {
|
||||||
|
description = "Instance ID"
|
||||||
|
value = aws_instance.example_instance.id
|
||||||
|
}
|
||||||
|
|
||||||
|
output "security_group_id" {
|
||||||
|
description = "Security Group ID"
|
||||||
|
value = aws_security_group.example_aws_sg.id
|
||||||
|
}
|
||||||
|
|
||||||
|
output "ssh_command" {
|
||||||
|
description = "Convenient SSH command"
|
||||||
|
value = format("ssh -p %d ubuntu@%s", var.ssh_port, aws_instance.example_instance.public_ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
output "portainer_url" {
|
||||||
|
description = "Portainer URL (if enabled and allowed by CIDR)"
|
||||||
|
value = format("https://%s:%d", aws_instance.example_instance.public_ip, var.portainer_port)
|
||||||
|
}
|
||||||
20
terraform.tfvars.example
Normal file
20
terraform.tfvars.example
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Copy to terraform.tfvars and edit to match your environment
|
||||||
|
|
||||||
|
aws_region = "us-east-1"
|
||||||
|
ami_id = "ami-0abcdef1234567890" # Ubuntu 24.04 in your region
|
||||||
|
public_key_path = "~/.ssh/id_rsa.pub"
|
||||||
|
|
||||||
|
# Lock down to your trusted IP(s) or ranges
|
||||||
|
allowed_cidr_blocks = ["198.51.100.42/32"]
|
||||||
|
|
||||||
|
# HTTPS-only by default (set true to allow HTTP 80)
|
||||||
|
enable_http = false
|
||||||
|
|
||||||
|
# Service ports
|
||||||
|
ssh_port = 2222
|
||||||
|
portainer_port = 9443
|
||||||
|
|
||||||
|
instance_type = "t3.micro"
|
||||||
|
|
||||||
|
# Environment label
|
||||||
|
env = "demo"
|
||||||
72
user_data.sh.tmpl
Normal file
72
user_data.sh.tmpl
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euxo pipefail
|
||||||
|
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# --- Base updates and security ---
|
||||||
|
apt-get update
|
||||||
|
apt-get -y upgrade
|
||||||
|
apt-get -y dist-upgrade
|
||||||
|
apt-get -y autoremove --purge
|
||||||
|
|
||||||
|
# Harden SSH: non-default port, no passwords, no root login
|
||||||
|
sed -i "s/^#\?Port .*/Port ${ssh_port}/" /etc/ssh/sshd_config
|
||||||
|
sed -i "s/^#\?PasswordAuthentication .*/PasswordAuthentication no/" /etc/ssh/sshd_config
|
||||||
|
sed -i "s/^#\?PermitRootLogin .*/PermitRootLogin no/" /etc/ssh/sshd_config
|
||||||
|
sed -i "s/^#\?ChallengeResponseAuthentication .*/ChallengeResponseAuthentication no/" /etc/ssh/sshd_config
|
||||||
|
|
||||||
|
# Fail2Ban + unattended upgrades + prereqs
|
||||||
|
apt-get install -y fail2ban unattended-upgrades ca-certificates curl gnupg lsb-release
|
||||||
|
|
||||||
|
# Ensure unattended upgrades is enabled
|
||||||
|
systemctl enable --now unattended-upgrades
|
||||||
|
|
||||||
|
# --- nftables firewall (default drop) ---
|
||||||
|
nft flush ruleset || true
|
||||||
|
nft add table inet filter
|
||||||
|
nft add chain inet filter input { type filter hook input priority 0 ; policy drop ; }
|
||||||
|
nft add chain inet filter forward { type filter hook forward priority 0 ; policy drop ; }
|
||||||
|
nft add chain inet filter output { type filter hook output priority 0 ; policy accept ; }
|
||||||
|
|
||||||
|
# Allow loopback and established/related
|
||||||
|
nft add rule inet filter input iif lo accept
|
||||||
|
nft add rule inet filter input ct state established,related accept
|
||||||
|
|
||||||
|
# Restrict service ports to trusted CIDRs (IPv4)
|
||||||
|
for ip in ${allowed_cidr_blocks}; do
|
||||||
|
nft add rule inet filter input ip saddr "$ip" tcp dport ${ssh_port} accept
|
||||||
|
nft add rule inet filter input ip saddr "$ip" tcp dport 443 accept
|
||||||
|
nft add rule inet filter input ip saddr "$ip" tcp dport ${portainer_port} accept
|
||||||
|
done
|
||||||
|
|
||||||
|
# Allow HTTP only if explicitly enabled
|
||||||
|
if [ "${enable_http}" = "true" ]; then
|
||||||
|
for ip in ${allowed_cidr_blocks}; do
|
||||||
|
nft add rule inet filter input ip saddr "$ip" tcp dport 80 accept
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Persist rules and lock permissions
|
||||||
|
nft list ruleset > /etc/nftables.conf
|
||||||
|
chmod 600 /etc/nftables.conf
|
||||||
|
systemctl enable --now nftables
|
||||||
|
|
||||||
|
# Apply SSH changes
|
||||||
|
systemctl daemon-reexec
|
||||||
|
systemctl restart ssh
|
||||||
|
|
||||||
|
# --- Docker (from official repo) ---
|
||||||
|
install -m 0755 -d /etc/apt/keyrings
|
||||||
|
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||||
|
chmod a+r /etc/apt/keyrings/docker.gpg
|
||||||
|
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" \
|
||||||
|
| tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||||
|
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
|
||||||
|
|
||||||
|
systemctl enable --now docker
|
||||||
|
usermod -aG docker ubuntu
|
||||||
|
|
||||||
|
# Optional: pre-pull Portainer CE
|
||||||
|
docker pull portainer/portainer-ce:latest
|
||||||
71
variables.tf
Normal file
71
variables.tf
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
variable "env" {
|
||||||
|
description = "Environment tag for resources (e.g. demo, production)"
|
||||||
|
type = string
|
||||||
|
default = "demo"
|
||||||
|
validation {
|
||||||
|
condition = contains(["demo", "staging", "production"], var.env)
|
||||||
|
error_message = "env must be one of: demo, staging, production."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "ssh_port" {
|
||||||
|
description = "SSH port for the instance"
|
||||||
|
type = number
|
||||||
|
default = 2222
|
||||||
|
validation {
|
||||||
|
condition = var.ssh_port >= 1 && var.ssh_port <= 65535
|
||||||
|
error_message = "ssh_port must be between 1 and 65535."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "portainer_port" {
|
||||||
|
description = "Portainer port"
|
||||||
|
type = number
|
||||||
|
default = 9443
|
||||||
|
validation {
|
||||||
|
condition = var.portainer_port >= 1 && var.portainer_port <= 65535
|
||||||
|
error_message = "portainer_port must be between 1 and 65535."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "instance_type" {
|
||||||
|
description = "EC2 instance type"
|
||||||
|
type = string
|
||||||
|
default = "t3.micro"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "allowed_cidr_blocks" {
|
||||||
|
description = "Trusted CIDR blocks allowed to access exposed services"
|
||||||
|
type = list(string)
|
||||||
|
validation {
|
||||||
|
condition = length(var.allowed_cidr_blocks) > 0
|
||||||
|
error_message = "At least one trusted CIDR must be provided in allowed_cidr_blocks."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "enable_http" {
|
||||||
|
description = "Whether to allow HTTP (80) in addition to HTTPS"
|
||||||
|
type = bool
|
||||||
|
default = false
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "aws_region" {
|
||||||
|
description = "AWS region to deploy resources"
|
||||||
|
type = string
|
||||||
|
default = "us-east-1"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "public_key_path" {
|
||||||
|
description = "Path to your public SSH key"
|
||||||
|
type = string
|
||||||
|
default = "~/.ssh/YOUR_PUBLIC_KEY.pub"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "ami_id" {
|
||||||
|
description = "Ubuntu AMI ID for the EC2 instance (e.g., ami-xxxxxxxx)"
|
||||||
|
type = string
|
||||||
|
validation {
|
||||||
|
condition = can(regex("^ami-[0-9a-fA-F]+$", var.ami_id))
|
||||||
|
error_message = "ami_id must look like an AMI ID, e.g., ami-0abc1234def567890."
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user