feat(infra): add full k8s stack mirroring docker-compose setup

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 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2026-01-21 11:33:49 -06:00
parent 31e0c2bfee
commit c16b70090f
9 changed files with 559 additions and 24 deletions

View File

@ -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",
]
}

260
infra/terraform/app.tf Normal file
View File

@ -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
}
}
}
}

View File

@ -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 <<EOF | kubectl apply -f -
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: ${var.letsencrypt_email}
privateKeySecretRef:
name: letsencrypt-prod-key
solvers:
- http01:
ingress:
class: traefik
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-staging
spec:
acme:
server: https://acme-staging-v02.api.letsencrypt.org/directory
email: ${var.letsencrypt_email}
privateKeySecretRef:
name: letsencrypt-staging-key
solvers:
- http01:
ingress:
class: traefik
EOF
EOT
}
triggers = {
email = var.letsencrypt_email
}
}

View File

@ -18,23 +18,3 @@ resource "kubernetes_namespace" "abaci" {
}
}
}
# Example: Redis deployment (optional - can use this instead of Docker Redis)
# Uncomment when ready to migrate Redis to k3s
#
# resource "helm_release" "redis" {
# name = "redis"
# repository = "https://charts.bitnami.com/bitnami"
# chart = "redis"
# namespace = kubernetes_namespace.abaci.metadata[0].name
#
# set {
# name = "architecture"
# value = "standalone"
# }
#
# set {
# name = "auth.enabled"
# value = "false"
# }
# }

View File

@ -3,10 +3,49 @@ output "namespace" {
value = kubernetes_namespace.abaci.metadata[0].name
}
output "cluster_info" {
description = "k3s cluster information"
output "app_service" {
description = "App service details"
value = {
kubeconfig = var.kubeconfig_path
namespace = var.namespace
name = kubernetes_service.app.metadata[0].name
namespace = kubernetes_service.app.metadata[0].namespace
port = 80
}
}
output "redis_service" {
description = "Redis service details"
value = {
name = kubernetes_service.redis.metadata[0].name
namespace = kubernetes_service.redis.metadata[0].namespace
url = "redis://redis.${kubernetes_namespace.abaci.metadata[0].name}.svc.cluster.local:6379"
}
}
output "ingress_info" {
description = "Ingress information"
value = {
domain = var.app_domain
tls_secret = "abaci-tls"
}
}
output "switchover_checklist" {
description = "Steps to switch traffic from Docker to k8s"
value = <<-EOT
To switch traffic from Docker Compose to k8s:
1. Ensure k8s pods are healthy:
kubectl get pods -n abaci
2. Update port forwarding on router:
- Forward ports 80 and 443 to k3s-node VM (192.168.86.37)
- Or update DNS to point to VM's public IP
3. Verify SSL certificate is issued:
kubectl get certificate -n abaci
4. Test the site via k8s
5. To rollback: revert port forwarding to NAS
EOT
}

120
infra/terraform/redis.tf Normal file
View File

@ -0,0 +1,120 @@
# Redis deployment for session storage and Socket.IO
resource "kubernetes_deployment" "redis" {
metadata {
name = "redis"
namespace = kubernetes_namespace.abaci.metadata[0].name
labels = {
app = "redis"
}
}
spec {
replicas = 1
selector {
match_labels = {
app = "redis"
}
}
template {
metadata {
labels = {
app = "redis"
}
}
spec {
container {
name = "redis"
image = "redis:7-alpine"
args = ["redis-server", "--appendonly", "yes"]
port {
container_port = 6379
}
resources {
requests = {
memory = "128Mi"
cpu = "100m"
}
limits = {
memory = "256Mi"
cpu = "500m"
}
}
volume_mount {
name = "redis-data"
mount_path = "/data"
}
liveness_probe {
exec {
command = ["redis-cli", "ping"]
}
initial_delay_seconds = 5
period_seconds = 10
}
readiness_probe {
exec {
command = ["redis-cli", "ping"]
}
initial_delay_seconds = 5
period_seconds = 5
}
}
volume {
name = "redis-data"
persistent_volume_claim {
claim_name = kubernetes_persistent_volume_claim.redis_data.metadata[0].name
}
}
}
}
}
}
resource "kubernetes_persistent_volume_claim" "redis_data" {
metadata {
name = "redis-data"
namespace = kubernetes_namespace.abaci.metadata[0].name
}
spec {
access_modes = ["ReadWriteOnce"]
resources {
requests = {
storage = "1Gi"
}
}
storage_class_name = "local-path" # k3s default storage class
}
wait_until_bound = false # local-path uses WaitForFirstConsumer
}
resource "kubernetes_service" "redis" {
metadata {
name = "redis"
namespace = kubernetes_namespace.abaci.metadata[0].name
}
spec {
selector = {
app = "redis"
}
port {
port = 6379
target_port = 6379
}
type = "ClusterIP"
}
}

View File

@ -0,0 +1,10 @@
# Copy this to terraform.tfvars and fill in values
# Required
letsencrypt_email = "your-email@example.com"
# Optional overrides
# app_domain = "abaci.one"
# app_image = "ghcr.io/antialias/soroban-abacus-flashcards:latest"
# app_replicas = 2
# use_staging_certs = true # Set to true when testing to avoid rate limits

View File

@ -9,3 +9,32 @@ variable "namespace" {
type = string
default = "abaci"
}
variable "app_domain" {
description = "Domain name for the application"
type = string
default = "abaci.one"
}
variable "app_image" {
description = "Docker image for the application"
type = string
default = "ghcr.io/antialias/soroban-abacus-flashcards:latest"
}
variable "app_replicas" {
description = "Number of app replicas"
type = number
default = 2
}
variable "letsencrypt_email" {
description = "Email for Let's Encrypt certificate notifications"
type = string
}
variable "use_staging_certs" {
description = "Use Let's Encrypt staging (for testing, avoids rate limits)"
type = bool
default = false
}

View File

@ -10,5 +10,9 @@ terraform {
source = "hashicorp/helm"
version = "~> 2.12"
}
null = {
source = "hashicorp/null"
version = "~> 3.2"
}
}
}