--- id: aws-deploy title: A one-shot Terraform module an operator can consume without cloning this status: open crates: [] dependsOn: [deploy] exitCriterion: > An operator with an AWS account and nothing else pins a module version, supplies a zone name and a container image tag, applies it, and is handed the NS records to delegate — with no file from this repository on their disk. --- # aws-deploy `infra/` lives inside this repository today: a checkout, a `terraform apply`, and knowledge of which files matter. The owner asked for a one-shot Terraform module, published as its own repository, that stands up the whole thing for an operator who has not cloned this source tree and should not need to. This project already has the precedent for splitting a component out: `vibescrobble-index`, its query service and the canvas were extracted into their own repository (see the `build!` commit that removed `crates/vibescrobble-index` and the rest, and left [index](index.md) and [canvas](canvas.md) in `plan/` naming no crates, "for now", until that repository is published). This epic is the same move applied to `infra/`: extract it, version it, and leave a pointer here rather than the Terraform itself. Whatever conventions that split settled — how the new repository is named, how it is linked back from here — this epic should follow rather than invent a second way of doing the same thing. ## The design problems, which matter more than the extraction itself **The module and the binary must stay in step.** `infra/templates/user_data.sh.tftpl` writes a systemd unit whose `ExecStart` passes specific flags — `--zone`, `--owner`, `--port`, `--data`, `--secret-file` today, confirmed by reading that template. A module version and a server (container image) version are therefore coupled: a flag renamed, dropped, or given new meaning between two image versions is invisible to Terraform, and an operator who bumps the module without also picking a compatible image gets a server that will not start, or worse, one that starts and silently misreads a flag that used to mean something else. Today nothing states the compatible range in either direction. - [ ] **Pin the pairing, not just each half separately.** The module's own version (a git tag on the new repository) needs to state which image tags it is known to produce a working `ExecStart` for, and the release notes for a module version bump need to say when that range changed. A module `README` table (module version → compatible image tag range) is the minimum; whether it can be checked automatically, rather than documented and trusted, is open. - [ ] **A sectioned TOML config changes what "compatible" even means, and makes it easier to state.** `config` (not yet a file in `plan/`, so not linked here — in review as of this writing) would replace the CLI flag list `ExecStart` builds today with a config file. A config file the module writes and the server reads is a much smaller surface to version than a flag list `sed`-substituted into a systemd unit: the module ships a schema version, the server refuses a config whose schema version it does not recognise, and the failure mode becomes a named refusal instead of a silently wrong or absent flag. This epic should target that surface if it has landed by the time this is built, and the CLI-flag surface if it has not — either way, name which one a given module version speaks. **Secrets and Terraform state.** `infra/variables.tf`'s `ssm_prefix` comment already states the current discipline: secret *values* are written to SSM out of band, by a human or a separate process this configuration does not run, so `terraform.tfstate` never carries key material. A one-shot module that *generates* the attestation secret for the operator — which is the more "one-shot" reading of the request — breaks that discipline by construction: anything a Terraform resource creates is a value Terraform's state file holds, in plaintext, in whatever backend that state lives in (local disk by default, S3 with no encryption unless configured, a CI runner's workspace). State is not a secret store anyone chose for this purpose, and it is routinely committed, shared, or left in a CI cache by people who never meant it to hold credentials. - [ ] **The module must not generate secrets.** It should accept references to secrets an operator already created out of band (an SSM parameter path, as today) or, at most, shell out to a provisioning step that writes directly to SSM and returns nothing to Terraform — never a `random_password` or similar resource whose value Terraform then tracks. Say this explicitly in the module's own documentation, not just here, since "one-shot" is exactly the phrase that invites the wrong shortcut. - [ ] **State backend is the operator's choice and the module's problem to not make worse.** The module should default to nothing that silently writes state somewhere the operator did not choose (no assumed remote backend), and its documentation should say, once, why the state file itself deserves the same handling as a secret even with the item above followed — resource ids, an instance's IP, a zone id are not secret, but a state file that later grows a generated value because someone relaxed the rule above is a slow, easy mistake. **What it must output.** Above everything else: the **NS records the operator delegates at their parent zone**. `infra/outputs.tf`'s `name_servers` output already says why in its own comment — Route53 assigns these when the hosted zone is created, Terraform cannot make the delegation record itself because the parent zone is not this stack's to manage, and nothing under the new zone resolves until a human adds that record by hand. A one-shot module that applies cleanly and prints nothing actionable has shipped half a deployment. - [ ] **Carry every output `infra/outputs.tf` has today** — the Elastic IP, the hosted zone id, the instance id, the data volume id — since each exists for an operational reason (pointing external monitoring at the IP before DNS resolves, `aws ssm start-session`, the restore drill) that does not go away by being packaged. - [ ] **Make the NS output impossible to miss.** A `terraform apply` that succeeds silently is where this fails an operator: the output should be loud, and the module's own top-level documentation should lead with it rather than bury it under variable reference tables. **What it must not assume.** `infra/variables.tf` today requires a `vpc_id` and an `instance_subnet_id` with no default, on purpose — this project has never assumed an existing VPC. A one-shot module aimed at an operator with "an AWS account and nothing else" raises the question of whether it should now provision a minimal VPC itself, and the answer this epic should state plainly either way rather than let default: **it must not assume one exists, and it must not assume the operator wants Terraform's defaults for anything that affects blast radius** — region, instance size, whether SSH access exists at all. Offering a minimal-VPC path as one option is reasonable; making it the only path is not. - [ ] **A region is a variable with no default**, matching `root_zone`'s own treatment in `infra/variables.tf` today (a value this stack should not silently choose for someone). - [ ] **State explicitly what a fresh apply does *not* set up**, alongside what it does: `plan/deploy.md`'s open items on restore drills, log-format upgrades, and rollback do not become solved by turning `infra/` into a module. A module that stands up the instance is not a module that has answered any of those; say so in its own documentation so packaging is never mistaken for having closed them. ## Relation to fleet [fleet](fleet.md) is written for an operator running several servers, each "a whole personal data server in its own right — its own zone, its own accounts, its own keys, its own repositories." A module the operator instantiates once per deployment is the natural shape for that: `terraform apply` with a different `root_zone` and a different `ssm_prefix`, ten times, rather than a script that assumes it is the only deployment on the account. This epic does not need fleet to exist first, but the module's variables (anything that would collide across two instantiations in the same account — IAM role names, KMS alias names, the backup vault name, all of which `infra/`'s current `.tf` files hardcode as `didbot-pds*` today) need to be parameterised with that in mind, or a second deployment in the same account will fail to apply rather than coexist. - [ ] **Namespace every account-global resource name** — the IAM role, instance profile, KMS alias, backup vault and plan currently hardcoded as `didbot-pds*` in `infra/iam.tf` and `infra/backup.tf` — by a variable (a deployment name or the zone itself), so two instantiations of this module in one AWS account do not collide on a role name neither chose. ## Done Nothing closed yet.