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:
commit
284510b9ac
15 changed files with 882 additions and 0 deletions
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
*.tfstate
|
||||
*.tfstate.*
|
||||
*.tfvars
|
||||
!*.tfvars.example
|
||||
.terraform/
|
||||
.DS_Store
|
||||
46
README.md
Normal file
46
README.md
Normal 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
11
terraform/do/.gitignore
vendored
Normal 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
26
terraform/do/.terraform.lock.hcl
generated
Normal 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
83
terraform/do/README.md
Normal 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/`.
|
||||
55
terraform/do/cloud-init/backend.yaml
Normal file
55
terraform/do/cloud-init/backend.yaml
Normal 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"
|
||||
69
terraform/do/cloud-init/forge.yaml
Normal file
69
terraform/do/cloud-init/forge.yaml
Normal 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
48
terraform/do/database.tf
Normal 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
88
terraform/do/droplet.tf
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
###############################################################################
|
||||
# Compute.
|
||||
#
|
||||
# backend — always-on. Forgejo (git) + Verdaccio (npm) + mac-sync + MCP
|
||||
# servers + LISTEN/NOTIFY workers + the WG↔VPC 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
76
terraform/do/network.tf
Normal 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
53
terraform/do/outputs.tf
Normal 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
56
terraform/do/spaces.tf
Normal 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
|
||||
}
|
||||
42
terraform/do/terraform.tfvars.example
Normal file
42
terraform/do/terraform.tfvars.example
Normal 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
190
terraform/do/variables.tf
Normal 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
33
terraform/do/versions.tf
Normal 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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue