hearth/terraform/minimal/main.tf
Eric Garcia 0d904fe130 fix(minimal): Replace Traefik HelmChart with direct deployment
HelmChart values schema changed in newer Traefik versions causing
installation failures. Replaced with direct Deployment + RBAC manifests
which work reliably with Traefik v3.2.

Also adds SSH public key variable for admin access.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 06:42:32 -05:00

397 lines
8.8 KiB
HCL

# Hearth Minimal Infrastructure
# Single EC2 + k3s for ~1 user
# Cost: ~$7.50/month
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.30"
}
}
backend "s3" {
bucket = "hearth-terraform-state-181640953119"
key = "hearth-minimal/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "hearth-terraform-locks"
encrypt = true
profile = "hearth"
}
}
provider "aws" {
region = var.aws_region
profile = "hearth"
default_tags {
tags = {
Project = "hearth"
Environment = "minimal"
ManagedBy = "terraform"
}
}
}
# -----------------------------------------------------------------------------
# Data Sources
# -----------------------------------------------------------------------------
data "aws_availability_zones" "available" {
state = "available"
}
data "aws_caller_identity" "current" {}
# Amazon Linux 2023 ARM64 (for t4g instances)
data "aws_ami" "al2023" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-arm64"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
# -----------------------------------------------------------------------------
# VPC - Minimal single public subnet
# -----------------------------------------------------------------------------
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "hearth-minimal"
}
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "hearth-minimal"
}
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = data.aws_availability_zones.available.names[0]
map_public_ip_on_launch = true
tags = {
Name = "hearth-minimal-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 = "hearth-minimal-public"
}
}
resource "aws_route_table_association" "public" {
subnet_id = aws_subnet.public.id
route_table_id = aws_route_table.public.id
}
# -----------------------------------------------------------------------------
# Security Group
# -----------------------------------------------------------------------------
resource "aws_security_group" "forgejo" {
name = "hearth-forgejo"
description = "Security group for Forgejo server"
vpc_id = aws_vpc.main.id
# SSH for Git (Forgejo)
ingress {
description = "Git SSH"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
# HTTP (redirect to HTTPS)
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"]
}
# Admin SSH (restricted - update with your IP)
ingress {
description = "Admin SSH"
from_port = 2222
to_port = 2222
protocol = "tcp"
cidr_blocks = var.admin_cidr_blocks
}
# Kubernetes API (for local kubectl, restricted)
ingress {
description = "Kubernetes API"
from_port = 6443
to_port = 6443
protocol = "tcp"
cidr_blocks = var.admin_cidr_blocks
}
# All outbound
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "hearth-forgejo"
}
}
# -----------------------------------------------------------------------------
# IAM Role for EC2 (S3 backup access)
# -----------------------------------------------------------------------------
resource "aws_iam_role" "forgejo" {
name = "hearth-forgejo"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ec2.amazonaws.com"
}
}
]
})
}
resource "aws_iam_role_policy" "forgejo_backup" {
name = "forgejo-backup"
role = aws_iam_role.forgejo.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"s3:PutObject",
"s3:GetObject",
"s3:ListBucket",
"s3:DeleteObject"
]
Resource = [
aws_s3_bucket.backups.arn,
"${aws_s3_bucket.backups.arn}/*"
]
},
{
Effect = "Allow"
Action = [
"ec2:CreateSnapshot",
"ec2:DescribeSnapshots",
"ec2:DeleteSnapshot"
]
Resource = "*"
}
]
})
}
resource "aws_iam_instance_profile" "forgejo" {
name = "hearth-forgejo"
role = aws_iam_role.forgejo.name
}
# -----------------------------------------------------------------------------
# S3 Bucket for Backups
# -----------------------------------------------------------------------------
resource "aws_s3_bucket" "backups" {
bucket = "hearth-backups-${data.aws_caller_identity.current.account_id}"
tags = {
Name = "hearth-backups"
}
}
resource "aws_s3_bucket_versioning" "backups" {
bucket = aws_s3_bucket.backups.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_lifecycle_configuration" "backups" {
bucket = aws_s3_bucket.backups.id
rule {
id = "expire-old-backups"
status = "Enabled"
filter {
prefix = ""
}
# Keep 60 days of backups
expiration {
days = 60
}
# Move to cheaper storage after 30 days (STANDARD_IA minimum)
transition {
days = 30
storage_class = "STANDARD_IA"
}
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "backups" {
bucket = aws_s3_bucket.backups.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
# -----------------------------------------------------------------------------
# EC2 Instance
# -----------------------------------------------------------------------------
resource "aws_instance" "forgejo" {
ami = data.aws_ami.al2023.id
instance_type = var.instance_type
subnet_id = aws_subnet.public.id
iam_instance_profile = aws_iam_instance_profile.forgejo.name
vpc_security_group_ids = [aws_security_group.forgejo.id]
# Use spot instance for cost savings
instance_market_options {
market_type = "spot"
spot_options {
max_price = var.spot_max_price
spot_instance_type = "persistent"
instance_interruption_behavior = "stop"
}
}
root_block_device {
volume_size = var.volume_size
volume_type = "gp3"
iops = 3000
throughput = 125
delete_on_termination = false # Preserve data on instance termination
encrypted = true
tags = {
Name = "hearth-forgejo-root"
}
}
user_data = base64encode(templatefile("${path.module}/user-data.sh", {
domain = var.domain
letsencrypt_email = var.letsencrypt_email
ssh_port = var.admin_ssh_port
s3_bucket = aws_s3_bucket.backups.id
ssh_public_key = var.ssh_public_key
}))
tags = {
Name = "hearth-forgejo"
}
lifecycle {
ignore_changes = [ami] # Don't replace on AMI updates
}
}
# -----------------------------------------------------------------------------
# Elastic IP (stable DNS)
# -----------------------------------------------------------------------------
resource "aws_eip" "forgejo" {
instance = aws_instance.forgejo.id
domain = "vpc"
tags = {
Name = "hearth-forgejo"
}
}
# -----------------------------------------------------------------------------
# Outputs
# -----------------------------------------------------------------------------
output "instance_id" {
description = "EC2 instance ID"
value = aws_instance.forgejo.id
}
output "public_ip" {
description = "Elastic IP address"
value = aws_eip.forgejo.public_ip
}
output "ssh_command" {
description = "SSH command for admin access"
value = "ssh -p ${var.admin_ssh_port} ec2-user@${aws_eip.forgejo.public_ip}"
}
output "forgejo_url" {
description = "Forgejo web URL"
value = "https://${var.domain}"
}
output "git_clone_url" {
description = "Git clone URL format"
value = "git@${var.domain}:ORG/REPO.git"
}
output "backup_bucket" {
description = "S3 bucket for backups"
value = aws_s3_bucket.backups.id
}
output "dns_record" {
description = "DNS A record to create"
value = "${var.domain} → ${aws_eip.forgejo.public_ip}"
}