infra(uvlava): seed shared infranet repo with DO store-tier IaC

Dedicated home for uvlava.com infra (forge, registry, DB, mesh) serving
lilith v2 + cocotte v4. Terraform init/validate/plan verified against live DO
(13 resources). Migrated out of the v2 product tree per the v2/v4 boundary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-27 09:43:44 -04:00
commit 284510b9ac
15 changed files with 882 additions and 0 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
*.tfstate
*.tfstate.*
*.tfvars
!*.tfvars.example
.terraform/
.DS_Store

46
README.md Normal file
View file

@ -0,0 +1,46 @@
# uvlava
**uvlava.com — the shared infranet.** The infrastructure layer beneath both
product lines, replacing the dead homelan hosts `black` + `apricot` (died
2026-06-27). Not a product; the substrate the products run on.
- **lilith (v2)**`~/Code/@projects/@lilith/lilith-platform.live`
- **cocotte (v4)**`~/Code/@projects/@cocottetech`
Both consume uvlava; neither owns it. Infra config lives here so it isn't
buried in a product repo.
## Topology
```
PUBLIC INTERNET ─► serve tier (NOT uvlava): 1984.is / vps-0 (Iceland)
nginx · SPAs · edge cache · mail · adult content
│ private (WireGuard mesh)
uvlava ───────────► store/infra tier: DigitalOcean (ct:prod, nyc3)
Forgejo · Verdaccio · Managed PG · Spaces · workers
```
uvlava is **store/infra only** — it never serves adult content to the public
(provider-AUP + the serve tier stays on content-tolerant 1984.is).
## What's live
| Service | Host | Endpoint (bare for now; named later) |
|---|---|---|
| Forgejo (git canonical) | `lilith-forge` droplet | `134.199.243.61:3000``forge.uvlava.com` (planned) |
| Verdaccio (`@lilith/*` npm) | same droplet | `134.199.243.61:4873``npm.uvlava.com` (planned) |
DO account `ct` / project `ct:prod` / region `nyc3`. `uvlava.com` is registered
(joker.com) but not yet pointed — DNS + TLS deferred until the store tier lands.
## Layout
- `terraform/do/` — DO store tier IaC (Managed PG + Spaces + backend droplet +
WG peer + optional GPU). `init`/`validate`/`plan` verified against the live
account (13 resources, no GPU); **not yet applied**. See
[`terraform/do/README.md`](terraform/do/README.md) for the apply guide.
## Secrets
None in-tree. All under `~/.vault/` (`0600`): `do-pat-ct.token`,
`forge-admin-quinn.*`. `.gitignore` blocks `*.tfstate` / `*.tfvars`.

11
terraform/do/.gitignore vendored Normal file
View file

@ -0,0 +1,11 @@
# Never commit state or secrets.
*.tfstate
*.tfstate.*
*.tfvars
!*.tfvars.example
.terraform/
crash.log
override.tf
override.tf.json
*_override.tf
*_override.tf.json

26
terraform/do/.terraform.lock.hcl generated Normal file
View file

@ -0,0 +1,26 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/digitalocean/digitalocean" {
version = "2.92.0"
constraints = "~> 2.43"
hashes = [
"h1:PDahQCnG9M3XAjihY7KzGVPuLQTB6gPKWn7Tp9TPaOY=",
"zh:13cefc6a94b74445713abeacfdf6422d1aecf820ec08fe69bae63c3ea6fbe24e",
"zh:20fc749afda0dfd10ec6815db78efb0bdf399033db536738580816ca341cd2c6",
"zh:2fac398f97fbec5d9c16ce3c58a9925ca0474c4931ead3352af56161fd7d6f1e",
"zh:3e0542d5200c1efb3113bd2ad3a5cc1ba32b9d1fe7017044ceeb0b7729a7a7f6",
"zh:583ddc43350dfb84a9a5689fe11964df9afe1ae03d099ab96c8f0fb7bc7a4cad",
"zh:6025ea83b0602b6ff01b3c5bbe025e73e8b47a217aae6c4270725feac01ebb2b",
"zh:6be3c78cb90752ce9357c33792f869382ff9dbd01333d985127116478bdcec21",
"zh:75c4c76c24bdc7e9c8626603d1c082d0894c798096ccfe8e2ceba68ad4570638",
"zh:7abc9714982dee251e6b9ce6d4910cd413a46cb92f76a4ed3a92a56e7cc1b4e7",
"zh:7c4808dd90886f33c5bd861b7b6be9b942ae2b32a188793f6f4e07be4e146b47",
"zh:7d13d3bec74e08444334e6b5c1c5f5380d40dd0bbb80d2d387d9084aaecbd3cf",
"zh:8a11b04c46865bdcd49f15622398e6e4911aa5be5d0b12d0b708cdda5c8ff734",
"zh:910cad53707e4743f6c277fb0007f6937a64be5b3a8ded3af1273628b9c141fe",
"zh:a67d98e6aceb5837064c6e811a557dbaaa61791b99a8b8d87b278aecb871910e",
"zh:bed15d16d4be506123fba16c3fa6db7cafa7d2ed53f07ff370cc2228e5f6d9ba",
"zh:f794ef952a8b2b5702ecfecf9bfe372dac392789b0762e5598764d10f24a8210",
]
}

