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:
parent
31e0c2bfee
commit
c16b70090f
|
|
@ -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",
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
# }
|
||||
# }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,5 +10,9 @@ terraform {
|
|||
source = "hashicorp/helm"
|
||||
version = "~> 2.12"
|
||||
}
|
||||
null = {
|
||||
source = "hashicorp/null"
|
||||
version = "~> 3.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue