Compare commits

...

13 Commits

Author SHA1 Message Date
Ryan Moon
911bc3eb76 fix: ignore default node_pool in cluster lifecycle to prevent recreation
Some checks failed
Terraform / terraform (push) Failing after 0s
Test / test (push) Successful in 37s
2026-04-03 07:20:55 -05:00
Ryan Moon
35b78f672c feat: add system and customer node pools, scale default workers to 0
Some checks failed
Terraform / terraform (push) Has been cancelled
Test / test (push) Has been cancelled
2026-04-03 07:20:00 -05:00
Ryan Moon
10528dd7bb feat: decommission droplet, point git DNS to cluster LB
Some checks failed
Terraform / terraform (push) Failing after 1s
Test / test (push) Successful in 1s
2026-04-02 20:44:22 -05:00
Ryan Moon
3471374cb6 feat: add DOKS, managed postgres/redis, WAF rules, external-dns
Some checks failed
Terraform / terraform (push) Failing after 32s
Test / test (push) Successful in 1s
2026-04-02 17:25:13 -05:00
Ryan Moon
155ef0345e fix: use catthehacker ubuntu image for runner to include Docker CLI 2026-04-01 21:29:37 -05:00
Ryan Moon
d8528f64dc fix: use host network for runner containers to enable service DNS 2026-04-01 21:15:43 -05:00
Ryan Moon
a47b5cf50e feat: add Bun to CI runner image 2026-04-01 20:04:28 -05:00
Ryan Moon
68e6587ea1 Add os-update playbook 2026-03-31 20:34:54 -05:00
Ryan Moon
99348d9eaa Split playbooks: each service independent, infra.yml only runs backup 2026-03-31 20:28:18 -05:00
Ryan Moon
4c22465a59 Remove vim swap file, ignore *.swp 2026-03-31 20:21:20 -05:00
Ryan Moon
610e68cf40 Split runner into own playbook to prevent self-restart during CI 2026-03-31 20:21:10 -05:00
Ryan Moon
3e055e2c6a Mark admin_ip as sensitive 2026-03-31 20:18:18 -05:00
Ryan Moon
7eb51120f2 Add gitea_registry_domain to vars 2026-03-31 20:15:56 -05:00
18 changed files with 263 additions and 129 deletions

View File

@@ -14,6 +14,10 @@ on:
type: choice type: choice
options: options:
- infra.yml - infra.yml
- gitea.yml
- vaultwarden.yml
- runner.yml
- os-update.yml
jobs: jobs:
ansible: ansible:

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@ terraform/.terraform/
*.tfstate *.tfstate
*.tfstate.backup *.tfstate.backup
ansible/inventory.ini ansible/inventory.ini
*.swp

View File

@@ -4,3 +4,5 @@ gitea_instance_url: "https://git.lunarfront.tech"
vaultwarden_domain: "vault.lunarfront.tech" vaultwarden_domain: "vault.lunarfront.tech"
vaultwarden_signups_allowed: false vaultwarden_signups_allowed: false
gitea_db_type: postgres gitea_db_type: postgres
gitea_registry_domain: "registry.lunarfront.tech"
gitea_registry_domain: "registry.lunarfront.tech"

View File

@@ -4,7 +4,4 @@
become: true become: true
roles: roles:
- gitea
- gitea-runner
- vaultwarden
- backup - backup

15
ansible/os-update.yml Normal file
View File

@@ -0,0 +1,15 @@
---
- name: OS updates
hosts: infra
become: true
tasks:
- name: Update apt cache and upgrade packages
apt:
update_cache: true
upgrade: safe
- name: Remove unused packages
apt:
autoremove: true
purge: true

View File

@@ -5,14 +5,14 @@ runner:
file: /data/.runner file: /data/.runner
capacity: 2 # max concurrent jobs — lower if droplet is under load capacity: 2 # max concurrent jobs — lower if droplet is under load
labels: labels:
- "ubuntu-latest:docker://node:20" - "ubuntu-latest:docker://catthehacker/ubuntu:act-latest"
- "ubuntu-22.04:docker://node:20" - "ubuntu-22.04:docker://catthehacker/ubuntu:act-22.04"
cache: cache:
enabled: true enabled: true
dir: /data/cache dir: /data/cache
container: container:
network: bridge network: host
force_pull: false # reuse cached images to speed up builds force_pull: false # reuse cached images to speed up builds
docker_sock_path: /var/run/docker.sock docker_sock_path: /var/run/docker.sock

7
ansible/runner.yml Normal file
View File

@@ -0,0 +1,7 @@
---
- name: Set up Gitea runner
hosts: infra
become: true
roles:
- gitea-runner

