mirror of
https://github.com/patrickbeane/terraform-aws-ec2-hardened.git
synced 2026-01-27 16:40:24 +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