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"
+}