From 005216b2de6dfda8bee980072d7f4c835911b990 Mon Sep 17 00:00:00 2001 From: patrickbeane Date: Sat, 16 Aug 2025 14:02:17 -0400 Subject: [PATCH] feat: release v1.0 - hardened EC2 baseline --- README.md | 162 +++++++++++++++++++++++++++++++++++++++ main.tf | 99 ++++++++++++++++++++++++ outputs.tf | 24 ++++++ terraform.tfvars.example | 20 +++++ user_data.sh.tmpl | 72 +++++++++++++++++ variables.tf | 71 +++++++++++++++++ 6 files changed, 448 insertions(+) create mode 100644 README.md create mode 100644 main.tf create mode 100644 outputs.tf create mode 100644 terraform.tfvars.example create mode 100644 user_data.sh.tmpl create mode 100644 variables.tf diff --git a/README.md b/README.md new file mode 100644 index 0000000..9ebad87 --- /dev/null +++ b/README.md @@ -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 ubuntu@` +- **Portainer** -> `https://:` (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 ubuntu@ +``` + +**Access Portainer** (if enabled): + +```bash +https://: + ``` + +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 \ No newline at end of file diff --git a/main.tf b/main.tf new file mode 100644 index 0000000..c774d77 --- /dev/null +++ b/main.tf @@ -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 + } +} diff --git a/outputs.tf b/outputs.tf new file mode 100644 index 0000000..67abd29 --- /dev/null +++ b/outputs.tf @@ -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) +} diff --git a/terraform.tfvars.example b/terraform.tfvars.example new file mode 100644 index 0000000..476067d --- /dev/null +++ b/terraform.tfvars.example @@ -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" \ No newline at end of file diff --git a/user_data.sh.tmpl b/user_data.sh.tmpl new file mode 100644 index 0000000..29d211c --- /dev/null +++ b/user_data.sh.tmpl @@ -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 diff --git a/variables.tf b/variables.tf new file mode 100644 index 0000000..385eff7 --- /dev/null +++ b/variables.tf @@ -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." + } +}