From c16b70090fac6aa906fc281095f56ac6d7cfdcd4 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Wed, 21 Jan 2026 11:33:49 -0600 Subject: [PATCH] feat(infra): add full k8s stack mirroring docker-compose setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Terraform now deploys a complete k8s environment: - cert-manager with Let's Encrypt (staging + prod issuers) - Redis deployment with persistent storage - App deployment (2 replicas, rolling updates) - Traefik ingress with SSL, HSTS, HTTP→HTTPS redirect Ready for switchover by forwarding ports 80/443 to k3s VM. Co-Authored-By: Claude Opus 4.5 --- infra/terraform/.terraform.lock.hcl | 20 ++ infra/terraform/app.tf | 260 +++++++++++++++++++++++ infra/terraform/cert-manager.tf | 73 +++++++ infra/terraform/main.tf | 20 -- infra/terraform/outputs.tf | 47 +++- infra/terraform/redis.tf | 120 +++++++++++ infra/terraform/terraform.tfvars.example | 10 + infra/terraform/variables.tf | 29 +++ infra/terraform/versions.tf | 4 + 9 files changed, 559 insertions(+), 24 deletions(-) create mode 100644 infra/terraform/app.tf create mode 100644 infra/terraform/cert-manager.tf create mode 100644 infra/terraform/redis.tf create mode 100644 infra/terraform/terraform.tfvars.example diff --git a/infra/terraform/.terraform.lock.hcl b/infra/terraform/.terraform.lock.hcl index 9862dbee..ce7c176c 100644 --- a/infra/terraform/.terraform.lock.hcl +++ b/infra/terraform/.terraform.lock.hcl @@ -40,3 +40,23 @@ provider "registry.terraform.io/hashicorp/kubernetes" { "zh:faf23e45f0090eef8ba28a8aac7ec5d4fdf11a36c40a8d286304567d71c1e7db", ] } + +provider "registry.terraform.io/hashicorp/null" { + version = "3.2.4" + constraints = "~> 3.2" + hashes = [ + "h1:L5V05xwp/Gto1leRryuesxjMfgZwjb7oool4WS1UEFQ=", + "zh:59f6b52ab4ff35739647f9509ee6d93d7c032985d9f8c6237d1f8a59471bbbe2", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:795c897119ff082133150121d39ff26cb5f89a730a2c8c26f3a9c1abf81a9c43", + "zh:7b9c7b16f118fbc2b05a983817b8ce2f86df125857966ad356353baf4bff5c0a", + "zh:85e33ab43e0e1726e5f97a874b8e24820b6565ff8076523cc2922ba671492991", + "zh:9d32ac3619cfc93eb3c4f423492a8e0f79db05fec58e449dee9b2d5873d5f69f", + "zh:9e15c3c9dd8e0d1e3731841d44c34571b6c97f5b95e8296a45318b94e5287a6e", + "zh:b4c2ab35d1b7696c30b64bf2c0f3a62329107bd1a9121ce70683dec58af19615", + "zh:c43723e8cc65bcdf5e0c92581dcbbdcbdcf18b8d2037406a5f2033b1e22de442", + "zh:ceb5495d9c31bfb299d246ab333f08c7fb0d67a4f82681fbf47f2a21c3e11ab5", + "zh:e171026b3659305c558d9804062762d168f50ba02b88b231d20ec99578a6233f", + "zh:ed0fe2acdb61330b01841fa790be00ec6beaac91d41f311fb8254f74eb6a711f", + ] +} diff --git a/infra/terraform/app.tf b/infra/terraform/app.tf new file mode 100644 index 00000000..8b518a40 --- /dev/null +++ b/infra/terraform/app.tf @@ -0,0 +1,260 @@ +# Main application deployment + +resource "kubernetes_secret" "app_env" { + metadata { + name = "app-env" + namespace = kubernetes_namespace.abaci.metadata[0].name + } + + data = { + # Add sensitive env vars here + # DATABASE_URL = var.database_url + } +} + +resource "kubernetes_config_map" "app_config" { + metadata { + name = "app-config" + namespace = kubernetes_namespace.abaci.metadata[0].name + } + + data = { + NODE_ENV = "production" + PORT = "3000" + HOSTNAME = "0.0.0.0" + NEXT_TELEMETRY_DISABLED = "1" + REDIS_URL = "redis://redis:6379" + } +} + +resource "kubernetes_deployment" "app" { + metadata { + name = "abaci-app" + namespace = kubernetes_namespace.abaci.metadata[0].name + labels = { + app = "abaci-app" + } + } + + spec { + replicas = var.app_replicas + + selector { + match_labels = { + app = "abaci-app" + } + } + + strategy { + type = "RollingUpdate" + rolling_update { + max_surge = 1 + max_unavailable = 0 + } + } + + template { + metadata { + labels = { + app = "abaci-app" + } + } + + spec { + container { + name = "app" + image = var.app_image + + port { + container_port = 3000 + } + + env_from { + config_map_ref { + name = kubernetes_config_map.app_config.metadata[0].name + } + } + + env_from { + secret_ref { + name = kubernetes_secret.app_env.metadata[0].name + } + } + + resources { + requests = { + memory = "256Mi" + cpu = "100m" + } + limits = { + memory = "512Mi" + cpu = "1000m" + } + } + + liveness_probe { + http_get { + path = "/api/health" + port = 3000 + } + initial_delay_seconds = 30 + period_seconds = 10 + timeout_seconds = 5 + failure_threshold = 3 + } + + readiness_probe { + http_get { + path = "/api/health" + port = 3000 + } + initial_delay_seconds = 5 + period_seconds = 5 + timeout_seconds = 3 + failure_threshold = 3 + } + } + } + } + } + + depends_on = [kubernetes_deployment.redis] +} + +resource "kubernetes_service" "app" { + metadata { + name = "abaci-app" + namespace = kubernetes_namespace.abaci.metadata[0].name + } + + spec { + selector = { + app = "abaci-app" + } + + port { + port = 80 + target_port = 3000 + } + + type = "ClusterIP" + } +} + +# Ingress with SSL via cert-manager +resource "kubernetes_ingress_v1" "app" { + metadata { + name = "abaci-app" + namespace = kubernetes_namespace.abaci.metadata[0].name + annotations = { + "cert-manager.io/cluster-issuer" = var.use_staging_certs ? "letsencrypt-staging" : "letsencrypt-prod" + "traefik.ingress.kubernetes.io/router.entrypoints" = "websecure" + # HSTS headers + "traefik.ingress.kubernetes.io/router.middlewares" = "${kubernetes_namespace.abaci.metadata[0].name}-hsts@kubernetescrd" + } + } + + spec { + ingress_class_name = "traefik" + + tls { + hosts = [var.app_domain] + secret_name = "abaci-tls" + } + + rule { + host = var.app_domain + + http { + path { + path = "/" + path_type = "Prefix" + + backend { + service { + name = kubernetes_service.app.metadata[0].name + port { + number = 80 + } + } + } + } + } + } + } + + depends_on = [null_resource.cert_manager_issuers] +} + +# HSTS middleware +resource "kubernetes_manifest" "hsts_middleware" { + manifest = { + apiVersion = "traefik.io/v1alpha1" + kind = "Middleware" + metadata = { + name = "hsts" + namespace = kubernetes_namespace.abaci.metadata[0].name + } + spec = { + headers = { + stsSeconds = 63072000 + stsIncludeSubdomains = true + stsPreload = true + } + } + } +} + +# HTTP to HTTPS redirect +resource "kubernetes_ingress_v1" "app_http_redirect" { + metadata { + name = "abaci-app-http-redirect" + namespace = kubernetes_namespace.abaci.metadata[0].name + annotations = { + "traefik.ingress.kubernetes.io/router.entrypoints" = "web" + "traefik.ingress.kubernetes.io/router.middlewares" = "${kubernetes_namespace.abaci.metadata[0].name}-redirect-https@kubernetescrd" + } + } + + spec { + ingress_class_name = "traefik" + + rule { + host = var.app_domain + + http { + path { + path = "/" + path_type = "Prefix" + + backend { + service { + name = kubernetes_service.app.metadata[0].name + port { + number = 80 + } + } + } + } + } + } + } +} + +# Redirect middleware +resource "kubernetes_manifest" "redirect_https_middleware" { + manifest = { + apiVersion = "traefik.io/v1alpha1" + kind = "Middleware" + metadata = { + name = "redirect-https" + namespace = kubernetes_namespace.abaci.metadata[0].name + } + spec = { + redirectScheme = { + scheme = "https" + permanent = true + } + } + } +} diff --git a/infra/terraform/cert-manager.tf b/infra/terraform/cert-manager.tf new file mode 100644 index 00000000..e72c2cd1 --- /dev/null +++ b/infra/terraform/cert-manager.tf @@ -0,0 +1,73 @@ +# cert-manager for automatic Let's Encrypt SSL certificates + +resource "helm_release" "cert_manager" { + name = "cert-manager" + repository = "https://charts.jetstack.io" + chart = "cert-manager" + namespace = "cert-manager" + create_namespace = true + version = "v1.14.4" + + set { + name = "installCRDs" + value = "true" + } + + set { + name = "global.leaderElection.namespace" + value = "cert-manager" + } +} + +# ClusterIssuers need to be applied after cert-manager CRDs are installed +# Using local-exec since kubernetes_manifest validates CRDs at plan time + +resource "null_resource" "cert_manager_issuers" { + depends_on = [helm_release.cert_manager] + + provisioner "local-exec" { + command = <<-EOT + export KUBECONFIG=${pathexpand(var.kubeconfig_path)} + + # Wait for cert-manager webhook to be ready + kubectl wait --for=condition=Available deployment/cert-manager-webhook -n cert-manager --timeout=120s + + # Apply ClusterIssuers + cat <