feat(deploy): aibook.fun deploy layer (terraform + ansible + docker on DO)

This commit is contained in:
dilonne
2026-09-02 10:57:09 +02:00
parent 89a3d76122
commit 8ef06cb3e7
21 changed files with 1106 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
# This file is maintained automatically by "tofu init".
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/digitalocean/digitalocean" {
version = "2.100.0"
constraints = "~> 2.36"
hashes = [
"h1:+1p5n5KeGjz/ulw/ZLXoaBkF+2UTgKqWqERw1nA2sJQ=",
"h1:79G5uDt1VHL6gWtSnK8lCOHZpo/jp/kSGA7FXtqQvX4=",
"h1:7r9AZrpc8pB6PRsJfmxSGLzIRmohmfpX6RrnA1FLbh4=",
"h1:9VsRnY7rmGiMFVG4ix3ry2fx8B8d7l9PyEpmGjjiW3I=",
"h1:LchVh6xhpXw1PEnl2mRnsLlR4dzLePw8M2vw4DuPk9U=",
"h1:OmnMLcKMFnZLI1CvTgQBod0awOpuVxsfB8dqBejfwE8=",
"h1:Q9BxzK1Tp8YeuCUXVogVfqTsVCrWUUJJdvpL7UA0W/s=",
"h1:R13HFu3vyxrd4aHyh/+p2YY0vWpPAFjafBRYVuIL5HE=",
"h1:RJH5t57bLx1b2+l+gS6RRHyakhEQjSKL/QYu6npCtbI=",
"h1:WQ2ClvMwBILHDzWWhAm3LBRra4HkSe9taUlUGSLBNWI=",
"h1:WbQfCVfC+GU0qPSoZKuoEllr3cMqwTC1Fx5DfbUjy/o=",
"h1:iwjK8zLBTHWHICCP/O+QFcyxH16bV4gKPFUbmu2foqc=",
"h1:pTFXceDc5intkLpVFVW3PKfwkF1CvmnWi3vYcF6SAQs=",
"h1:pc1iMmHPQA37PCkVWTRzuO/WgaoSGYtSBVP5paO6nK4=",
"h1:uDVCzJGtpV2DqyPWoYWjQpbEV48PxS6/U7/uiGfDDsU=",
"h1:yyBAhHZmejmdvjuvsF2gAwIZebqOmisQIT5dOQqQx9A=",
"zh:021dbdacf7dd636cb66990964737bc4a6402160b5301e06b6fdab03a814f7e96",
"zh:083ff822b22738eedc9eae29aa5d4c369b2de921e49fefa9858a4e7c6274b0c1",
"zh:10695b5f7dbbe8ba1a20ac6bea1ab8613b43d2b1f6f9b53369bed296b2dff4d2",
"zh:110124b9d07cd1f0f2831ad27dfeb5a010a070952ea9a9e675ca3cfe158a0ffa",
"zh:409a8b22ffb9a53caf57d0a16f5f8ac0a2fcd61b6b0bcd512a685ea26cd06047",
"zh:488e4b494c198c0696c85d2c73c66c40567ca2ad5230bbd95d8ffdead86e646d",
"zh:4dd5b6e8c1be5fb619812fe738d0b49e0d24731fb290c0ab5190b7bc60c70e66",
"zh:6717fa7f623ad4c9b4d99acceffd1372d272ff02d7c98259dd17ec7ef11b836c",
"zh:7a25445caebd2e7f6ab2381578ecaf31744159a7e4a16476f19112d2e50d8519",
"zh:86b3d7050ba04ec7e0a14dbee8c0540fca8f4377fb072f87c88a6fcd26388714",
"zh:c23f30a38930c283055b6f04acbcad06cb0dd3c71c172eb23bb239f50dd12823",
"zh:d1f9c5be7f7d5c71830357bdd9c0fb529185e9380092e1b792f969495c943790",
"zh:d222573e7de8f9ad1c5b87a6c44c2dc59947c3968b3e70b7d4c76263d6c8fe5c",
"zh:e1f2d606661435416c4d2b48db80244d0acef88a5ce81b5dcbd65ff2642b1b4d",
"zh:ea13fad19414586864557601c59e85188601e150c815ec1e71f8ed83f1942a50",
"zh:f795be71dc933aaec7145f93068da17f227783964d33a79de591a042aac7c3ca",
]
}
+25
View File
@@ -0,0 +1,25 @@
# aibook-fun - Terraform Backend Configuration
# State stored in DigitalOcean Spaces (S3-compatible) for team sharing, SSE-C
# encrypted at rest.
#
# Pass credentials via itofu.sh (preferred) or -backend-config flags:
# access_key / secret_key / sse_customer_key
# Backend config cannot reference Terraform variables — itofu.sh forwards them.
terraform {
backend "s3" {
endpoint = "https://atl1.digitaloceanspaces.com"
bucket = "weown-tools-state"
key = "aibook-fun/aibook-fun.tfstate"
region = "us-east-1"
encrypt = true
acl = "private"
# Required for DO Spaces (S3-compatible but not AWS)
skip_credentials_validation = true
skip_metadata_api_check = true
skip_requesting_account_id = true
skip_region_validation = true
skip_s3_checksum = true
}
}
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Run OpenTofu with TF_VAR_* creds injected from Infisical /tofu/.
# Usage: infisical login && INFISICAL_PROJECT_ID=<id> ./itofu.sh <init|plan|apply|...>
set -euo pipefail
INFISICAL_PROJECT_ID="${INFISICAL_PROJECT_ID:-}"
INFISICAL_ENV="${INFISICAL_ENV:-prod}"
TOFU_PATH="/tofu"
PLAN_FILE="${WEOWN_TOFU_PLAN:-plan.tfplan}"
: "${INFISICAL_PROJECT_ID:?INFISICAL_PROJECT_ID not set. Export it (e.g. from site.conf) before running itofu.sh.}"
command -v infisical >/dev/null 2>&1 || { echo "ERROR: infisical CLI not found." >&2; exit 1; }
command -v tofu >/dev/null 2>&1 || { echo "ERROR: tofu not found." >&2; exit 1; }
infisical export --projectId="$INFISICAL_PROJECT_ID" --env="$INFISICAL_ENV" --path="$TOFU_PATH" >/dev/null 2>&1 || {
echo "ERROR: cannot read $INFISICAL_PROJECT_ID $INFISICAL_ENV $TOFU_PATH — run 'infisical login' and check your access." >&2; exit 1; }
[ "$#" -ge 1 ] || { echo "usage: ./itofu.sh <init|plan|apply|output|destroy|...> [args]" >&2; exit 1; }
irun() { infisical run --projectId="$INFISICAL_PROJECT_ID" --env="$INFISICAL_ENV" --path="$TOFU_PATH" -- "$@"; }
# S3 backend reads AWS_* env on every state op — so ALL tofu calls go through trun.
# shellcheck disable=SC2016
trun() { irun bash -c 'export AWS_ACCESS_KEY_ID="$TF_VAR_spaces_access_key" AWS_SECRET_ACCESS_KEY="$TF_VAR_spaces_secret_key"; exec tofu "$@"' itofu "$@"; }
case "$1" in
init)
shift
# shellcheck disable=SC2016
irun bash -c 'export AWS_ACCESS_KEY_ID="$TF_VAR_spaces_access_key" AWS_SECRET_ACCESS_KEY="$TF_VAR_spaces_secret_key"; exec tofu init -reconfigure \
-backend-config="sse_customer_key=$TF_VAR_spaces_encryption_key" "$@"' itofu "$@"
;;
plan)
shift
trun plan -out="$PLAN_FILE" "$@"
;;
apply)
shift
if [ "$#" -eq 0 ] && [ -f "$PLAN_FILE" ]; then
set +e; trun apply "$PLAN_FILE"; rc=$?; set -e
rm -f "$PLAN_FILE"
exit "$rc"
fi
trun apply "$@"
;;
*)
trun "$@"
;;
esac
+113
View File
@@ -0,0 +1,113 @@
# aibook-fun - Main Infrastructure
# Managed by OpenTofu
resource "digitalocean_droplet" "web" {
name = "aibook-fun"
image = var.droplet_image
size = var.droplet_size
region = var.region
monitoring = true
backups = true
# CPU/RAM-only resize (provider default is true = also grow the disk).
resize_disk = false
ssh_keys = var.ssh_key_fingerprints
user_data = templatefile("${path.module}/templates/cloud-init.yaml", {
project_name = "aibook_fun"
})
tags = ["aibook-fun", "web", "weown-ai"]
lifecycle {
# A droplet destroy means losing every Docker volume + cert state on it.
# make any such plan a hard error instead of an accident.
prevent_destroy = true
# user_data/tags/ssh_keys are create-time-only on DO; ignore drift so
# unrelated changes don't arm `# forces replacement` on next apply.
ignore_changes = [user_data, tags, ssh_keys]
}
}
# Optional encrypted-at-rest data volume (default 0 = stateless: droplet local
# disk only). Enable by setting data_volume_size_gb > 0 if the site ever needs
# durable state; DO block storage is encrypted at rest, the droplet disk is not.
resource "digitalocean_volume" "data" {
count = var.data_volume_size_gb > 0 ? 1 : 0
region = var.region
name = "aibook-fun-data"
size = var.data_volume_size_gb
initial_filesystem_type = "ext4"
description = "aibook-fun encrypted-at-rest data (docker data-root)"
lifecycle {
prevent_destroy = true
}
}
resource "digitalocean_volume_attachment" "data" {
count = var.data_volume_size_gb > 0 ? 1 : 0
droplet_id = digitalocean_droplet.web.id
volume_id = digitalocean_volume.data[0].id
}
resource "digitalocean_reserved_ip" "web" {
region = var.region
}
resource "digitalocean_reserved_ip_assignment" "web" {
ip_address = digitalocean_reserved_ip.web.ip_address
droplet_id = digitalocean_droplet.web.id
}
#trivy:ignore:AVD-DIG-0001 # Public web server: HTTP/HTTPS inbound from internet is required by design
#trivy:ignore:AVD-DIG-0003 # Public web server: unrestricted outbound required for package installs, ACME, image pulls
resource "digitalocean_firewall" "web" {
name = "aibook-fun-fw"
droplet_ids = [digitalocean_droplet.web.id]
# SSH — restrict via var.ssh_source_cidrs. ⚠️ PRODUCTION: pin to admin IP/32 or VPN
inbound_rule {
protocol = "tcp"
port_range = "22"
source_addresses = var.ssh_source_cidrs
}
# HTTP (ACME challenge + redirect)
inbound_rule {
protocol = "tcp"
port_range = "80"
source_addresses = ["0.0.0.0/0", "::/0"]
}
# HTTPS
inbound_rule {
protocol = "tcp"
port_range = "443"
source_addresses = ["0.0.0.0/0", "::/0"]
}
# HTTPS/QUIC (HTTP/3)
inbound_rule {
protocol = "udp"
port_range = "443"
source_addresses = ["0.0.0.0/0", "::/0"]
}
# All outbound TCP (apt, ACME, container image pulls)
outbound_rule {
protocol = "tcp"
port_range = "1-65535"
destination_addresses = ["0.0.0.0/0", "::/0"]
}
# All outbound UDP (DNS, NTP, QUIC)
outbound_rule {
protocol = "udp"
port_range = "1-65535"
destination_addresses = ["0.0.0.0/0", "::/0"]
}
tags = ["aibook-fun"]
}
+44
View File
@@ -0,0 +1,44 @@
# aibook-fun - Monitoring Configuration
# Managed by OpenTofu
resource "digitalocean_monitor_alert" "cpu" {
count = var.enable_monitoring ? 1 : 0
alerts {
email = [var.alert_email]
}
window = "5m"
type = "v1/insights/droplet/cpu"
compare = "GreaterThan"
value = var.cpu_alert_threshold
enabled = true
entities = [digitalocean_droplet.web.id]
description = "aibook-fun: CPU usage above ${var.cpu_alert_threshold}% for 5 min"
}
resource "digitalocean_monitor_alert" "memory" {
count = var.enable_monitoring ? 1 : 0
alerts {
email = [var.alert_email]
}
window = "5m"
type = "v1/insights/droplet/memory_utilization_percent"
compare = "GreaterThan"
value = var.memory_alert_threshold
enabled = true
entities = [digitalocean_droplet.web.id]
description = "aibook-fun: Memory usage above ${var.memory_alert_threshold}% for 5 min"
}
resource "digitalocean_monitor_alert" "disk" {
count = var.enable_monitoring ? 1 : 0
alerts {
email = [var.alert_email]
}
window = "5m"
type = "v1/insights/droplet/disk_utilization_percent"
compare = "GreaterThan"
value = var.disk_alert_threshold
enabled = true
entities = [digitalocean_droplet.web.id]
description = "aibook-fun: Disk usage above ${var.disk_alert_threshold}% for 5 min"
}
+28
View File
@@ -0,0 +1,28 @@
# aibook-fun - Terraform Outputs
# Managed by OpenTofu
output "droplet_ip" {
description = "Droplet IPv4 address (reserved IP)"
value = digitalocean_reserved_ip.web.ip_address
}
output "reserved_ip" {
description = "Reserved IP address (alias for droplet_ip; stable across rebuilds)"
value = digitalocean_reserved_ip.web.ip_address
}
output "droplet_id" {
description = "Droplet ID"
value = digitalocean_droplet.web.id
}
output "domain" {
description = "Primary domain"
value = var.domain
}
output "site_url" {
description = "Site URL"
value = "https://${var.domain}"
}
@@ -0,0 +1,85 @@
#cloud-config
# First-boot bootstrap: base packages + Docker + git. App layer via ansible.
package_update: true
packages:
- apt-transport-https
- ca-certificates
- curl
- gnupg
- lsb-release
- jq
- unzip
- git
write_files:
# Docker installation
- path: /tmp/install-docker.sh
permissions: "0755"
content: |
#!/bin/bash
set -euo pipefail
curl -fsSL https://get.docker.com -o /tmp/get-docker.sh
sh /tmp/get-docker.sh
systemctl enable docker
systemctl start docker
usermod -aG docker ubuntu
# Docker daemon config (log rotation)
- path: /etc/docker/daemon.json
content: |
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
},
"storage-driver": "overlay2"
}
# Encrypted-at-rest data volume setup (runs BEFORE Docker is installed).
# Graceful no-op when no volume is attached (data_volume_size_gb = 0 default
# for aibook-fun stateless marketing site).
- path: /tmp/setup-data-volume.sh
permissions: "0755"
content: |
#!/bin/bash
set -euo pipefail
DEV="/dev/disk/by-id/scsi-0DO_Volume_aibook-fun-data"
MNT="/mnt/${project_name}_data"
for i in $(seq 1 24); do
[ -e "$DEV" ] && break
sleep 5
done
if [ ! -e "$DEV" ]; then
echo "no data volume attached after 120s - using droplet local disk (stateless mode)"
exit 0
fi
blkid "$DEV" >/dev/null 2>&1 || mkfs.ext4 -q "$DEV"
mkdir -p "$MNT"
grep -q "scsi-0DO_Volume_aibook-fun-data" /etc/fstab || \
echo "$DEV $MNT ext4 defaults,noatime,nofail,discard 0 2" >> /etc/fstab
mountpoint -q "$MNT" || mount "$MNT"
mkdir -p "$MNT/docker"
jq --arg dr "$MNT/docker" '. + {"data-root": $dr}' /etc/docker/daemon.json > /tmp/daemon.json.new
mv /tmp/daemon.json.new /etc/docker/daemon.json
mkdir -p /opt/${project_name}
echo "data volume mounted at $MNT (docker data-root relocated)"
runcmd:
# Data volume setup only when a block volume is attached
- 'bash -c "[ -e /dev/disk/by-id/scsi-0DO_Volume_aibook-fun-data ] && bash /tmp/setup-data-volume.sh || echo no-data-volume"'
- [bash, /tmp/install-docker.sh]
- [systemctl, restart, docker]
# Ensure the app dir exists for ansible to land the compose + Caddyfile
- [mkdir, -p, /opt/${project_name}]
# Marker so ansible/deploy.yml can confirm the host is ready
- [touch, /opt/${project_name}/.bootstrap-complete]
# Enable automatic security updates
- dpkg-reconfigure -plow unattended-upgrades
final_message: |
aibook-fun bootstrap complete. Next step from the operator workstation:
ansible-playbook aibook-fun/ansible/deploy.yml -i 'root@<this-droplet-ip>,'
Ansible will clone the repo, build the Docker image locally on this droplet,
and bring up the compose stack. Site served at https://aibook.fun.
+27
View File
@@ -0,0 +1,27 @@
# ⚠️ NEVER commit terraform.tfvars with real values. Use ./itofu.sh instead —
# it reads TF_VAR_* straight from Infisical /tofu/.
do_token = "<YOUR_DIGITALOCEAN_TOKEN>"
ssh_key_fingerprints = ["<YOUR_SSH_KEY_FINGERPRINT>"]
# pin to admin IP/32 or VPN range for prod
ssh_source_cidrs = ["0.0.0.0/0", "::/0"]
spaces_access_key = "<YOUR_SPACES_ACCESS_KEY>"
spaces_secret_key = "<YOUR_SPACES_SECRET_KEY>"
spaces_encryption_key = "<YOUR_SSE_C_KEY_BASE64>"
domain = "aibook.fun"
region = "atl1"
droplet_size = "s-2vcpu-4gb-amd"
droplet_image = "ubuntu-24-04-x64"
data_volume_size_gb = 0
enable_monitoring = true
# MUST be verified on the DO account
alert_email = "PLT@weown.net"
cpu_alert_threshold = 80
memory_alert_threshold = 90
disk_alert_threshold = 85
+82
View File
@@ -0,0 +1,82 @@
variable "domain" {
type = string
default = "aibook.fun"
}
variable "region" {
type = string
default = "atl1"
}
variable "droplet_size" {
type = string
default = "s-2vcpu-4gb-amd"
}
variable "droplet_image" {
type = string
default = "ubuntu-24-04-x64"
}
variable "ssh_key_fingerprints" {
type = list(string)
}
variable "ssh_source_cidrs" {
description = "CIDR list allowed to reach port 22 — pin to admin IP/32 or VPN range for prod"
type = list(string)
default = ["0.0.0.0/0", "::/0"]
}
variable "do_token" {
type = string
sensitive = true
}
variable "spaces_access_key" {
type = string
sensitive = true
}
variable "spaces_secret_key" {
type = string
sensitive = true
}
variable "spaces_encryption_key" {
description = "DO Spaces SSE-C encryption key (32-byte AES-256, base64)"
type = string
sensitive = true
}
variable "data_volume_size_gb" {
description = "0 = stateless (droplet local disk); >0 attaches encrypted DO block volume for Docker data-root"
type = number
default = 0
}
variable "enable_monitoring" {
type = bool
default = true
}
variable "alert_email" {
description = "Email for monitoring alerts — MUST be verified on the DO account"
type = string
default = "PLT@weown.net"
}
variable "cpu_alert_threshold" {
type = number
default = 80
}
variable "memory_alert_threshold" {
type = number
default = 90
}
variable "disk_alert_threshold" {
type = number
default = 85
}
+17
View File
@@ -0,0 +1,17 @@
# aibook-fun - OpenTofu Version Constraints
# Managed by OpenTofu
terraform {
required_version = ">= 1.7.0"
required_providers {
digitalocean = {
source = "digitalocean/digitalocean"
version = "~> 2.36"
}
}
}
provider "digitalocean" {
token = var.do_token
}