7
ansible/vaultwarden.yml Normal file
View File

@@ -0,0 +1,7 @@
---
- name: Set up Vaultwarden
hosts: infra
become: true
roles:
- vaultwarden

View File

@@ -6,6 +6,7 @@ RUN apk add --no-cache \
curl \ curl \
git \ git \
nodejs \ nodejs \
npm \
openssh-client \ openssh-client \
python3 \ python3 \
py3-pip \ py3-pip \
@@ -16,7 +17,11 @@ RUN curl -fsSL https://releases.hashicorp.com/terraform/1.10.5/terraform_1.10.5_
&& unzip terraform.zip -d /usr/local/bin \ && unzip terraform.zip -d /usr/local/bin \
&& rm terraform.zip && rm terraform.zip
# Bun
ENV BUN_INSTALL="/usr/local"
RUN curl -fsSL https://bun.sh/install | bash
# Ansible collections # Ansible collections
RUN ansible-galaxy collection install community.docker RUN ansible-galaxy collection install community.docker
RUN terraform --version && ansible --version RUN terraform --version && ansible --version && bun --version

56
terraform/doks.tf Normal file
View File

@@ -0,0 +1,56 @@
# ─── DOKS Cluster ─────────────────────────────────────────────────────────────
resource "digitalocean_kubernetes_cluster" "main" {
name = "lunarfront"
region = var.region
version = var.k8s_version
# Required by provider but managed externally — do not let Terraform recreate
node_pool {
name = "workers"
size = "s-2vcpu-4gb"
node_count = 0
}
lifecycle {
ignore_changes = [node_pool]
}
tags = ["lunarfront", "k8s"]
}
# Customer pool — auto-scales for customer app instances
resource "digitalocean_kubernetes_node_pool" "system" {
cluster_id = digitalocean_kubernetes_cluster.main.id
name = "system"
size = var.k8s_system_node_size
node_count = 2
labels = {
role = "system"
}
}
resource "digitalocean_kubernetes_node_pool" "customers" {
cluster_id = digitalocean_kubernetes_cluster.main.id
name = "customers"
size = var.k8s_customer_node_size
min_nodes = 0
max_nodes = var.k8s_max_customer_nodes
auto_scale = true
labels = {
role = "customer"
}
}
# ─── 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
}

View File

@@ -39,135 +39,24 @@ data "cloudflare_zone" "main" {
name = var.domain name = var.domain
} }
# ─── Droplet ──────────────────────────────────────────────────────────────────
data "digitalocean_ssh_key" "main" {
name = var.ssh_key_name
}
resource "digitalocean_droplet" "gitea" {
name = "gitea"
region = var.region
size = var.droplet_size
image = "ubuntu-24-04-x64"
ssh_keys = [data.digitalocean_ssh_key.main.id]
tags = ["infra", "gitea"]
}
# ─── Firewall ─────────────────────────────────────────────────────────────────
# Cloudflare IPv4 ranges — http://www.cloudflare.com/ips-v4
locals {
cloudflare_ipv4 = [
"173.245.48.0/20",
"103.21.244.0/22",
"103.22.200.0/22",
"103.31.4.0/22",
"141.101.64.0/18",
"108.162.192.0/18",
"190.93.240.0/20",
"188.114.96.0/20",
"197.234.240.0/22",
"198.41.128.0/17",
"162.158.0.0/15",
"104.16.0.0/13",
"104.24.0.0/14",
"172.64.0.0/13",
"131.0.72.0/22",
]
cloudflare_ipv6 = [
"2400:cb00::/32",
"2606:4700::/32",
"2803:f800::/32",
"2405:b500::/32",
"2405:8100::/32",
"2a06:98c0::/29",
"2c0f:f248::/32",
]
}
resource "digitalocean_firewall" "gitea" {
name = "gitea-firewall"
droplet_ids = [digitalocean_droplet.gitea.id]
# SSH — your IP only
inbound_rule {
protocol = "tcp"
port_range = "22"
source_addresses = ["${var.admin_ip}/32"]
}
# HTTP — Cloudflare IPs only (web UI)
inbound_rule {
protocol = "tcp"
port_range = "80"
source_addresses = concat(local.cloudflare_ipv4, local.cloudflare_ipv6)
}
# HTTPS — Cloudflare IPs for proxied domains + all IPs for registry (DNS-only)
inbound_rule {
protocol = "tcp"
port_range = "443"
source_addresses = ["0.0.0.0/0", "::/0"]
}
# Gitea SSH for git push/pull — your IP only
inbound_rule {
protocol = "tcp"
port_range = "2222"
source_addresses = ["${var.admin_ip}/32"]
}
outbound_rule {
protocol = "tcp"
port_range = "1-65535"
destination_addresses = ["0.0.0.0/0", "::/0"]
}
outbound_rule {
protocol = "udp"
port_range = "1-65535"
destination_addresses = ["0.0.0.0/0", "::/0"]
}
}
# ─── DNS records ────────────────────────────────────────────────────────────── # ─── DNS records ──────────────────────────────────────────────────────────────
# Proxied through Cloudflare — web UI
resource "cloudflare_record" "gitea" { resource "cloudflare_record" "gitea" {
zone_id = data.cloudflare_zone.main.id zone_id = data.cloudflare_zone.main.id
name = "git" name = "git"
type = "A" type = "A"
value = digitalocean_droplet.gitea.ipv4_address content = var.cluster_lb_ip
proxied = true proxied = false
ttl = 1 ttl = 3600
} }
resource "cloudflare_record" "vaultwarden" { # DNS only — no Cloudflare proxy, for SSH git access
zone_id = data.cloudflare_zone.main.id
name = "vault"
type = "A"
value = digitalocean_droplet.gitea.ipv4_address
proxied = true
ttl = 1
}
# DNS only — no Cloudflare proxy, for direct SSH/git access
resource "cloudflare_record" "git_ssh" { resource "cloudflare_record" "git_ssh" {
zone_id = data.cloudflare_zone.main.id zone_id = data.cloudflare_zone.main.id
name = "git-ssh" name = "git-ssh"
type = "A" type = "A"
value = digitalocean_droplet.gitea.ipv4_address content = var.cluster_lb_ip
proxied = false
ttl = 3600
}
# DNS only — no Cloudflare proxy, for container registry (no 100MB upload limit)
resource "cloudflare_record" "registry" {
zone_id = data.cloudflare_zone.main.id
name = "registry"
type = "A"
value = digitalocean_droplet.gitea.ipv4_address
proxied = false proxied = false
ttl = 3600 ttl = 3600
} }

View File

@@ -1,4 +1,24 @@
output "gitea_ip" { output "k8s_cluster_id" {
description = "Public IP of the Gitea droplet" description = "DOKS cluster ID"
value = digitalocean_droplet.gitea.ipv4_address 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
} }

23
terraform/postgres.tf Normal file
View File

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

14
terraform/project.tf Normal file
View File

@@ -0,0 +1,14 @@
# ─── DO Project assignment ────────────────────────────────────────────────────
data "digitalocean_project" "main" {
name = "lunarfront-infra"
}
resource "digitalocean_project_resources" "main" {
project = data.digitalocean_project.main.id
resources = [
digitalocean_kubernetes_cluster.main.urn,
digitalocean_database_cluster.postgres.urn,
digitalocean_database_cluster.redis.urn,
]
}

22
terraform/redis.tf Normal file
View File

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

View File

@@ -1,6 +1,7 @@
do_token = "your-digitalocean-api-token" do_token = "your-digitalocean-api-token"
ssh_key_name = "your-key-name-in-do" ssh_key_name = "your-key-name-in-do"
region = "nyc3" region = "nyc3"
droplet_size = "s-1vcpu-2gb" droplet_size = "s-2vcpu-4gb"
cloudflare_api_token = "your-cloudflare-api-token" cloudflare_api_token = "your-cloudflare-api-token"
domain = "example.com" domain = "example.com"
cluster_lb_ip = "your-ingress-lb-ip"

View File

@@ -18,7 +18,7 @@ variable "region" {
variable "droplet_size" { variable "droplet_size" {
description = "Droplet size slug" description = "Droplet size slug"
type = string type = string
default = "s-1vcpu-2gb" default = "s-2vcpu-4gb"
} }
variable "cloudflare_api_token" { variable "cloudflare_api_token" {
@@ -35,4 +35,51 @@ variable "domain" {
variable "admin_ip" { variable "admin_ip" {
description = "Your public IP for SSH and git access (without /32)" description = "Your public IP for SSH and git access (without /32)"
type = string 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_system_node_size" {
description = "System node pool droplet size (infra workloads)"
type = string
default = "s-2vcpu-4gb"
}
variable "k8s_customer_node_size" {
description = "Customer node pool droplet size (app instances)"
type = string
default = "s-1vcpu-2gb"
}
variable "k8s_max_customer_nodes" {
description = "Maximum nodes in the customer pool"
type = number
default = 10
}
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"
} }

24
terraform/waf.tf Normal file
View File

@@ -0,0 +1,24 @@
# ─── 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 {
"vault.${var.domain}"
"argocd.${var.domain}"
}
and not ip.src eq ${var.admin_ip}
)
EOT
}
}