diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml new file mode 100644 index 0000000..f52235d --- /dev/null +++ b/.github/workflows/terraform.yml @@ -0,0 +1,134 @@ +name: Terraform + +on: + push: + branches: [main] + paths: [tf/**] + pull_request: + branches: [main] + paths: [tf/**] + +concurrency: + group: terraform + cancel-in-progress: false + +env: + AWS_ACCESS_KEY_ID: ${{ secrets.WASABI_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.WASABI_SECRET_KEY }} + +jobs: + terraform-plan: + name: Terraform Plan + runs-on: ubuntu-latest + outputs: + tfplanExitCode: ${{ steps.tf-plan.outputs.exitcode }} + + steps: + - uses: actions/checkout@v4 + + - uses: hashicorp/setup-terraform@v3 + with: + terraform_wrapper: false + + - name: Terraform Init + working-directory: ./tf + run: terraform init + + - name: Terraform Format + working-directory: ./tf + run: terraform fmt -check + + - name: Terraform Plan + id: tf-plan + working-directory: ./tf + env: + TF_VAR_hcloud_token: ${{ secrets.HCLOUD_TOKEN }} + TF_VAR_cloudflare_api_token: ${{ secrets.CLOUDFLARE_API_TOKEN }} + TF_VAR_cloudflare_zone_id: ${{ secrets.CLOUDFLARE_ZONE_ID }} + TF_VAR_ssh_public_key: ${{ secrets.SSH_PUBLIC_KEY }} + run: | + export exitcode=0 + terraform plan \ + -lock-timeout=5m \ + -detailed-exitcode \ + -no-color \ + -out tfplan \ + || export exitcode=$? + + echo "exitcode=$exitcode" >> "$GITHUB_OUTPUT" + + if [ "$exitcode" -eq 1 ]; then + echo "Terraform Plan Failed!" + exit 1 + else + exit 0 + fi + + - uses: actions/upload-artifact@v4 + with: + name: tfplan + path: tf/tfplan + + - name: Create Plan Summary + id: tf-plan-string + working-directory: ./tf + run: | + TERRAFORM_PLAN=$(terraform show -no-color tfplan) + + delimiter="$(openssl rand -hex 8)" + { + echo "summary<<${delimiter}" + echo "## Terraform Plan Output" + echo "
Click to expand" + echo "" + echo '```terraform' + echo "$TERRAFORM_PLAN" + echo '```' + echo "
" + echo "${delimiter}" + } >> "$GITHUB_OUTPUT" + + - name: Publish Plan to Summary + env: + SUMMARY: ${{ steps.tf-plan-string.outputs.summary }} + run: echo "$SUMMARY" >> "$GITHUB_STEP_SUMMARY" + + - name: Comment Plan on PR + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 + env: + SUMMARY: "${{ steps.tf-plan-string.outputs.summary }}" + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const body = process.env.SUMMARY; + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: body + }) + + terraform-apply: + name: Terraform Apply + if: github.ref == 'refs/heads/main' && needs.terraform-plan.outputs.tfplanExitCode == 2 + runs-on: ubuntu-latest + needs: [terraform-plan] + + steps: + - uses: actions/checkout@v4 + + - uses: hashicorp/setup-terraform@v3 + + - name: Terraform Init + working-directory: ./tf + run: terraform init + + - uses: actions/download-artifact@v4 + with: + name: tfplan + path: tf + + - name: Terraform Apply + working-directory: ./tf + run: terraform apply -auto-approve -lock-timeout=5m tfplan diff --git a/config/mysql/99-akatsuki.cnf b/config/mysql/99-akatsuki.cnf index acb942c..840a75c 100644 --- a/config/mysql/99-akatsuki.cnf +++ b/config/mysql/99-akatsuki.cnf @@ -1,29 +1,29 @@ [mysqld] # Networking bind-address = 0.0.0.0 -max_connections = 1000 +max_connections = 500 -# InnoDB - 40GB buffer pool for 64GB RAM system -innodb_buffer_pool_size = 42949672960 -innodb_buffer_pool_instances = 8 +# InnoDB - 20GB buffer pool for 32GB RAM system (CX53 VPS) +innodb_buffer_pool_size = 21474836480 +innodb_buffer_pool_instances = 4 innodb_flush_log_at_trx_commit = 1 innodb_flush_method = O_DIRECT # Authentication compatibility (services use mysql_native_password) default_authentication_plugin = mysql_native_password -# I/O tuning for NVMe -innodb_io_capacity = 10000 -innodb_io_capacity_max = 20000 -innodb_read_io_threads = 8 -innodb_write_io_threads = 8 +# I/O tuning for VPS NVMe (~18K IOPS) +innodb_io_capacity = 2000 +innodb_io_capacity_max = 4000 +innodb_read_io_threads = 4 +innodb_write_io_threads = 4 # Redo log (replaces deprecated innodb_log_file_size in 8.0.30+) -innodb_redo_log_capacity = 2G +innodb_redo_log_capacity = 1G # Temp tables -tmp_table_size = 256M -max_heap_table_size = 256M +tmp_table_size = 128M +max_heap_table_size = 128M # Binary logging log_bin = /var/log/mysql/mysql-bin.log diff --git a/setup.sh b/setup.sh index 7aa4967..38d0235 100644 --- a/setup.sh +++ b/setup.sh @@ -1,9 +1,16 @@ #!/usr/bin/env bash set -euo pipefail -# Hetzner AX42-U server bootstrap script +# Hetzner Cloud CX53 server bootstrap script # Installs and configures all services for Akatsuki production +echo "=== Creating swap file (4GB) ===" +fallocate -l 4G /swapfile +chmod 600 /swapfile +mkswap /swapfile +swapon /swapfile +echo '/swapfile none swap sw 0 0' >> /etc/fstab + echo "=== Installing system packages ===" apt-get update DEBIAN_FRONTEND=noninteractive apt-get install -y \ diff --git a/tf/.gitignore b/tf/.gitignore new file mode 100644 index 0000000..1d1380f --- /dev/null +++ b/tf/.gitignore @@ -0,0 +1,5 @@ +.terraform/ +*.tfstate +*.tfstate.backup +terraform.tfvars +.terraform.lock.hcl diff --git a/tf/cloudflare.tf b/tf/cloudflare.tf new file mode 100644 index 0000000..9895e56 --- /dev/null +++ b/tf/cloudflare.tf @@ -0,0 +1,112 @@ +# Phase 2: Uncomment after data migration is complete and records are imported. +# +# Import existing records first to avoid duplicates: +# terraform import cloudflare_record.apex +# terraform import 'cloudflare_record.cname["a"]' +# ... (for each CNAME and MX record) +# +# Get record IDs with: +# curl -s -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ +# "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records" | jq '.result[]' + +# resource "cloudflare_record" "apex" { +# zone_id = var.cloudflare_zone_id +# name = "akatsuki.gg" +# type = "A" +# value = hcloud_server.production.ipv4_address +# proxied = true +# ttl = 1 +# } +# +# locals { +# cname_subdomains = [ +# "a", +# "air_conditioning", +# "assets", +# "b", +# "beatmaps", +# "c", +# "difficulty", +# "old", +# "osu", +# "payments", +# "performance", +# "relax", +# "rework", +# "reworks", +# "s", +# "vault", +# "www", +# ] +# } +# +# resource "cloudflare_record" "cname" { +# for_each = toset(local.cname_subdomains) +# +# zone_id = var.cloudflare_zone_id +# name = each.value +# type = "CNAME" +# value = "akatsuki.gg" +# proxied = true +# ttl = 1 +# } +# +# resource "cloudflare_record" "mx_primary" { +# zone_id = var.cloudflare_zone_id +# name = "akatsuki.gg" +# type = "MX" +# value = "aspmx.l.google.com" +# priority = 1 +# proxied = false +# ttl = 1 +# } +# +# resource "cloudflare_record" "mx_alt1" { +# zone_id = var.cloudflare_zone_id +# name = "akatsuki.gg" +# type = "MX" +# value = "alt1.aspmx.l.google.com" +# priority = 5 +# proxied = false +# ttl = 1 +# } +# +# resource "cloudflare_record" "mx_alt2" { +# zone_id = var.cloudflare_zone_id +# name = "akatsuki.gg" +# type = "MX" +# value = "alt2.aspmx.l.google.com" +# priority = 5 +# proxied = false +# ttl = 1 +# } +# +# resource "cloudflare_record" "mx_alt3" { +# zone_id = var.cloudflare_zone_id +# name = "akatsuki.gg" +# type = "MX" +# value = "alt3.aspmx.l.google.com" +# priority = 10 +# proxied = false +# ttl = 1 +# } +# +# resource "cloudflare_record" "mx_alt4" { +# zone_id = var.cloudflare_zone_id +# name = "akatsuki.gg" +# type = "MX" +# value = "alt4.aspmx.l.google.com" +# priority = 10 +# proxied = false +# ttl = 1 +# } +# +# resource "cloudflare_record" "mx_verification" { +# zone_id = var.cloudflare_zone_id +# name = "akatsuki.gg" +# type = "MX" +# value = "3h5azgn53tixa3a2yxyqkgyethll22hdjl7jj5jshsfw2wpalkhq.mx-verification.google.com" +# priority = 15 +# proxied = false +# ttl = 1 +# } diff --git a/tf/outputs.tf b/tf/outputs.tf new file mode 100644 index 0000000..4e7fa5b --- /dev/null +++ b/tf/outputs.tf @@ -0,0 +1,9 @@ +output "server_ip" { + description = "Public IPv4 address of the production server" + value = hcloud_server.production.ipv4_address +} + +output "server_status" { + description = "Server status" + value = hcloud_server.production.status +} diff --git a/tf/provider.tf b/tf/provider.tf new file mode 100644 index 0000000..e06fc38 --- /dev/null +++ b/tf/provider.tf @@ -0,0 +1,37 @@ +terraform { + backend "s3" { + bucket = "akatsuki-terraform-state" + key = "server-infra/terraform.tfstate" + region = "ca-central-1" + + endpoints = { + s3 = "https://s3.ca-central-1.wasabisys.com" + } + + # Wasabi doesn't support these S3 features + skip_credentials_validation = true + skip_metadata_api_check = true + skip_requesting_account_id = true + skip_region_validation = true + use_path_style = true + } + + required_providers { + hcloud = { + source = "hetznercloud/hcloud" + version = "~> 1.49" + } + cloudflare = { + source = "cloudflare/cloudflare" + version = "~> 4" + } + } +} + +provider "hcloud" { + token = var.hcloud_token +} + +provider "cloudflare" { + api_token = var.cloudflare_api_token +} diff --git a/tf/server.tf b/tf/server.tf new file mode 100644 index 0000000..4b2dabc --- /dev/null +++ b/tf/server.tf @@ -0,0 +1,74 @@ +resource "hcloud_ssh_key" "deploy" { + name = "akatsuki-deploy" + public_key = var.ssh_public_key +} + +resource "hcloud_server" "production" { + name = "akatsuki-production" + server_type = "cx53" + location = "fsn1" + image = "ubuntu-24.04" + ssh_keys = [hcloud_ssh_key.deploy.id] + + firewall_ids = [hcloud_firewall.production.id] + + labels = { + environment = "production" + project = "akatsuki" + } +} + +resource "hcloud_firewall" "production" { + name = "akatsuki-production" + + # SSH - Tailscale only + rule { + description = "SSH via Tailscale" + direction = "in" + protocol = "tcp" + port = "22" + source_ips = [var.tailscale_ipv4_range] + } + + # HTTP - Cloudflare only + dynamic "rule" { + for_each = [var.cloudflare_ipv4_ranges] + content { + description = "HTTP from Cloudflare" + direction = "in" + protocol = "tcp" + port = "80" + source_ips = rule.value + } + } + + # HTTPS - Cloudflare only + dynamic "rule" { + for_each = [var.cloudflare_ipv4_ranges] + content { + description = "HTTPS from Cloudflare" + direction = "in" + protocol = "tcp" + port = "443" + source_ips = rule.value + } + } + + # Vault UI - Tailscale only + rule { + description = "Vault via Tailscale" + direction = "in" + protocol = "tcp" + port = "8200" + source_ips = [var.tailscale_ipv4_range] + } + + # Grafana - Tailscale only + rule { + description = "Grafana via Tailscale" + direction = "in" + protocol = "tcp" + port = "3001" + source_ips = [var.tailscale_ipv4_range] + } +} diff --git a/tf/terraform.tfvars.example b/tf/terraform.tfvars.example new file mode 100644 index 0000000..2cb748c --- /dev/null +++ b/tf/terraform.tfvars.example @@ -0,0 +1,4 @@ +hcloud_token = "your-hetzner-cloud-api-token" +cloudflare_api_token = "your-cloudflare-api-token" +cloudflare_zone_id = "your-cloudflare-zone-id" +ssh_public_key = "ssh-ed25519 AAAA... user@host" diff --git a/tf/variables.tf b/tf/variables.tf new file mode 100644 index 0000000..98d54f7 --- /dev/null +++ b/tf/variables.tf @@ -0,0 +1,48 @@ +variable "hcloud_token" { + description = "Hetzner Cloud API token" + sensitive = true +} + +variable "cloudflare_api_token" { + description = "Cloudflare API token" + sensitive = true +} + +variable "cloudflare_zone_id" { + description = "Cloudflare zone ID for akatsuki.gg" + type = string +} + +variable "ssh_public_key" { + description = "SSH public key content for server access" + type = string +} + +# Cloudflare IPv4 ranges for firewall rules +# https://www.cloudflare.com/ips/ +variable "cloudflare_ipv4_ranges" { + type = list(string) + default = [ + "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", + ] +} + +# Tailscale CGNAT range for SSH access +variable "tailscale_ipv4_range" { + type = string + default = "100.64.0.0/10" +}