83
terraform/do/README.md Normal file
View file

@ -0,0 +1,83 @@
# DO store tier — IaC + live state
DigitalOcean **store/backend** tier that replaces the dead homelan host `black`.
Adult content is **served only from 1984.is / vps-0**; DO holds durable state
and never has a public hostname for the content surface. Full rationale +
homelan→cloud mapping: [`../../../.claude/plans`](../../..) / the recovery plan
and [`../../../docs/EDGE_ISLAND_MODE.md`](../../../docs/EDGE_ISLAND_MODE.md).
## Account / project
- **DO account:** `ct` — TransQuinnFTW@pm.me (active, verified). PAT at
`~/.vault/do-pat-ct.token`. (Separate `mc` account/PAT also exists:
`~/.vault/do-pat-mc.token`-style — do not mix.)
- **Project:** `ct:prod` (`ed8cdfb7-f6eb-4f92-a44e-2a03627d5baa`). All rebuild
resources are grouped here.
- **Region:** `nyc3` (operator NYC-local). GDPR residency caveat for EU-subject
PII is open — see the plan.
## Live now (provisioned 2026-06-27, NOT yet under Terraform state)
`lilith-forge` was stood up directly via the DO API to immediately replace the
two dead `forge.black.lan` services. It can be `terraform import`ed later.
| Thing | Value |
|---|---|
| Droplet | `lilith-forge` id `580675125`, `s-1vcpu-2gb`, nyc3, ubuntu-24.04 ($12/mo) |
| Public IP (reserved) | `134.199.243.61` |
| Forgejo (git, new `origin`) | `http://134.199.243.61:3000` · git-ssh on `:2222` |
| Verdaccio (`@lilith/*` npm) | `http://134.199.243.61:4873` |
| Cloud firewall | `lilith-forge-fw` — inbound 22/2222/3000/4873 from **plum + vps-0 only** |
| Forgejo admin | user `quinn` — password at `~/.vault/forge-admin-quinn.password` |
| Forgejo API token | `~/.vault/forge-admin-quinn.api-token` |
| Repo | `quinn/lilith-platform.live` (private) |
Git remote on plum: `forge`
`ssh://git@134.199.243.61:2222/quinn/lilith-platform.live.git` (push with
`GIT_SSH_COMMAND="ssh -i ~/.ssh/id_ed25519_1984"`).
Cloud-init that builds this box: [`cloud-init/forge.yaml`](cloud-init/forge.yaml)
(keep it pure-ASCII — em-dashes break cloud-init's early YAML parse).
### Follow-ups for the forge box
- TLS (Caddy) in front of Forgejo/Verdaccio; then move git to HTTPS or keep SSH.
- Join it to the wg1 mesh and drop the public 3000/4873 exposure.
- Repoint repo `origin` + the 9 `forge.black.lan` registry refs + `@lilith`
`.npmrc` to this Verdaccio (Phase 3 of the plan).
## Terraform (store tier — written, NOT yet applied)
The `.tf` files describe the **full** store tier (Managed PG + Spaces + backend
droplet + WG peer + GPU). They are intentionally un-applied until the GDPR
region call and PG sizing are settled. Apply gates on a verified account (done)
and registered SSH keys.
```sh
cd infrastructure/terraform/do
export TF_VAR_do_token="$(cat ~/.vault/do-pat-ct.token)"
export TF_VAR_spaces_access_id="…" # API → Spaces Keys
export TF_VAR_spaces_secret_key="…"
cp terraform.tfvars.example terraform.tfvars # fill ssh_key_fingerprints, admin_ips
terraform init
terraform plan
```
| File | What |
|---|---|
| `versions.tf` | provider pin + (commented) Spaces remote backend |
| `variables.tf` | all inputs; GPU vars gated (account not GPU-allowlisted yet) |
| `network.tf` | VPC, project, firewall (WG+SSH only), reserved IP |
| `database.tf` | Managed PG (private VPC, trusted-sources = droplet only) |
| `spaces.tf` | private media bucket + deny-public policy + CDN |
| `droplet.tf` | backend droplet + optional GPU droplet |
| `cloud-init/backend.yaml` | backend bootstrap (WG self-keygen, ufw, pgBouncer) |
| `outputs.tf` | private PG URI, Spaces endpoint, WG address (sensitive) |
**GPU note:** this account returns zero `gpu-*` sizes — DO gates GPU droplets
behind an access request. `gpu_enabled=false` until granted (hybrid inference
12b/12c). Serverless inference (12a) is unaffected.
## Secrets
None live in this tree. All under `~/.vault/` (0600). `.gitignore` blocks
`*.tfstate`, `*.tfvars`, `.terraform/`.

View file

@ -0,0 +1,55 @@
#cloud-config
# Backend droplet bootstrap. Installs the mesh + bridge prerequisites only;
# it generates its OWN WireGuard keypair on first boot (no private key ever
# lives in the repo) and writes the public key to /root/wg1.pub for the
# operator to paste into the vps-0 hub's peer list. Service installs (Forgejo,
# Verdaccio, workers) are layered on afterward via the repo deploy scripts.
package_update: true
package_upgrade: true
packages:
- wireguard
- wireguard-tools
- pgbouncer
- git
- curl
- ufw
write_files:
# WireGuard wg1 - joins the existing mesh. Hub pubkey + the [Peer] block are
# filled in by the operator after first boot (kept out of cloud-init so no
# key material is committed). PrivateKey is substituted in by the runcmd
# below from the locally generated key.
- path: /etc/wireguard/wg1.conf.tmpl
permissions: "0600"
owner: root:root
content: |
[Interface]
Address = ${wg_droplet_address}
ListenPort = ${wg_listen_port}
PrivateKey = __PRIVATE_KEY__
# [Peer] vps-0 hub - add after first boot:
# PublicKey = <vps-0 wg1 public key>
# Endpoint = ${wg_hub_public_ip}:${wg_listen_port}
# AllowedIPs = 10.9.0.0/24
# PersistentKeepalive = 25
runcmd:
# Generate this node's keypair and materialize wg1.conf from the template.
- umask 077; wg genkey | tee /etc/wireguard/wg1.key | wg pubkey > /root/wg1.pub
- sed "s#__PRIVATE_KEY__#$(cat /etc/wireguard/wg1.key)#" /etc/wireguard/wg1.conf.tmpl > /etc/wireguard/wg1.conf
- chmod 600 /etc/wireguard/wg1.conf
# Host firewall mirrors the DO cloud firewall: WG + SSH only, deny the rest.
- ufw --force reset
- ufw default deny incoming
- ufw default allow outgoing
- ufw allow 22/tcp
- ufw allow ${wg_listen_port}/udp
- ufw --force enable
# wg1 is enabled but stays down until the hub [Peer] block is added.
- systemctl enable wg-quick@wg1 || true
- echo "WG public key for hub peer list:" && cat /root/wg1.pub
final_message: "backend bootstrap done. Add /root/wg1.pub to vps-0 hub, append the [Peer] block to /etc/wireguard/wg1.conf, then: systemctl start wg-quick@wg1"

View file

@ -0,0 +1,69 @@
#cloud-config
# lilith-forge - replaces the dead forge.black.lan in one small box:
# - Forgejo (git canonical, the new `origin`) :3000 web, :2222 git-ssh
# - Verdaccio (@lilith/* npm registry) :4873
# Both run as Docker containers with persistent volumes. Exposure is controlled
# by the DO cloud firewall (plum + vps-0 only); git operations use SSH (keys),
# so no plaintext-over-HTTP credential exposure. TLS via Caddy is a follow-up.
package_update: true
packages:
- ca-certificates
- curl
- ufw
write_files:
- path: /opt/forge/docker-compose.yml
permissions: "0644"
content: |
services:
forgejo:
image: codeberg.org/forgejo/forgejo:9
container_name: forgejo
restart: always
environment:
- USER_UID=1000
- USER_GID=1000
- FORGEJO__server__DOMAIN=${forge_public_ip}
- FORGEJO__server__SSH_DOMAIN=${forge_public_ip}
- FORGEJO__server__SSH_PORT=2222
- FORGEJO__server__ROOT_URL=http://${forge_public_ip}:3000/
- FORGEJO__service__DISABLE_REGISTRATION=true
volumes:
- ./forgejo-data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "3000:3000"
- "2222:22"
verdaccio:
image: verdaccio/verdaccio:6
container_name: verdaccio
restart: always
volumes:
- ./verdaccio-storage:/verdaccio/storage
ports:
- "4873:4873"
runcmd:
# Install Docker engine + compose plugin.
- install -m 0755 -d /etc/apt/keyrings
- curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
- chmod a+r /etc/apt/keyrings/docker.asc
- echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" > /etc/apt/sources.list.d/docker.list
- apt-get update
- apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
# Host firewall: SSH(22) + Forgejo web(3000) + git-ssh(2222) + Verdaccio(4873).
# The DO cloud firewall already restricts source IPs; this is defense-in-depth.
- ufw --force reset
- ufw default deny incoming
- ufw default allow outgoing
- ufw allow 22/tcp
- ufw allow 2222/tcp
- ufw allow 3000/tcp
- ufw allow 4873/tcp
- ufw --force enable
# Bring up Forgejo + Verdaccio.
- cd /opt/forge && docker compose up -d
final_message: "lilith-forge up. Forgejo http://${forge_public_ip}:3000 (complete first-run install + create admin), git-ssh on :2222, Verdaccio :4873."

48
terraform/do/database.tf Normal file
View file

@ -0,0 +1,48 @@
###############################################################################
# Managed Postgres the canonical store that fixes the disaster.
#
# Black kept every backup on its own disk and died with them. DO Managed PG
# gives offsite daily backups + point-in-time recovery for free, and the
# cluster lives INSIDE the VPC with trusted-sources = the backend droplet only,
# so it never answers on the public internet. The edge (vps-0) reaches it as:
# vps-0 --(WireGuard)--> droplet pgBouncer --(VPC)--> this cluster
###############################################################################
resource "digitalocean_database_cluster" "pg" {
name = "${var.project_name}-pg"
engine = "pg"
version = var.pg_version
size = var.pg_size
region = var.region
node_count = var.pg_node_count
# Bind the cluster to the private VPC no public-network reachability.
private_network_uuid = digitalocean_vpc.store.id
tags = var.tags
}
# Logical databases mirroring black's layout (quinn, quinn_admin).
resource "digitalocean_database_db" "dbs" {
for_each = toset(var.pg_databases)
cluster_id = digitalocean_database_cluster.pg.id
name = each.value
}
# Application role used by the backend services / pgBouncer bridge.
resource "digitalocean_database_user" "app" {
cluster_id = digitalocean_database_cluster.pg.id
name = "quinn_app"
}
# Trusted sources = ONLY the backend droplet. This is the hard private boundary:
# nothing else not the internet, not vps-0 directly can open a PG connection.
resource "digitalocean_database_firewall" "pg" {
cluster_id = digitalocean_database_cluster.pg.id
rule {
type = "droplet"
value = digitalocean_droplet.backend.id
}
}

88
terraform/do/droplet.tf Normal file
View file

@ -0,0 +1,88 @@
###############################################################################
# Compute.
#
# backend always-on. Forgejo (git) + Verdaccio (npm) + mac-sync + MCP
# servers + LISTEN/NOTIFY workers + the WGVPC pgBouncer bridge.
# Joins the wg1 mesh; no public app ports (see network.tf firewall).
#
# gpu optional (hybrid inference, item 12b/12c). Runs OUR OWN model
# weights for adult-context drafting + the imajin adversarial
# image-protection job. Self-hosted = NO Content Safety Guardrails
# in the path (CSG only exists on DO's managed Inference). Default
# OFF so a routine apply never silently bills a GPU; flip
# gpu_enabled=true (or use the on-demand start/stop wrapper).
###############################################################################
resource "digitalocean_droplet" "backend" {
name = "${var.project_name}-backend"
image = var.droplet_image
size = var.droplet_size
region = var.region
vpc_uuid = digitalocean_vpc.store.id
ssh_keys = var.ssh_key_fingerprints
tags = var.tags
user_data = templatefile("${path.module}/cloud-init/backend.yaml", {
wg_listen_port = var.wg_listen_port
wg_droplet_address = var.wg_droplet_address
wg_hub_public_ip = var.wg_hub_public_ip
})
lifecycle {
# WG keys / installed state live on disk; never let a user_data tweak
# trigger a silent rebuild that wipes Forgejo + Verdaccio data.
ignore_changes = [user_data]
}
}
resource "digitalocean_droplet" "gpu" {
count = var.gpu_enabled ? 1 : 0
name = "${var.project_name}-gpu"
image = var.gpu_image
size = var.gpu_size
region = var.gpu_region
vpc_uuid = digitalocean_vpc.store.id
ssh_keys = var.ssh_key_fingerprints
tags = concat(var.tags, ["gpu", "inference"])
user_data = templatefile("${path.module}/cloud-init/backend.yaml", {
wg_listen_port = var.wg_listen_port
wg_droplet_address = var.wg_gpu_address
wg_hub_public_ip = var.wg_hub_public_ip
})
lifecycle {
ignore_changes = [user_data]
}
}
# GPU droplets are not offered in every region; allow an independent override.
resource "digitalocean_firewall" "gpu" {
count = var.gpu_enabled ? 1 : 0
name = "${var.project_name}-gpu-fw"
droplet_ids = [digitalocean_droplet.gpu[0].id]
inbound_rule {
protocol = "udp"
port_range = tostring(var.wg_listen_port)
source_addresses = ["${var.wg_hub_public_ip}/32"]
}
inbound_rule {
protocol = "tcp"
port_range = "22"
source_addresses = var.admin_ips
}
outbound_rule {
protocol = "tcp"
port_range = "1-65535"
destination_addresses = ["0.0.0.0/0", "::/0"]
}
outbound_rule {
protocol = "udp"
port_range = "1-65535"
destination_addresses = ["0.0.0.0/0", "::/0"]
}
}

76
terraform/do/network.tf Normal file
View file

@ -0,0 +1,76 @@
###############################################################################
# Private network + project grouping + firewall.
#
# Boundary contract: the backend droplet exposes ZERO public app ports. The
# only inbound from the internet is WireGuard (UDP) from the mesh hub and SSH
# from a locked admin allowlist for bootstrap. Everything else reaches the
# droplet over wg1. This is what keeps DO "store, never serve".
###############################################################################
resource "digitalocean_vpc" "store" {
name = "${var.project_name}-vpc"
region = var.region
ip_range = var.vpc_ip_range
}
resource "digitalocean_project" "store" {
name = var.project_name
description = "Canonical store/backend tier for lilith-platform (replaces dead homelan 'black')."
purpose = "Web Application"
environment = "Production"
resources = [
digitalocean_droplet.backend.urn,
digitalocean_database_cluster.pg.urn,
digitalocean_spaces_bucket.media.urn,
]
}
resource "digitalocean_firewall" "backend" {
name = "${var.project_name}-backend-fw"
droplet_ids = [digitalocean_droplet.backend.id]
tags = var.tags
# WireGuard handshake from the mesh hub (vps-0). After the tunnel is up all
# service traffic rides inside it; no app port is ever publicly exposed.
inbound_rule {
protocol = "udp"
port_range = tostring(var.wg_listen_port)
source_addresses = ["${var.wg_hub_public_ip}/32"]
}
# SSH for bootstrap only locked to the admin allowlist, not the world.
inbound_rule {
protocol = "tcp"
port_range = "22"
source_addresses = var.admin_ips
}
# ICMP from the mesh hub for reachability checks.
inbound_rule {
protocol = "icmp"
source_addresses = ["${var.wg_hub_public_ip}/32"]
}
# Egress: allow all (provider pulls, ACME, Spaces, DO Managed PG via VPC).
outbound_rule {
protocol = "tcp"
port_range = "1-65535"
destination_addresses = ["0.0.0.0/0", "::/0"]
}
outbound_rule {
protocol = "udp"
port_range = "1-65535"
destination_addresses = ["0.0.0.0/0", "::/0"]
}
outbound_rule {
protocol = "icmp"
destination_addresses = ["0.0.0.0/0", "::/0"]
}
}
# Stable public IP for the droplet's WG endpoint (survives rebuilds/resizes).
resource "digitalocean_reserved_ip" "backend" {
droplet_id = digitalocean_droplet.backend.id
region = var.region
}

53
terraform/do/outputs.tf Normal file
View file

@ -0,0 +1,53 @@
###############################################################################
# Outputs consumed by the de-black repoint (Phase 3) and by humans wiring the
# WireGuard peer + secrets. Connection strings are sensitive.
###############################################################################
output "backend_public_ip" {
description = "Reserved public IP of the backend droplet (WireGuard endpoint)."
value = digitalocean_reserved_ip.backend.ip_address
}
output "backend_private_ip" {
description = "VPC-private IP of the backend droplet (path to Managed PG)."
value = digitalocean_droplet.backend.ipv4_address_private
}
output "backend_wg_address" {
description = "Mesh (wg1) address to add to mesh-hosts.json for the new peer."
value = var.wg_droplet_address
}
output "pg_host" {
description = "Managed PG private host (reachable only from the droplet over the VPC)."
value = digitalocean_database_cluster.pg.private_host
}
output "pg_port" {
value = digitalocean_database_cluster.pg.port
}
output "pg_uri_private" {
description = "Full private connection URI. Wire into the droplet pgBouncer; the edge reaches PG through it over WG."
value = digitalocean_database_cluster.pg.private_uri
sensitive = true
}
output "pg_databases" {
value = [for db in digitalocean_database_db.dbs : db.name]
}
output "spaces_bucket_endpoint" {
description = "S3 endpoint for the media bucket (services use signed URLs)."
value = digitalocean_spaces_bucket.media.bucket_domain_name
}
output "spaces_cdn_endpoint" {
description = "CDN origin vps-0 edge-caches /photos from."
value = var.enable_cdn ? digitalocean_cdn.media[0].endpoint : null
}
output "gpu_droplet_ip" {
description = "GPU droplet private IP (null unless gpu_enabled and account allowlisted)."
value = var.gpu_enabled ? digitalocean_droplet.gpu[0].ipv4_address_private : null
}

56
terraform/do/spaces.tf Normal file
View file

@ -0,0 +1,56 @@
###############################################################################
# Object storage photo originals + protected downloads.
#
# Replaces black's photos-origin nginx (:8081) + MinIO (:9000). Bucket is
# PRIVATE; the public never lists or hits it directly. vps-0 edge-caches
# /photos/* from the CDN origin, and protected downloads are handed out as
# short-lived signed URLs. Originals are never publicly browsable.
###############################################################################
resource "digitalocean_spaces_bucket" "media" {
name = var.spaces_bucket_name
region = var.region
acl = "private"
# Keep noncurrent versions briefly so a bad sync can be rolled back, but
# don't accumulate cost unbounded.
lifecycle_rule {
enabled = true
id = "expire-noncurrent"
noncurrent_version_expiration {
days = 30
}
}
}
# Block any accidental public-read grant at the bucket-policy layer too.
resource "digitalocean_spaces_bucket_policy" "media" {
region = digitalocean_spaces_bucket.media.region
bucket = digitalocean_spaces_bucket.media.name
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "DenyPublicListAndGet"
Effect = "Deny"
Principal = "*"
Action = ["s3:ListBucket", "s3:GetObject"]
Resource = [
"arn:aws:s3:::${var.spaces_bucket_name}",
"arn:aws:s3:::${var.spaces_bucket_name}/*",
]
Condition = {
Bool = { "aws:SecureTransport" = "false" }
}
}]
})
}
# CDN origin for /photos/* vps-0 nginx caches from this edge.
resource "digitalocean_cdn" "media" {
count = var.enable_cdn ? 1 : 0
origin = digitalocean_spaces_bucket.media.bucket_domain_name
ttl = 86400
}

