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>
This commit is contained in:
Eric Garcia 2026-01-24 06:42:32 -05:00
parent c5b5945b46
commit 0d904fe130
8 changed files with 168 additions and 350 deletions

View file

@ -1,84 +0,0 @@
# DNS Elastic IPs for Stable Glue Records
# RFC 0046: Domain Email Migration - Phase 1
#
# Purpose: Allocate static Elastic IPs for the DNS NLB to enable
# stable glue records at GoDaddy for NS delegation.
#
# Architecture:
# - 3 EIPs (one per AZ) for high availability
# - Attached to NLB via subnet_mapping
# - Used as glue records: ns1.domain.com, ns2.domain.com, ns3.domain.com
#
# Cost: ~$0/mo (EIPs attached to running resources are free)
locals {
# Enable static IPs for DNS delegation
enable_dns_static_ips = var.enable_dns_static_ips
# Domains to be delegated from GoDaddy to PowerDNS
managed_domains = [
"superviber.com",
"muffinlabs.ai",
"letemcook.com",
"appbasecamp.com",
"thanksforborrowing.com",
"alignment.coop"
]
}
# Allocate Elastic IPs for DNS NLB (one per AZ)
resource "aws_eip" "dns" {
count = local.enable_dns_static_ips ? length(local.azs) : 0
domain = "vpc"
tags = merge(local.common_tags, {
Name = "${local.name}-dns-${count.index + 1}"
Purpose = "dns-nlb"
RFC = "0046"
Description = "Stable IP for DNS glue records - AZ ${count.index + 1}"
})
lifecycle {
# Prevent accidental deletion - changing these breaks glue records
prevent_destroy = true
}
}
# Output the EIP public IPs for glue record configuration
output "dns_elastic_ips" {
description = "Elastic IP addresses for DNS NLB (use for glue records at GoDaddy)"
value = aws_eip.dns[*].public_ip
}
output "dns_elastic_ip_allocation_ids" {
description = "Elastic IP allocation IDs for NLB subnet_mapping"
value = aws_eip.dns[*].id
}
output "glue_record_instructions" {
description = "Instructions for configuring glue records at GoDaddy"
value = local.enable_dns_static_ips ? join("\n", [
"================================================================================",
"GoDaddy Glue Record Configuration",
"RFC 0046: Domain Email Migration - DNS Delegation",
"================================================================================",
"",
"Domains: ${join(", ", local.managed_domains)}",
"",
"1. Custom Nameservers (set for each domain):",
" - ns1.<domain>",
" - ns2.<domain>",
" - ns3.<domain>",
"",
"2. Glue Records (Host Records):",
" ns1 -> ${try(aws_eip.dns[0].public_ip, "PENDING")}",
" ns2 -> ${try(aws_eip.dns[1].public_ip, "PENDING")}",
" ns3 -> ${try(aws_eip.dns[2].public_ip, "PENDING")}",
"",
"3. Verification Commands:",
" dig @${try(aws_eip.dns[0].public_ip, "PENDING")} superviber.com NS",
" dig @8.8.8.8 superviber.com NS",
"",
"================================================================================"
]) : "DNS static IPs not enabled. Set enable_dns_static_ips = true to allocate EIPs."
}

View file

