feat: release v1.0 - hardened EC2 baseline

This commit is contained in:
2025-08-16 14:02:17 -04:00
commit 005216b2de
6 changed files with 448 additions and 0 deletions

162
README.md Normal file
View 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 Im 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
```
Canonicals AWS account ID is `099720109477` — this ensures you get the official image.
---
## ⚡ Quick Start — Demo Mode
_Opens ingress for fast testing; **not** productionsafe._
```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 allopen 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 — ProductionReady
_Locks ingress to trusted CIDRs; HTTPSonly 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 | ProductionReady |
|----------------------|-----------------|----------------------------|
| `allowed_cidr_blocks`| `["0.0.0.0/0"]` | `["203.0.113.42/32"]` |
| `enable_http` | true | false |
| Security Group | wide open | CIDRrestricted |
| nftables rules | open ingress | CIDRrestricted 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 selfsigned 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) prepull of Portainer
---
## 🚧 Future Enhancements
- Optional TLS for Portainer via Traefik
- Prebaked AMIs with hardened baselines
---
## 📜 License
MIT License

99
main.tf Normal file
View 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
View 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
View 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
View 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
View 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."
}
}