diff --git a/terraform/doks.tf b/terraform/doks.tf new file mode 100644 index 0000000..4bf08b2 --- /dev/null +++ b/terraform/doks.tf @@ -0,0 +1,30 @@ +# ─── DOKS Cluster ───────────────────────────────────────────────────────────── + +resource "digitalocean_kubernetes_cluster" "main" { + name = "lunarfront" + region = var.region + version = var.k8s_version + + node_pool { + name = "workers" + size = var.k8s_node_size + min_nodes = var.k8s_min_nodes + max_nodes = var.k8s_max_nodes + auto_scale = true + } + + tags = ["lunarfront", "k8s"] +} + +# ─── DNS — wildcard for customer subdomains → cluster load balancer ─────────── +# Uncomment after the cluster is up and nginx ingress load balancer IP is known. +# Set cluster_lb_ip in terraform.tfvars then re-run terraform apply. + +resource "cloudflare_record" "apps_wildcard" { + zone_id = data.cloudflare_zone.main.id + name = "*" + type = "A" + content = var.cluster_lb_ip + proxied = true + ttl = 1 +} diff --git a/terraform/main.tf b/terraform/main.tf index dbd24c7..e4c09d6 100644 --- a/terraform/main.tf +++ b/terraform/main.tf @@ -112,11 +112,11 @@ resource "digitalocean_firewall" "gitea" { source_addresses = ["0.0.0.0/0", "::/0"] } - # Gitea SSH for git push/pull — your IP only + # Gitea SSH for git push/pull — open until Gitea is migrated to DOKS inbound_rule { protocol = "tcp" port_range = "2222" - source_addresses = ["${var.admin_ip}/32"] + source_addresses = ["0.0.0.0/0", "::/0"] } outbound_rule { @@ -138,7 +138,7 @@ resource "cloudflare_record" "gitea" { zone_id = data.cloudflare_zone.main.id name = "git" type = "A" - value = digitalocean_droplet.gitea.ipv4_address + content = digitalocean_droplet.gitea.ipv4_address proxied = true ttl = 1 } @@ -147,7 +147,7 @@ resource "cloudflare_record" "vaultwarden" { zone_id = data.cloudflare_zone.main.id name = "vault" type = "A" - value = digitalocean_droplet.gitea.ipv4_address + content = digitalocean_droplet.gitea.ipv4_address proxied = true ttl = 1 } @@ -157,7 +157,7 @@ resource "cloudflare_record" "git_ssh" { zone_id = data.cloudflare_zone.main.id name = "git-ssh" type = "A" - value = digitalocean_droplet.gitea.ipv4_address + content = digitalocean_droplet.gitea.ipv4_address proxied = false ttl = 3600 } @@ -167,7 +167,7 @@ resource "cloudflare_record" "registry" { zone_id = data.cloudflare_zone.main.id name = "registry" type = "A" - value = digitalocean_droplet.gitea.ipv4_address + content = digitalocean_droplet.gitea.ipv4_address proxied = false ttl = 3600 } diff --git a/terraform/outputs.tf b/terraform/outputs.tf index ee8e655..b987e84 100644 --- a/terraform/outputs.tf +++ b/terraform/outputs.tf @@ -2,3 +2,28 @@ output "gitea_ip" { description = "Public IP of the Gitea droplet" value = digitalocean_droplet.gitea.ipv4_address } + +output "k8s_cluster_id" { + description = "DOKS cluster ID" + value = digitalocean_kubernetes_cluster.main.id +} + +output "postgres_host" { + description = "Managed Postgres host" + value = digitalocean_database_cluster.postgres.host +} + +output "postgres_port" { + description = "Managed Postgres port" + value = digitalocean_database_cluster.postgres.port +} + +output "redis_host" { + description = "Managed Redis/Valkey host" + value = digitalocean_database_cluster.redis.host +} + +output "redis_port" { + description = "Managed Redis/Valkey port" + value = digitalocean_database_cluster.redis.port +} diff --git a/terraform/postgres.tf b/terraform/postgres.tf new file mode 100644 index 0000000..6acaff4 --- /dev/null +++ b/terraform/postgres.tf @@ -0,0 +1,23 @@ +# ─── Managed PostgreSQL cluster ─────────────────────────────────────────────── +# Shared across all customers — each customer gets their own database. + +resource "digitalocean_database_cluster" "postgres" { + name = "lunarfront-postgres" + engine = "pg" + version = "16" + size = var.postgres_size + region = var.region + node_count = 1 + + tags = ["lunarfront", "postgres"] +} + +# Restrict access to the DOKS cluster only +resource "digitalocean_database_firewall" "postgres" { + cluster_id = digitalocean_database_cluster.postgres.id + + rule { + type = "k8s" + value = digitalocean_kubernetes_cluster.main.id + } +} diff --git a/terraform/project.tf b/terraform/project.tf new file mode 100644 index 0000000..f3bc189 --- /dev/null +++ b/terraform/project.tf @@ -0,0 +1,15 @@ +# ─── DO Project assignment ──────────────────────────────────────────────────── + +data "digitalocean_project" "main" { + name = "lunarfront-infra" +} + +resource "digitalocean_project_resources" "main" { + project = data.digitalocean_project.main.id + resources = [ + digitalocean_droplet.gitea.urn, + digitalocean_kubernetes_cluster.main.urn, + digitalocean_database_cluster.postgres.urn, + digitalocean_database_cluster.redis.urn, + ] +} diff --git a/terraform/redis.tf b/terraform/redis.tf new file mode 100644 index 0000000..130db66 --- /dev/null +++ b/terraform/redis.tf @@ -0,0 +1,22 @@ +# ─── Managed Valkey/Redis cluster ───────────────────────────────────────────── +# Shared across all customers. + +resource "digitalocean_database_cluster" "redis" { + name = "lunarfront-redis" + engine = "valkey" + version = "8" + size = var.redis_size + region = var.region + node_count = 1 + + tags = ["lunarfront", "redis"] +} + +resource "digitalocean_database_firewall" "redis" { + cluster_id = digitalocean_database_cluster.redis.id + + rule { + type = "k8s" + value = digitalocean_kubernetes_cluster.main.id + } +} diff --git a/terraform/terraform.tfvars.example b/terraform/terraform.tfvars.example index 4d9763e..2324c25 100644 --- a/terraform/terraform.tfvars.example +++ b/terraform/terraform.tfvars.example @@ -1,6 +1,7 @@ do_token = "your-digitalocean-api-token" ssh_key_name = "your-key-name-in-do" region = "nyc3" -droplet_size = "s-1vcpu-2gb" +droplet_size = "s-2vcpu-4gb" cloudflare_api_token = "your-cloudflare-api-token" domain = "example.com" +cluster_lb_ip = "your-ingress-lb-ip" diff --git a/terraform/variables.tf b/terraform/variables.tf index eab2c1d..e1294cb 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -18,7 +18,7 @@ variable "region" { variable "droplet_size" { description = "Droplet size slug" type = string - default = "s-1vcpu-2gb" + default = "s-2vcpu-4gb" } variable "cloudflare_api_token" { @@ -37,3 +37,49 @@ variable "admin_ip" { type = string sensitive = true } + +# ─── DOKS ───────────────────────────────────────────────────────────────────── + +variable "k8s_version" { + description = "Kubernetes version slug (run: doctl kubernetes options versions)" + type = string + default = "1.32.13-do.2" +} + +variable "k8s_node_size" { + description = "Node pool droplet size" + type = string + default = "s-2vcpu-4gb" +} + +variable "k8s_min_nodes" { + description = "Minimum nodes in the pool" + type = number + default = 1 +} + +variable "k8s_max_nodes" { + description = "Maximum nodes in the pool" + type = number + default = 3 +} + +variable "cluster_lb_ip" { + description = "IP of the DOKS ingress load balancer (set after first cluster apply)" + type = string + default = "" +} + +# ─── Managed databases ──────────────────────────────────────────────────────── + +variable "postgres_size" { + description = "Managed Postgres cluster size slug" + type = string + default = "db-s-1vcpu-1gb" +} + +variable "redis_size" { + description = "Managed Valkey/Redis cluster size slug" + type = string + default = "db-s-1vcpu-1gb" +} diff --git a/terraform/waf.tf b/terraform/waf.tf new file mode 100644 index 0000000..088d5bc --- /dev/null +++ b/terraform/waf.tf @@ -0,0 +1,25 @@ +# ─── Cloudflare WAF — restrict admin subdomains to admin IP ─────────────────── + +resource "cloudflare_ruleset" "admin_ip_allowlist" { + zone_id = data.cloudflare_zone.main.id + name = "Admin IP allowlist" + description = "Block access to admin subdomains from non-admin IPs" + kind = "zone" + phase = "http_request_firewall_custom" + + rules { + action = "block" + description = "Block non-admin IPs from admin subdomains" + enabled = true + expression = <<-EOT + ( + http.host in { + "git.${var.domain}" + "vault.${var.domain}" + "argocd.${var.domain}" + } + and not ip.src eq ${var.admin_ip} + ) + EOT + } +}