diff --git a/.gitignore b/.gitignore index 03269be..420bd48 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,8 @@ e2e/devnet/.env # deploy secrets (never commit) deploy/.env + +# opentofu state (holds db password) + local dirs +deploy/infra/.terraform/ +deploy/infra/*.tfstate +deploy/infra/*.tfstate.* diff --git a/deploy/infra/.terraform.lock.hcl b/deploy/infra/.terraform.lock.hcl new file mode 100644 index 0000000..5c6161a --- /dev/null +++ b/deploy/infra/.terraform.lock.hcl @@ -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.95.0" + constraints = "~> 2.40" + hashes = [ + "h1:IR5TJd1uh/8JGWNRo+ysZ8fOozaVT4KXK5qGEX4ox+k=", + "h1:MRi95OXNUuy4wBjCuQQJVS9MOAEMSoR3w8/YtjdIqRI=", + "h1:Swmt4VwblOIw8yQK+EQQlmZOJRLGJtikR8A3L8zPuCM=", + "h1:UWrU2kRNW56/Q8nlH9mpZxuM6L04IUu8bs8veBx5YHc=", + "h1:WBJiNQxuQ6JMbpL7vl50ew9AhrmBQw34fmVqu/AxYkI=", + "h1:XZbPgajB+3b0Nhh8Jseo9avwHuRVECBZRB3f5o9Ktzg=", + "h1:ZRZws8qQuB+FoNBEq6OtqWGqja8FaCLud4zNT8sxPMw=", + "h1:aF0lD4JA1smHTYlnnCpZCkHKujMOlIFzc8d9cGP9i58=", + "h1:ac0rldIsNVEQVSBt0y71cZ0jeADKoIwNRVKHQFZb3Pk=", + "h1:c3+WAv5kNvqSrSvwX5VLr/l3rs77/2h9W8S8eNl5uAA=", + "h1:cAbvvqhchSbHfJDfr3VDdxqhsj3xotMvWCegSvLcaaE=", + "h1:cKRVDzHVB2+Tm/1gud6Q/ZKthTt0PbYv+JyyRKUIYc4=", + "h1:gU65/ZDf34zDD1FRQZFZUsFAeTitXxaX2oD6X0hU3wg=", + "h1:kQToPR+KDppfiOKhoiYgFbDmvf02f2tAdYG1xTANdIU=", + "h1:nK09t8RXbJa+RWApFCnXaQ+WPz5T2sgWqKHTGAp2uYo=", + "h1:w4TGK/JLcC/OLfdtvqrd87d23D0U9k52UdNq3TIlfw0=", + "zh:0ce2c15895b2200fb81106d381bb541f634484ecf2c424f884d28264a03f4e1e", + "zh:0df1ebcf9092f8f2bb8de98e9a29bf3787db3828ef8ee1a58a1278660ef345f7", + "zh:3251c0d0749a493cf203334e43b8b3c49d9dc72577adba95f8a0e2569e04aa62", + "zh:3dd8067a13cc0b5e1fda05cebd40afd930bf834b36e1c577970ae03a89326409", + "zh:446c8421cb9fb66080606c2d2c6ebd56343f574bd4303581683762421d864881", + "zh:49749c88d6acffbeb048e2fdfb038a600acf8cb9c0a69bee609796761059265d", + "zh:5eb6ec872f21500ac8b1f9782e50a8cbe0d855b37a327e1dc4c5d5aa961374cf", + "zh:7347e0c0679b2fbb373446a42545cd36da02c2e8a1dbc17888f860c2be7020c6", + "zh:74bec404a389761ec88581033cc2f58894a450ae8a2b1d485d01186b30600bdd", + "zh:7931784ff333aaba9b83ca8c5a22b316be8616fb829418b8e0fa4621cfd6e3a1", + "zh:80768ff8f2354b58e4760a0325ee2fd290178472042771599794762d64dd9573", + "zh:875bbe2a64cd986a4c1f27bffde285e1629557a0f2093c5ec636b0b9e64061eb", + "zh:8cb8b370ad8399c937d9c6902258630abcf6a4dcb47b3eea0f175c61c8a1157b", + "zh:aadd0c28269d478b3d3f7cd4ad118f42339b936c0974ad8d55e7aaad7542a8d0", + "zh:d1245847913885a76a44e1c0c0694ebb55b1bda3c6f7110f9717a7bc239b8511", + "zh:d804fe826b0e44bd39868ef818490f33d96c1884332849ee4a425aafb2655b6f", + ] +} diff --git a/deploy/infra/README.md b/deploy/infra/README.md new file mode 100644 index 0000000..60c98b3 --- /dev/null +++ b/deploy/infra/README.md @@ -0,0 +1,40 @@ +# deploy/infra — OpenTofu (mokkenstorm DO account) + +Provisions the at-record alpha's DigitalOcean side: a ~2GB droplet with Docker +(via cloud-init) and a managed Postgres cluster reachable only from that +droplet. Cloudflare (tunnel, DNS, WAF) is provisioned separately (see +`../../docs/deploy.md`); this module is DO-only. + +## Auth + +The `digitalocean` provider reads `DIGITALOCEAN_TOKEN` from the environment: a +personal access token on the **mokkenstorm.dev** DO account (not riotbyte). + +```sh +export DIGITALOCEAN_TOKEN=dop_v1_... +``` + +State is local (`terraform.tfstate`) and holds the DB password, so it is +gitignored. Move it to a Spaces/R2 backend before this is anything but an alpha. + +## Use + +```sh +cd deploy/infra +tofu init +tofu apply -var "ssh_public_key=$(cat ~/.ssh/id_ed25519.pub)" +``` + +Then wire the outputs into the app's secrets: + +```sh +DBURL=$(tofu output -raw database_url) +op item edit kaaso7pdpoacuvvpku7dxxjasi "DATABASE_URL[password]=$DBURL" +tofu output -raw droplet_ip # SSH + deploy target +``` + +## What it does not do + +- No Cloudflare tunnel/DNS/WAF (separate, needs a Cloudflare token). +- No image build/push (ghcr, manual or CI). +- No app rollout (SSH + `docker compose up` after the droplet exists). diff --git a/deploy/infra/cloud-init.yaml b/deploy/infra/cloud-init.yaml new file mode 100644 index 0000000..3042e5c --- /dev/null +++ b/deploy/infra/cloud-init.yaml @@ -0,0 +1,15 @@ +#cloud-config +package_update: true +packages: + - ca-certificates + - curl +runcmd: + # Docker CE + the compose plugin, from Docker's apt repo. + - 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 + - systemctl enable --now docker + - mkdir -p /opt/at-record diff --git a/deploy/infra/database.tf b/deploy/infra/database.tf new file mode 100644 index 0000000..27f1017 --- /dev/null +++ b/deploy/infra/database.tf @@ -0,0 +1,28 @@ +resource "digitalocean_database_cluster" "pg" { + name = "${var.name}-pg" + engine = "pg" + version = var.pg_version + size = var.pg_size + region = var.region + node_count = 1 +} + +resource "digitalocean_database_db" "crate" { + cluster_id = digitalocean_database_cluster.pg.id + name = "crate" +} + +resource "digitalocean_database_user" "crate" { + cluster_id = digitalocean_database_cluster.pg.id + name = "crate" +} + +# Only the app droplet may reach Postgres. +resource "digitalocean_database_firewall" "pg" { + cluster_id = digitalocean_database_cluster.pg.id + + rule { + type = "droplet" + value = digitalocean_droplet.app.id + } +} diff --git a/deploy/infra/droplet.tf b/deploy/infra/droplet.tf new file mode 100644 index 0000000..f957e31 --- /dev/null +++ b/deploy/infra/droplet.tf @@ -0,0 +1,14 @@ +resource "digitalocean_ssh_key" "deploy" { + name = "${var.name}-deploy" + public_key = var.ssh_public_key +} + +resource "digitalocean_droplet" "app" { + name = var.name + region = var.region + size = var.droplet_size + image = var.droplet_image + ssh_keys = [digitalocean_ssh_key.deploy.fingerprint] + user_data = file("${path.module}/cloud-init.yaml") + tags = [var.name] +} diff --git a/deploy/infra/outputs.tf b/deploy/infra/outputs.tf new file mode 100644 index 0000000..51e4d20 --- /dev/null +++ b/deploy/infra/outputs.tf @@ -0,0 +1,17 @@ +output "droplet_ip" { + value = digitalocean_droplet.app.ipv4_address + description = "Public IPv4 of the app droplet (SSH + deploy target)." +} + +output "database_url" { + sensitive = true + description = "Feeds the app's DATABASE_URL (write it into the 1Password item)." + value = format( + "postgresql://%s:%s@%s:%d/%s?sslmode=require", + digitalocean_database_user.crate.name, + digitalocean_database_user.crate.password, + digitalocean_database_cluster.pg.host, + digitalocean_database_cluster.pg.port, + digitalocean_database_db.crate.name, + ) +} diff --git a/deploy/infra/providers.tf b/deploy/infra/providers.tf new file mode 100644 index 0000000..364eac5 --- /dev/null +++ b/deploy/infra/providers.tf @@ -0,0 +1,3 @@ +# Token comes from the DIGITALOCEAN_TOKEN env var (a personal access token on +# the mokkenstorm.dev DO account, NOT the riotbyte one). Never commit a token. +provider "digitalocean" {} diff --git a/deploy/infra/variables.tf b/deploy/infra/variables.tf new file mode 100644 index 0000000..d35a315 --- /dev/null +++ b/deploy/infra/variables.tf @@ -0,0 +1,37 @@ +variable "name" { + type = string + default = "at-record" + description = "Base name for the droplet, PG cluster, and tags." +} + +variable "region" { + type = string + default = "ams3" +} + +variable "droplet_size" { + type = string + default = "s-1vcpu-2gb" + description = "BEAM + cloudflared need ~2GB; 512MB is too small." +} + +variable "droplet_image" { + type = string + default = "ubuntu-24-04-x64" +} + +variable "pg_version" { + type = string + default = "16" +} + +variable "pg_size" { + type = string + default = "db-s-1vcpu-1gb" + description = "Smallest managed PG node." +} + +variable "ssh_public_key" { + type = string + description = "Public key authorized for root SSH on the droplet (used for deploys)." +} diff --git a/deploy/infra/versions.tf b/deploy/infra/versions.tf new file mode 100644 index 0000000..2cf5b65 --- /dev/null +++ b/deploy/infra/versions.tf @@ -0,0 +1,9 @@ +terraform { + required_version = ">= 1.6" + required_providers { + digitalocean = { + source = "digitalocean/digitalocean" + version = "~> 2.40" + } + } +}