Add Terraform configuration for KrustyPlanet VPS

- Configure Hetzner Cloud server (CPX22, Ubuntu 24.04)
- Manage floating IP (87.99.133.81)
- Firewall rules for HTTP, HTTPS, SSH
- Persistent volume (40GB)
- nginx reverse proxy with SSL (Let's Encrypt)
- contact-api (Node.js email backend)
- Fix CORS issue: removed proxy_set_header Origin ://;
- Include cloud-init for initial provisioning

This Terraform config will manage the VPS going forward.
This commit is contained in:
Jezza Hehn 2026-04-13 22:14:11 +00:00
parent c5c14d2fad
commit b1fde182bf
6 changed files with 481 additions and 0 deletions

16
terraform/.gitignore vendored Normal file
View file

@ -0,0 +1,16 @@
# Terraform
*.tfstate
*.tfstate.*
.terraform/
*.tfvars
!terraform.tfvars.example
# Variables
*.tfvars.backup
# Local state
.terraform.lock.hcl
# SSH keys
*.pem
*.key

53
terraform/README.md Normal file
View file

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

View file

@ -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!"

158
terraform/main.tf Normal file
View file

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

16
terraform/provider.tf Normal file
View file

@ -0,0 +1,16 @@
###############################################################################
# provider.tf
###############################################################################
terraform {
required_providers {
hcloud = {
source = "hetznercloud/hcloud"
version = "~> 1.47"
}
}
}
provider "hcloud" {
token = var.hcloud_token
}

69
terraform/variables.tf Normal file
View file

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