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:
parent
c5c14d2fad
commit
b1fde182bf
6 changed files with 481 additions and 0 deletions
16
terraform/.gitignore
vendored
Normal file
16
terraform/.gitignore
vendored
Normal 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
53
terraform/README.md
Normal 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
|
||||||
169
terraform/cloud-init.yaml.tpl
Normal file
169
terraform/cloud-init.yaml.tpl
Normal 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
158
terraform/main.tf
Normal 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
16
terraform/provider.tf
Normal 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
69
terraform/variables.tf
Normal 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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue