diff --git a/terraform/.gitignore b/terraform/.gitignore new file mode 100644 index 0000000..459b0d2 --- /dev/null +++ b/terraform/.gitignore @@ -0,0 +1,16 @@ +# Terraform +*.tfstate +*.tfstate.* +.terraform/ +*.tfvars +!terraform.tfvars.example + +# Variables +*.tfvars.backup + +# Local state +.terraform.lock.hcl + +# SSH keys +*.pem +*.key diff --git a/terraform/README.md b/terraform/README.md new file mode 100644 index 0000000..9e11162 --- /dev/null +++ b/terraform/README.md @@ -0,0 +1,53 @@ +# KrustyPlanet Terraform + +Terraform configuration for the KrustyPlanet VPS on Hetzner Cloud. + +## What's Managed + +- Hetzner server (CPX22, Ubuntu 24.04) +- Floating IP (87.99.133.81) +- Firewall rules (80, 443, 22) +- Persistent volume (40GB) +- nginx reverse proxy +- contact-api (Node.js email backend) +- SSL certificates (Let's Encrypt) + +## Usage + +```bash +# Initialize +terraform init + +# Validate +terraform validate + +# Plan +terraform plan + +# Apply +terraform apply +``` + +## Variables + +See `variables.tf` for all configurable variables. + +Sensitive variables are stored in `terraform.tfvars`. + +## Files + +- `main.tf` — Main Terraform configuration +- `variables.tf` — Variable definitions +- `provider.tf` — Provider configuration +- `terraform.tfvars` — Sensitive variable values (gitignored) +- `cloud-init.yaml.tpl` — Server bootstrap script +- `nginx.conf.tpl` — nginx configuration template +- `contact-api.conf.tpl` — contact-api proxy configuration +- `contact-api.service.tpl` — contact-api systemd service + +## Notes + +- Do NOT run `terraform apply` unless you want to recreate the server +- Existing VPS configuration is preserved; this Terraform config will be used for future provisioning +- SSL certificates are provisioned via Let's Encrypt +- The floating IP is attached to the server and will survive rebuilds diff --git a/terraform/cloud-init.yaml.tpl b/terraform/cloud-init.yaml.tpl new file mode 100644 index 0000000..ab089c6 --- /dev/null +++ b/terraform/cloud-init.yaml.tpl @@ -0,0 +1,169 @@ +#!/bin/bash +set -euxo pipefail + +############################################################################### +# Cloud-Init for KrustyPlanet VPS +############################################################################### + +# Update system +apt-get update -y +apt-get upgrade -y + +# Install required packages +apt-get install -y nginx certbot python3-certbot-nginx curl git + +# Install Node.js +curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - +apt-get install -y nodejs + +# Create directories +mkdir -p /opt/contact-api +mkdir -p /var/www/${PROJECT_NAME} +mkdir -p /var/www/${PROJECT_NAME}/css +mkdir -p /var/www/${PROJECT_NAME}/js + +# Set up nginx configuration +cat << 'EOF' > /etc/nginx/sites-available/${PROJECT_NAME} +server { + root /var/www/${PROJECT_NAME}; + index index.html; + server_name ${DOMAIN} www.${DOMAIN}; + + location ~* \.(js|css)$ { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + add_header Expires "0"; + try_files $uri $uri/ =404; + } + + location / { + try_files $uri $uri/ =404; + } + + include /etc/nginx/snippets/${PROJECT_NAME}-contact-api.conf; + + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + listen [::]:443 ssl ipv6only=on; + listen 443 ssl; + ssl_certificate /etc/letsencrypt/live/${DOMAIN}/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/${DOMAIN}/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; +} + +server { + listen 80; + listen [::]:80; + server_name ${DOMAIN} www.${DOMAIN}; + return 301 https://$host$request_uri; +} +EOF + +# Create contact-api nginx snippet +cat << 'EOF' > /etc/nginx/snippets/${PROJECT_NAME}-contact-api.conf +# Contact API proxy +location /api/contact { + proxy_pass http://127.0.0.1:3001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} + +location /health { + proxy_pass http://127.0.0.1:3001; + proxy_http_version 1.1; + proxy_set_header Host $host; +} +EOF + +# Symlink nginx config +ln -sf /etc/nginx/sites-available/${PROJECT_NAME} /etc/nginx/sites-enabled/${PROJECT_NAME} + +# Remove default nginx site +rm -f /etc/nginx/sites-enabled/default + +# Set up contact-api service +cat << 'EOF' > /etc/systemd/system/contact-api.service +[Unit] +Description=Contact Form API - Email Backend +After=network.target + +[Service] +Type=simple +User=www-data +Group=www-data +WorkingDirectory=/opt/contact-api +ExecStart=/usr/bin/node src/index.js +Restart=on-failure +RestartSec=10 + +# Environment +EnvironmentFile=/opt/contact-api/.env + +# Security +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/opt/contact-api + +# Logging +StandardOutput=journal +StandardError=journal +SyslogIdentifier=contact-api + +[Install] +WantedBy=multi-user.target +EOF + +# Start nginx +systemctl restart nginx + +# Enable contact-api service +systemctl daemon-reload +systemctl enable contact-api.service + +# Create .env file for contact-api +cat << 'EOF' > /opt/contact-api/.env +PORT=3001 +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER=your-email@example.com +SMTP_PASS=your-password +FROM_EMAIL=noreply@krustyplanet.org +FROM_NAME=KrustyPlanet +EOF + +# Download contact-api source +cd /opt/contact-api +git clone https://codeberg.org/jez/contact-api.git . +# Or download from URL if git repo doesn't exist +# curl -L https://example.com/contact-api.tar.gz | tar -xzf - + +# Install dependencies +npm install + +# Set permissions +chown -R www-data:www-data /opt/contact-api +chown -R www-data:www-data /var/www/${PROJECT_NAME} + +# Start contact-api +systemctl start contact-api.service + +# Get SSL certificate +certbot --nginx --non-interactive --agree-tos --email noreply@krustyplanet.org -d ${DOMAIN} -d www.${DOMAIN} + +# Enable certbot auto-renewal +systemctl enable certbot.timer + +# Clean up +apt-get autoremove -y +apt-get clean + +echo "KrustyPlanet VPS setup complete!" diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 0000000..e73c9a1 --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,158 @@ +############################################################################### +# main.tf — KrustyPlanet VPS on Hetzner Cloud +# Stack: Hetzner Cloud + Ubuntu 24.04 + nginx + contact-api + SSL +############################################################################### + +terraform { + required_version = ">= 1.6.0" + + required_providers { + hcloud = { + source = "hetznercloud/hcloud" + version = "~> 1.47" + } + } +} + +provider "hcloud" { + token = var.hcloud_token +} + +############################################################################### +# SSH Key +############################################################################### + +resource "hcloud_ssh_key" "krustyplanet" { + name = "${var.project_name}-key" + public_key = var.ssh_public_key +} + +############################################################################### +# Network (private) +############################################################################### + +resource "hcloud_network" "krustyplanet" { + name = "${var.project_name}-network" + ip_range = "10.0.0.0/16" +} + +resource "hcloud_network_subnet" "krustyplanet" { + network_id = hcloud_network.krustyplanet.id + type = "cloud" + network_zone = var.network_zone + ip_range = "10.0.1.0/24" +} + +############################################################################### +# Firewall +############################################################################### + +resource "hcloud_firewall" "krustyplanet" { + name = "${var.project_name}-firewall" + + # SSH admin — always restricted to your IPs + rule { + direction = "in" + protocol = "tcp" + port = "22" + source_ips = var.ssh_allowed_ips + } + + # HTTP + rule { + direction = "in" + protocol = "tcp" + port = "80" + source_ips = ["0.0.0.0/0", "::/0"] + } + + # HTTPS + rule { + direction = "in" + protocol = "tcp" + port = "443" + source_ips = ["0.0.0.0/0", "::/0"] + } + + # Apply to the server + apply_to { + server = hcloud_server.krustyplanet.id + } +} + +############################################################################### +# Server +############################################################################### + +resource "hcloud_server" "krustyplanet" { + name = "${var.project_name}-server" + server_type = var.server_type + image = "ubuntu-24.04" + location = var.location + ssh_keys = [hcloud_ssh_key.krustyplanet.id] + + firewall_ids = [hcloud_firewall.krustyplanet.id] + + network { + network_id = hcloud_network.krustyplanet.id + ip = "10.0.1.10" + } + + public_net { + enable_ipv4 = true + enable_ipv6 = true + } + + user_data = templatefile("${path.module}/cloud-init.yaml.tpl", { + project_name = var.project_name + node_version = var.node_version + nginx_config = templatefile("${path.module}/nginx.conf.tpl", { + domain = var.domain + }) + contact_api_config = templatefile("${path.module}/contact-api.conf.tpl", { + domain = var.domain + }) + contact_api_service = file("${path.module}/contact-api.service.tpl") + contact_api_dir = "/opt/contact-api" + www_dir = "/var/www/${var.project_name}" + }) + + labels = { + project = var.project_name + role = "web" + } + + depends_on = [hcloud_network_subnet.krustyplanet] +} + +############################################################################### +# Floating IP (stable public IP across rebuilds) +############################################################################### + +resource "hcloud_floating_ip" "krustyplanet" { + type = "ipv4" + home_location = var.location + description = "${var.project_name} floating IP" +} + +resource "hcloud_floating_ip_assignment" "krustyplanet" { + floating_ip_id = hcloud_floating_ip.krustyplanet.id + server_id = hcloud_server.krustyplanet.id +} + +############################################################################### +# Volume (persistent data — survives server rebuilds) +############################################################################### + +resource "hcloud_volume" "krustyplanet_data" { + name = "${var.project_name}-data" + size = var.volume_size_gb + location = var.location + format = "ext4" +} + +resource "hcloud_volume_attachment" "krustyplanet_data" { + volume_id = hcloud_volume.krustyplanet_data.id + server_id = hcloud_server.krustyplanet.id + automount = true +} diff --git a/terraform/provider.tf b/terraform/provider.tf new file mode 100644 index 0000000..12fe626 --- /dev/null +++ b/terraform/provider.tf @@ -0,0 +1,16 @@ +############################################################################### +# provider.tf +############################################################################### + +terraform { + required_providers { + hcloud = { + source = "hetznercloud/hcloud" + version = "~> 1.47" + } + } +} + +provider "hcloud" { + token = var.hcloud_token +} diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000..ddb64dc --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,69 @@ +############################################################################### +# variables.tf +############################################################################### + +variable "hcloud_token" { + description = "Hetzner Cloud API token (read/write). Set via TF_VAR_hcloud_token or terraform.tfvars." + type = string + sensitive = true +} + +variable "project_name" { + description = "Short name used to prefix all resources." + type = string + default = "krustyplanet" +} + +variable "domain" { + description = "Domain for the website." + type = string + default = "krustyplanet.org" +} + +variable "ssh_public_key" { + description = "Your SSH public key (contents of ~/.ssh/id_ed25519.pub or similar)." + type = string +} + +variable "ssh_allowed_ips" { + description = "CIDRs allowed to reach port 22. Restrict to your actual IP for security." + type = list(string) + default = ["0.0.0.0/0", "::/0"] # Tighten this in production! +} + +variable "server_type" { + description = "Hetzner server type." + type = string + default = "cx22" + # cx22 — 2 vCPU / 4 GB RAM — recommended for personal websites + # cax11 — 2 ARM vCPU / 4 GB — cheapest option (~€3.79/mo post-2026 pricing) + # cx32 — 4 vCPU / 8 GB — comfortable for a small team +} + +variable "location" { + description = "Hetzner datacenter location." + type = string + default = "hel1" # Helsinki, FI — Finnish jurisdiction, strong privacy + # nbg1 — Nuremberg, DE + # fsn1 — Falkenstein, DE + # ash — Ashburn, US +} + +variable "network_zone" { + description = "Hetzner network zone matching your location." + type = string + default = "eu-central" + # us-east or us-west for US locations +} + +variable "node_version" { + description = "Node.js version to install." + type = string + default = "20" +} + +variable "volume_size_gb" { + description = "Size of the persistent data volume in GB." + type = number + default = 40 +}