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