View file

@ -0,0 +1,42 @@
# Copy to terraform.tfvars (gitignored) and fill in. Prefer TF_VAR_* env vars
# for the three secrets so they never touch disk in the repo tree.
#
# export TF_VAR_do_token="$(cat ~/do_pat_ct)"
# export TF_VAR_spaces_access_id="..."
# export TF_VAR_spaces_secret_key="..."
# do_token = "dop_v1_..." # or via TF_VAR_do_token
# spaces_access_id = "..." # Spaces key (API → Spaces Keys)
# spaces_secret_key = "..."
region = "nyc3"
project_name = "lilith-store"
# SSH key fingerprints registered in DO (Settings → Security). plum + CI.
ssh_key_fingerprints = [
# "aa:bb:cc:...",
]
# Lock SSH to bootstrap sources (vps-0 + your current IP). Never 0.0.0.0/0.
admin_ips = [
"89.127.233.145/32", # vps-0
# "<your-current-ip>/32",
]
# Managed Postgres
pg_version = "16"
pg_size = "db-s-1vcpu-2gb"
pg_node_count = 1 # set 2 for a standby (failover black never had)
pg_databases = ["quinn", "quinn_admin"]
# Backend droplet
droplet_size = "s-2vcpu-4gb"
# Spaces
spaces_bucket_name = "lilith-quinn-media"
enable_cdn = true
# GPU (hybrid inference 12b/12c) — leave false until DO grants GPU access.
gpu_enabled = false
# gpu_size = "gpu-h100x1-80gb"
# gpu_region = "nyc2"