@ -107,9 +107,9 @@ module "nlb" {
vpc_id = module.vpc.vpc_id
public_subnet_ids = module.vpc.public_subnet_ids
# RFC 0046: Enable static IPs for stable DNS glue records
enable_static_ips = var.enable_dns_static_ips
elastic_ip_ids = var.enable_dns_static_ips ? aws_eip.dns[*].id : []
# Static IPs disabled for initial deployment
enable_static_ips = false
elastic_ip_ids = []
tags = local.common_tags

View file

@ -332,6 +332,7 @@ resource "aws_instance" "forgejo" {
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 = {

View file

@ -32,6 +32,15 @@ systemctl enable --now docker
sed -i "s/#Port 22/Port $SSH_PORT/" /etc/ssh/sshd_config
systemctl restart sshd
# Add admin SSH key
if [ -n "${ssh_public_key}" ]; then
mkdir -p /home/ec2-user/.ssh
echo "${ssh_public_key}" >> /home/ec2-user/.ssh/authorized_keys
chown -R ec2-user:ec2-user /home/ec2-user/.ssh
chmod 700 /home/ec2-user/.ssh
chmod 600 /home/ec2-user/.ssh/authorized_keys
fi
# Enable automatic security updates
dnf install -y dnf-automatic
sed -i 's/apply_updates = no/apply_updates = yes/' /etc/dnf/automatic.conf
@ -64,52 +73,115 @@ kind: Namespace
metadata:
name: traefik
---
apiVersion: helm.cattle.io/v1
kind: HelmChart
apiVersion: v1
kind: ServiceAccount
metadata:
name: traefik
namespace: kube-system
namespace: traefik
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: traefik
rules:
- apiGroups: [""]
resources: [services, endpoints, secrets, nodes, pods]
verbs: [get, list, watch]
- apiGroups: [extensions, networking.k8s.io]
resources: [ingresses, ingressclasses]
verbs: [get, list, watch]
- apiGroups: [extensions, networking.k8s.io]
resources: [ingresses/status]
verbs: [update]
- apiGroups: [traefik.io]
resources: ["*"]
verbs: [get, list, watch]
- apiGroups: [discovery.k8s.io]
resources: [endpointslices]
verbs: [get, list, watch]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: traefik
subjects:
- kind: ServiceAccount
name: traefik
namespace: traefik
roleRef:
kind: ClusterRole
name: traefik
apiGroup: rbac.authorization.k8s.io
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: traefik
namespace: traefik
spec:
repo: https://traefik.github.io/charts
chart: traefik
targetNamespace: traefik
valuesContent: |-
ports:
ssh:
port: 2222
exposedPort: 22
expose:
default: true
protocol: TCP
web:
redirectTo:
port: websecure
websecure:
tls:
enabled: true
ingressRoute:
dashboard:
enabled: false
certificatesResolvers:
letsencrypt:
acme:
email: ${letsencrypt_email}
storage: /data/acme.json
httpChallenge:
entryPoint: web
persistence:
enabled: true
size: 128Mi
additionalArguments:
- "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
- "--entrypoints.ssh.address=:2222/tcp"
service:
type: LoadBalancer
replicas: 1
selector:
matchLabels:
app: traefik
template:
metadata:
labels:
app: traefik
spec:
serviceAccountName: traefik
containers:
- name: traefik
image: traefik:v3.2
args:
- --api.insecure=false
- --providers.kubernetesingress
- --providers.kubernetescrd
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
- --entrypoints.ssh.address=:22/tcp
- --entrypoints.web.http.redirections.entrypoint.to=websecure
- --certificatesresolvers.letsencrypt.acme.email=${letsencrypt_email}
- --certificatesresolvers.letsencrypt.acme.storage=/data/acme.json
- --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web
ports:
- name: web
containerPort: 80
- name: websecure
containerPort: 443
- name: ssh
containerPort: 22
volumeMounts:
- name: acme
mountPath: /data
volumes:
- name: acme
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: traefik
namespace: traefik
spec:
type: LoadBalancer
externalTrafficPolicy: Local
selector:
app: traefik
ports:
- name: web
port: 80
targetPort: 80
- name: websecure
port: 443
targetPort: 443
- name: ssh
port: 22
targetPort: 22
EOF
# Wait for Traefik
echo "Waiting for Traefik..."
sleep 30
sleep 15
# -----------------------------------------------------------------------------
# Create Forgejo Namespace and Resources

View file

@ -45,3 +45,9 @@ variable "admin_cidr_blocks" {
type = list(string)
default = ["0.0.0.0/0"] # Restrict this in production!
}
variable "ssh_public_key" {
description = "SSH public key for admin access"
type = string
default = ""
}

BIN
terraform/plan.tfplan Normal file

Binary file not shown.

View file

@ -1,212 +0,0 @@
# Vault PKI for S/MIME Certificates
# RFC 0041: Self-Hosted DNS and Email
# ADR 0006: Zero-Knowledge Zero-Trust (Layer 3 encryption support)
#
# This configuration creates a PKI secrets engine in Vault for issuing
# S/MIME certificates to users. S/MIME provides end-to-end email encryption
# (Layer 3) where only the sender and recipient can read the email content.
terraform {
required_providers {
vault = {
source = "hashicorp/vault"
version = "~> 4.0"
}
}
}
# =============================================================================
# ROOT CA (Offline - used only to sign intermediate)
# =============================================================================
resource "vault_mount" "pki_root_smime" {
path = "pki-smime-root"
type = "pki"
description = "S/MIME Root CA - Offline, used only to sign intermediate"
# Root CA has a longer lifetime
default_lease_ttl_seconds = 315360000 # 10 years
max_lease_ttl_seconds = 315360000 # 10 years
}
resource "vault_pki_secret_backend_root_cert" "smime_root" {
backend = vault_mount.pki_root_smime.path
type = "internal"
common_name = "Alignment S/MIME Root CA"
ttl = "315360000" # 10 years
key_type = "rsa"
key_bits = 4096
organization = "Alignment"
country = "US"
exclude_cn_from_sans = true
}
# =============================================================================
# INTERMEDIATE CA (Online - used to issue user certificates)
# =============================================================================
resource "vault_mount" "pki_smime" {
path = "pki-smime"
type = "pki"
description = "S/MIME Intermediate CA - Issues user certificates"
# Intermediate has shorter lifetime
default_lease_ttl_seconds = 31536000 # 1 year
max_lease_ttl_seconds = 94608000 # 3 years
}
# Generate intermediate CSR
resource "vault_pki_secret_backend_intermediate_cert_request" "smime_intermediate" {
backend = vault_mount.pki_smime.path
type = "internal"
common_name = "Alignment S/MIME Intermediate CA"
key_type = "rsa"
key_bits = 4096
organization = "Alignment"
country = "US"
}
# Sign intermediate with root
resource "vault_pki_secret_backend_root_sign_intermediate" "smime_intermediate" {
backend = vault_mount.pki_root_smime.path
common_name = "Alignment S/MIME Intermediate CA"
csr = vault_pki_secret_backend_intermediate_cert_request.smime_intermediate.csr
ttl = "157680000" # 5 years
organization = "Alignment"
country = "US"
# Intermediate CA permissions
permitted_dns_domains = ["alignment.dev"]
}
# Set the signed intermediate certificate
resource "vault_pki_secret_backend_intermediate_set_signed" "smime_intermediate" {
backend = vault_mount.pki_smime.path
certificate = vault_pki_secret_backend_root_sign_intermediate.smime_intermediate.certificate
}
# =============================================================================
# S/MIME CERTIFICATE ROLE
# =============================================================================
resource "vault_pki_secret_backend_role" "smime_user" {
backend = vault_mount.pki_smime.path
name = "smime-user"
# Certificate lifetime
ttl = 31536000 # 1 year
max_ttl = 63072000 # 2 years
# Domain restrictions
allow_any_name = false
allowed_domains = ["alignment.dev"]
allow_subdomains = false
enforce_hostnames = false
# Allow email addresses
allow_bare_domains = true
# Key configuration
key_type = "rsa"
key_bits = 4096
key_usage = ["DigitalSignature", "KeyEncipherment", "ContentCommitment"]
# S/MIME specific - Email Protection extended key usage
ext_key_usage = ["EmailProtection"]
# Certificate properties
require_cn = true
use_csr_common_name = true
use_csr_sans = true
# Organization defaults
organization = ["Alignment"]
country = ["US"]
# Don't include SANs automatically
allow_ip_sans = false
allow_localhost = false
allowed_uri_sans = []
allowed_other_sans = []
# Generate certificates (not just sign CSRs)
generate_lease = true
no_store = false
}
# =============================================================================
# POLICY FOR S/MIME CERTIFICATE ISSUANCE
# =============================================================================
resource "vault_policy" "smime_user_issue" {
name = "smime-user-issue"
policy = <<-EOT
# Allow users to issue S/MIME certificates for their own email
# Users authenticate via OIDC, and their email claim is used to verify
# they can only request certificates for their own email address.
# Issue S/MIME certificate
path "pki-smime/issue/smime-user" {
capabilities = ["create", "update"]
# Restrict to user's own email
allowed_parameters = {
"common_name" = []
"alt_names" = []
"ttl" = []
}
}
# Read certificate chain
path "pki-smime/ca/pem" {
capabilities = ["read"]
}
path "pki-smime/ca_chain" {
capabilities = ["read"]
}
# List own certificates
path "pki-smime/certs" {
capabilities = ["list"]
}
EOT
}
# =============================================================================
# CRL CONFIGURATION
# =============================================================================
resource "vault_pki_secret_backend_config_urls" "smime_urls" {
backend = vault_mount.pki_smime.path
issuing_certificates = [
"https://vault.alignment.dev/v1/pki-smime/ca"
]
crl_distribution_points = [
"https://vault.alignment.dev/v1/pki-smime/crl"
]
ocsp_servers = [
"https://vault.alignment.dev/v1/pki-smime/ocsp"
]
}
# =============================================================================
# OUTPUTS
# =============================================================================
output "smime_ca_chain" {
description = "S/MIME CA certificate chain"
value = vault_pki_secret_backend_intermediate_set_signed.smime_intermediate.certificate
sensitive = false
}
output "smime_issue_path" {
description = "Path to issue S/MIME certificates"
value = "${vault_mount.pki_smime.path}/issue/${vault_pki_secret_backend_role.smime_user.name}"
}

View file

@ -1,11 +1,7 @@
# Foundation Infrastructure - Terraform Configuration
# RFC 0039: ADR-Compliant Foundation Infrastructure
# ADR 0003: CockroachDB Self-Hosted FIPS
# ADR 0004: Set It and Forget It Architecture
# ADR 0005: Full-Stack Self-Hosting
# Hearth Infrastructure - Terraform Configuration
terraform {
required_version = ">= 1.6.0"
required_version = ">= 1.5.0"
required_providers {
aws = {
@ -20,16 +16,55 @@ terraform {
source = "hashicorp/helm"
version = "~> 2.12"
}
kubectl = {
source = "gavinbunney/kubectl"
version = "~> 1.14"
}
tls = {
source = "hashicorp/tls"
version = "~> 4.0"
}
}
# Backend configuration - use S3 for state storage
# Configure in environments/production/backend.tf
backend "s3" {
bucket = "hearth-terraform-state-181640953119"
key = "hearth/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 = var.project_name
Environment = var.environment
ManagedBy = "terraform"
}
}
}
provider "kubernetes" {
host = module.eks.cluster_endpoint
cluster_ca_certificate = base64decode(module.eks.cluster_certificate_authority_data)
exec {
api_version = "client.authentication.k8s.io/v1beta1"
command = "aws"
args = ["eks", "get-token", "--cluster-name", module.eks.cluster_name, "--profile", "hearth"]
}
}
provider "helm" {
kubernetes {
host = module.eks.cluster_endpoint
cluster_ca_certificate = base64decode(module.eks.cluster_certificate_authority_data)
exec {
api_version = "client.authentication.k8s.io/v1beta1"
command = "aws"
args = ["eks", "get-token", "--cluster-name", module.eks.cluster_name, "--profile", "hearth"]
}
}
}