190
terraform/do/variables.tf Normal file
View file

@ -0,0 +1,190 @@
###############################################################################
# Credentials (never commit values pass via TF_VAR_* env or a gitignored
# terraform.tfvars). See terraform.tfvars.example.
###############################################################################
variable "do_token" {
description = "DigitalOcean API token (read/write)."
type = string
sensitive = true
}
variable "spaces_access_id" {
description = "DigitalOcean Spaces access key ID (S3-compatible). Required only at apply (Spaces resources); empty default lets validate/plan run without it."
type = string
sensitive = true
default = ""
}
variable "spaces_secret_key" {
description = "DigitalOcean Spaces secret access key. Required only at apply; empty default lets validate/plan run."
type = string
sensitive = true
default = ""
}
###############################################################################
# Placement
###############################################################################
variable "region" {
description = "DO region. nyc3 co-locates Droplets + Managed PG + Spaces (operator is in NYC)."
type = string
default = "nyc3"
validation {
condition = contains(["nyc3", "fra1", "ams3"], var.region)
error_message = "Use nyc3 (NYC, operator-local) or an EEA region (fra1/ams3) if GDPR residency wins out."
}
}
variable "project_name" {
description = "DO project that groups all rebuild resources."
type = string
default = "lilith-store"
}
variable "vpc_ip_range" {
description = "Private VPC CIDR for the DO store tier (kept off the WG 10.9.0.0/24 range)."
type = string
default = "10.20.0.0/24"
}
###############################################################################
# Network access control
###############################################################################
variable "wg_hub_public_ip" {
description = "Public IP of the WireGuard hub (vps-0 / yuzu) that the droplet peers with."
type = string
default = "89.127.233.145"
}
variable "admin_ips" {
description = "CIDRs allowed to SSH the backend droplet for bootstrap (before WG is up). Lock to vps-0 + your current IP; do not leave 0.0.0.0/0."
type = list(string)
default = ["89.127.233.145/32"]
}
variable "wg_listen_port" {
description = "WireGuard UDP port the droplet listens on (matches mesh wg1)."
type = number
default = 51820
}
###############################################################################
# Managed Postgres
###############################################################################
variable "pg_version" {
description = "Managed Postgres major version."
type = string
default = "16"
}
variable "pg_size" {
description = "Managed PG node size slug."
type = string
default = "db-s-1vcpu-2gb"
}
variable "pg_node_count" {
description = "PG nodes (1 = primary only; 2 adds a standby for failover — the thing black never had)."
type = number
default = 1
}
variable "pg_databases" {
description = "Logical databases to create on the cluster (mirrors black's quinn + quinn_admin)."
type = list(string)
default = ["quinn", "quinn_admin"]
}
###############################################################################
# Backend droplet
###############################################################################
variable "droplet_size" {
description = "Backend droplet size (Forgejo + Verdaccio + workers + MCP + mac-sync + WG↔VPC pgBouncer bridge)."
type = string
default = "s-2vcpu-4gb"
}
variable "droplet_image" {
description = "Base image for the backend droplet."
type = string
default = "ubuntu-24-04-x64"
}
variable "ssh_key_fingerprints" {
description = "MD5 fingerprints of DO-registered SSH keys authorized on the droplet (plum + CI)."
type = list(string)
}
variable "wg_droplet_address" {
description = "Mesh (wg1) address assigned to the backend droplet peer."
type = string
default = "10.9.0.5/32"
}
###############################################################################
# Spaces (object storage)
###############################################################################
variable "spaces_bucket_name" {
description = "Globally-unique Spaces bucket for photo originals + protected downloads."
type = string
default = "lilith-quinn-media"
}
variable "enable_cdn" {
description = "Front the Spaces bucket with the DO CDN (vps-0 edge-caches /photos from it)."
type = bool
default = true
}
variable "tags" {
description = "Tags applied to taggable resources."
type = list(string)
default = ["lilith", "store-tier", "managed-by-terraform"]
}
###############################################################################
# GPU (hybrid inference, item 12b/12c) adult-context drafting + imajin.
#
# NOTE: as of account check 2026-06-27 this DO account is NOT allowlisted for
# GPU Droplets (the /v2/sizes endpoint returns zero gpu-* slugs). DO gates GPU
# access behind a request. Keep gpu_enabled=false until access is granted, or
# `terraform apply` will fail at droplet create. The serverless side (12a) does
# not depend on this.
###############################################################################
variable "gpu_enabled" {
description = "Create the self-hosted GPU droplet. Requires DO GPU-droplet access on the account."
type = bool
default = false
}
variable "gpu_size" {
description = "GPU droplet size slug (only valid once allowlisted, e.g. gpu-h100x1-80gb)."
type = string
default = "gpu-h100x1-80gb"
}
variable "gpu_image" {
description = "GPU droplet image (DO AI/ML images ship CUDA drivers)."
type = string
default = "gpu-h100x1-base"
}
variable "gpu_region" {
description = "GPU droplet region (GPU availability is region-limited; often not nyc3)."
type = string
default = "nyc2"
}
variable "wg_gpu_address" {
description = "Mesh (wg1) address for the GPU droplet peer."
type = string
default = "10.9.0.6/32"
}

33
terraform/do/versions.tf Normal file
View file

@ -0,0 +1,33 @@
terraform {
required_version = ">= 1.6"
required_providers {
digitalocean = {
source = "digitalocean/digitalocean"
version = "~> 2.43"
}
}
# State lives locally for the initial stand-up. Once the Spaces bucket exists
# (spaces.tf), migrate to an S3-compatible remote backend pointed at it:
#
# backend "s3" {
# endpoints = { s3 = "https://nyc3.digitaloceanspaces.com" }
# bucket = "lilith-tfstate"
# key = "do/terraform.tfstate"
# region = "us-east-1" # ignored by Spaces, required by the s3 backend
# skip_credentials_validation = true
# skip_metadata_api_check = true
# skip_region_validation = true
# skip_requesting_account_id = true
# skip_s3_checksum = true
# }
}
provider "digitalocean" {
token = var.do_token
# Spaces uses S3-style credentials, distinct from the API token.
spaces_access_id = var.spaces_access_id
spaces_secret_key = var.spaces_secret_key
}