terraform: per-service backend/frontend image tags (two-image contract) (#2)

This commit is contained in:
emi 2026-07-22 23:27:13 +00:00
parent dde342f01b
commit 8f98bab89f
6 changed files with 59 additions and 38 deletions

View file

@ -17,14 +17,16 @@ This config manages **two VPS hosts** today:
| key | role | VPS | branch | domain | notes | | key | role | VPS | branch | domain | notes |
|--------|--------|-------------------------|-----------|-------------------|------------------------------------| |--------|--------|-------------------------|-----------|-------------------|------------------------------------|
| `prod` | prod | NEW 48 GB / 12-core box (`169.58.46.181`) | `release` (of the APP repo — see `app_image_tag`) | `thermograph.org` | Caddy TLS; sized up (8/8/4/16g) | | `prod` | prod | NEW 48 GB / 12-core box (`169.58.46.181`) | `release` (of the APP repos — see `backend_image_tag` / `frontend_image_tag`) | `thermograph.org` | Caddy TLS; sized up (8/8/4/16g) |
| `beta` | beta | old box `75.119.132.91` | `main` (of the APP repo) | `beta.thermograph.org` | Caddy TLS; also hosts Forgejo | | `beta` | beta | old box `75.119.132.91` | `main` (of the APP repo) | `beta.thermograph.org` | Caddy TLS; also hosts Forgejo |
Each host's own checkout on disk (`app_dir`, `git_branch`) is **this infra Each host's own checkout on disk (`app_dir`, `git_branch`) is **this infra
repo**, not the app repo — the "branch" column above is which app-repo tag a repo**, not the app repo — the "branch" column above is which app-repo tag a
host is meant to track conceptually; the actual pinned version is host is meant to track conceptually; the actual pinned versions are
`var.hosts[*].app_image_tag` (e.g. `"sha-<12 hex>"`), since the host has no app `var.hosts[*].backend_image_tag` / `frontend_image_tag` (e.g. `"sha-<12 hex>"` each —
checkout to derive a tag from. The LAN dev server is **out of scope here** — it the app is two separately-published images, `emi/thermograph-backend/app` and
`emi/thermograph-frontend/app`), since the host has no app checkout to derive a tag
from. The LAN dev server is **out of scope here** — it
builds from source via the app repo's `deploy/deploy-dev.sh` (a self-hosted builds from source via the app repo's `deploy/deploy-dev.sh` (a self-hosted
Forgejo Actions runner), not Terraform. Forgejo Actions runner), not Terraform.
@ -41,7 +43,8 @@ Per host, over SSH provisioners, Terraform:
`/etc/thermograph.env` from the SOPS+age vault — **Terraform itself never sees or `/etc/thermograph.env` from the SOPS+age vault — **Terraform itself never sees or
carries an app secret** (see "Secrets" below); carries an app secret** (see "Secrets" below);
- for a host **with** a domain, installs a rendered Caddyfile and reloads Caddy; - for a host **with** a domain, installs a rendered Caddyfile and reloads Caddy;
- brings the stack up on the explicit `app_image_tag`: `docker login` to the registry, - brings the stack up on the explicit per-service tags (exports `BACKEND_IMAGE_TAG` /
`FRONTEND_IMAGE_TAG` for the compose file): `docker login` to the registry,
`docker compose <-f each compose file> pull backend frontend`, then `docker compose <-f each compose file> pull backend frontend`, then
`... up -d --remove-orphans`, running docker as root with both env files sourced; `... up -d --remove-orphans`, running docker as root with both env files sourced;
- health-checks `http://127.0.0.1:8137/`. - health-checks `http://127.0.0.1:8137/`.
@ -134,7 +137,8 @@ pinned across machines.
`terraform apply` runs `remote-exec` **on the server**: it resets the checkout to the `terraform apply` runs `remote-exec` **on the server**: it resets the checkout to the
branch, renders topology config + secrets, and runs `docker compose pull && up -d` branch, renders topology config + secrets, and runs `docker compose pull && up -d`
(pulling the pinned `app_image_tag` and recreating containers — a brief app restart). (pulling the pinned `backend_image_tag` / `frontend_image_tag` and recreating
containers — a brief app restart).
Against the live production host this is a real deploy. Review the plan, apply in a Against the live production host this is a real deploy. Review the plan, apply in a
maintenance window, and prefer `-target` to touch one host at a time. maintenance window, and prefer `-target` to touch one host at a time.

View file

@ -92,7 +92,8 @@ module "host" {
ssh_private_key_path = each.value.ssh_private_key_path ssh_private_key_path = each.value.ssh_private_key_path
role = each.value.role role = each.value.role
git_branch = each.value.git_branch git_branch = each.value.git_branch
app_image_tag = each.value.app_image_tag backend_image_tag = each.value.backend_image_tag
frontend_image_tag = each.value.frontend_image_tag
domain = each.value.domain domain = each.value.domain
compose_files = each.value.compose_files compose_files = each.value.compose_files
app_dir = each.value.app_dir app_dir = each.value.app_dir

View file

@ -73,7 +73,8 @@ resource "null_resource" "host" {
compose_flags = local.compose_flags compose_flags = local.compose_flags
caddy_sha = sha256(local.caddy_content) caddy_sha = sha256(local.caddy_content)
branch = var.git_branch branch = var.git_branch
app_image_tag = var.app_image_tag backend_image_tag = var.backend_image_tag
frontend_image_tag = var.frontend_image_tag
app_dir = var.app_dir app_dir = var.app_dir
sizing = "${var.workers}/${var.app_cpus}/${var.db_cpus}/${var.db_memory}/${var.timescaledb_tag}" sizing = "${var.workers}/${var.app_cpus}/${var.db_cpus}/${var.db_memory}/${var.timescaledb_tag}"
# Re-provision when the archive self-hosting config changes. The rclone.conf is # Re-provision when the archive self-hosting config changes. The rclone.conf is
@ -235,10 +236,13 @@ resource "null_resource" "host" {
# 3. Install /etc/thermograph-topology.env, render /etc/thermograph.env from the # 3. Install /etc/thermograph-topology.env, render /etc/thermograph.env from the
# SOPS+age vault (deploy/render-secrets.sh, now part of this same checkout), # SOPS+age vault (deploy/render-secrets.sh, now part of this same checkout),
# then bring the stack up on the EXPLICIT app image tag there's no app # then bring the stack up on the EXPLICIT per-service app image tags there's no
# checkout on this host to derive a tag from (see var.app_image_tag). docker # app checkout on this host to derive a tag from (see var.backend_image_tag /
# runs as root (sources both env files in the same shell) so it never depends # frontend_image_tag). The compose file reads BACKEND_IMAGE_TAG / FRONTEND_IMAGE_TAG
# on the docker group membership taking effect in this session. # (for emi/thermograph-backend/app and emi/thermograph-frontend/app), so we export
# those, not the old single IMAGE_PATH/IMAGE_TAG. docker runs as root (sources both
# env files in the same shell) so it never depends on the docker group membership
# taking effect in this session.
provisioner "remote-exec" { provisioner "remote-exec" {
inline = [ inline = [
<<-EOT <<-EOT
@ -252,7 +256,7 @@ resource "null_resource" "host" {
. deploy/render-secrets.sh . deploy/render-secrets.sh
render_thermograph_secrets ${var.app_dir} render_thermograph_secrets ${var.app_dir}
set -a; . /etc/thermograph-topology.env; . /etc/thermograph.env; set +a set -a; . /etc/thermograph-topology.env; . /etc/thermograph.env; set +a
export REGISTRY_HOST="git.thermograph.org" IMAGE_PATH="emi/thermograph/app" IMAGE_TAG="${var.app_image_tag}" export REGISTRY_HOST="git.thermograph.org" BACKEND_IMAGE_TAG="${var.backend_image_tag}" FRONTEND_IMAGE_TAG="${var.frontend_image_tag}"
echo "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" --username emi --password-stdin echo "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" --username emi --password-stdin
docker compose ${local.compose_flags} pull backend frontend docker compose ${local.compose_flags} pull backend frontend
docker compose ${local.compose_flags} up -d --remove-orphans docker compose ${local.compose_flags} up -d --remove-orphans

View file

@ -26,12 +26,17 @@ variable "role" {
} }
variable "git_branch" { variable "git_branch" {
description = "This INFRA repo's branch the host checkout is reset to (independent of which app image is deployed — see app_image_tag)." description = "This INFRA repo's branch the host checkout is reset to (independent of which app images are deployed — see backend_image_tag / frontend_image_tag)."
type = string type = string
} }
variable "app_image_tag" { variable "backend_image_tag" {
description = "App image tag to pull, e.g. \"sha-<12 hex>\" (build-push.yml's tag for the app-repo commit) or a semver tag. The host has no app-repo checkout to derive this from, so it's always explicit." description = "Backend image tag to pull (emi/thermograph-backend/app), e.g. \"sha-<12 hex>\" (build-push.yml's tag for the backend-repo commit) or a semver tag. The host has no app-repo checkout to derive this from, so it's always explicit."
type = string
}
variable "frontend_image_tag" {
description = "Frontend image tag to pull (emi/thermograph-frontend/app), e.g. \"sha-<12 hex>\" (build-push.yml's tag for the frontend-repo commit) or a semver tag. Always explicit, same as backend_image_tag."
type = string type = string
} }

View file

@ -20,8 +20,9 @@ hosts = {
ssh_user = "agent" ssh_user = "agent"
ssh_private_key_path = "~/.ssh/thermograph_agent_ed25519" ssh_private_key_path = "~/.ssh/thermograph_agent_ed25519"
role = "prod" role = "prod"
git_branch = "main" # this INFRA repo's branch -- see app_image_tag for the app version git_branch = "main" # this INFRA repo's branch -- see *_image_tag for the app versions
app_image_tag = "REPLACE_WITH_APP_IMAGE_TAG" # e.g. "sha-abcdef012345" -- from build-push.yml on the app repo backend_image_tag = "REPLACE_WITH_BACKEND_IMAGE_TAG" # e.g. "sha-abcdef012345" -- from thermograph-backend build-push.yml
frontend_image_tag = "REPLACE_WITH_FRONTEND_IMAGE_TAG" # e.g. "sha-012345abcdef" -- from thermograph-frontend build-push.yml
domain = "thermograph.org" # Caddy TLS in front, app on loopback domain = "thermograph.org" # Caddy TLS in front, app on loopback
compose_files = ["docker-compose.yml"] compose_files = ["docker-compose.yml"]
app_dir = "/opt/thermograph" app_dir = "/opt/thermograph"
@ -43,7 +44,8 @@ hosts = {
ssh_private_key_path = "~/.ssh/thermograph_agent_ed25519" ssh_private_key_path = "~/.ssh/thermograph_agent_ed25519"
role = "beta" role = "beta"
git_branch = "main" git_branch = "main"
app_image_tag = "REPLACE_WITH_APP_IMAGE_TAG" backend_image_tag = "REPLACE_WITH_BACKEND_IMAGE_TAG"
frontend_image_tag = "REPLACE_WITH_FRONTEND_IMAGE_TAG"
# No public domain by default: no Caddy/TLS, firewall opens the app port. NOTE: # No public domain by default: no Caddy/TLS, firewall opens the app port. NOTE:
# with compose_files = ["docker-compose.yml"] the app binds 127.0.0.1 only, so # with compose_files = ["docker-compose.yml"] the app binds 127.0.0.1 only, so
# until you either set a domain (e.g. "beta.thermograph.org", which fronts it with # until you either set a domain (e.g. "beta.thermograph.org", which fronts it with
@ -68,7 +70,8 @@ hosts = {
# ssh_private_key_path = "~/.ssh/thermograph_agent_ed25519" # ssh_private_key_path = "~/.ssh/thermograph_agent_ed25519"
# role = "uat" # role = "uat"
# git_branch = "main" # git_branch = "main"
# app_image_tag = "REPLACE_WITH_APP_IMAGE_TAG" # backend_image_tag = "REPLACE_WITH_BACKEND_IMAGE_TAG"
# frontend_image_tag = "REPLACE_WITH_FRONTEND_IMAGE_TAG"
# domain = "" # domain = ""
# compose_files = ["docker-compose.yml"] # compose_files = ["docker-compose.yml"]
# app_dir = "/opt/thermograph" # app_dir = "/opt/thermograph"
@ -89,7 +92,8 @@ hosts = {
# ssh_private_key_path = "~/.ssh/thermograph_agent_ed25519" # ssh_private_key_path = "~/.ssh/thermograph_agent_ed25519"
# role = "uat" # role = "uat"
# git_branch = "main" # git_branch = "main"
# app_image_tag = "REPLACE_WITH_APP_IMAGE_TAG" # backend_image_tag = "REPLACE_WITH_BACKEND_IMAGE_TAG"
# frontend_image_tag = "REPLACE_WITH_FRONTEND_IMAGE_TAG"
# size = "nano" # size = "nano"
# } # }
# } # }

View file

@ -24,14 +24,16 @@ variable "hosts" {
ssh_private_key_path = string # path to the private key for that user ssh_private_key_path = string # path to the private key for that user
role = string # "prod" | "beta" (informational + outputs) role = string # "prod" | "beta" (informational + outputs)
git_branch = string # this INFRA repo's branch the checkout is reset to git_branch = string # this INFRA repo's branch the checkout is reset to
# Which app image to run, e.g. "sha-<12 hex>" (matches build-push.yml's tag for # Which app images to run, e.g. "sha-<12 hex>" (each matching build-push.yml's tag
# the app-repo commit build-push.yml built) or a semver tag on a release push. # for the commit that app repo built) or a semver tag on a release push. The app is
# Required, no default: since the host's checkout is this infra repo, not the app # TWO separately-published images now emi/thermograph-backend/app and
# repo, there is no "current commit" to derive a tag from it must be explicit. # emi/thermograph-frontend/app pinned independently. Required, no default: the
# Bump this (via a normal tfvars edit + apply) whenever the app repo ships a # host's checkout is this infra repo, not the app repos, so there's no "current
# commit you want this host running; the infra repo's own git_branch is # commit" to derive a tag from; both must be explicit. Bump these (via a normal
# independent of that and rarely needs to change. # tfvars edit + apply) whenever an app repo ships a commit you want this host
app_image_tag = string # running; the infra repo's own git_branch is independent and rarely needs to change.
backend_image_tag = string
frontend_image_tag = string
domain = optional(string, "") # public domain; "" => no Caddy/TLS domain = optional(string, "") # public domain; "" => no Caddy/TLS
compose_files = optional(list(string), ["docker-compose.yml"]) compose_files = optional(list(string), ["docker-compose.yml"])
app_dir = optional(string, "/opt/thermograph") # checkout path on the host app_dir = optional(string, "/opt/thermograph") # checkout path on the host
@ -73,7 +75,8 @@ variable "gcp_hosts" {
ssh_private_key_path = string # path to the matching PRIVATE key (for the module's provisioner) ssh_private_key_path = string # path to the matching PRIVATE key (for the module's provisioner)
role = string role = string
git_branch = string git_branch = string
app_image_tag = string backend_image_tag = string
frontend_image_tag = string
domain = optional(string, "") domain = optional(string, "")
compose_files = optional(list(string), ["docker-compose.yml"]) compose_files = optional(list(string), ["docker-compose.yml"])
app_dir = optional(string, "/opt/thermograph") app_dir = optional(string, "/opt/thermograph")
@ -84,7 +87,7 @@ variable "gcp_hosts" {
} }
variable "repo_url" { variable "repo_url" {
description = "Git remote to clone from when a host has no checkout yet. Points at THIS repo (thermograph-infra) now, not the app repo -- the app's own source is never checked out on a host; only its published registry images are pulled (see var.hosts[*].app_image_tag). thermograph-infra is a private repo, so this typically needs embedded read credentials, e.g. a Forgejo deploy token: \"https://<token-name>:<token>@git.thermograph.org/emi/thermograph-infra.git\"." description = "Git remote to clone from when a host has no checkout yet. Points at THIS repo (thermograph-infra) now, not the app repo -- the app's own source is never checked out on a host; only its published registry images are pulled (see var.hosts[*].backend_image_tag / frontend_image_tag). thermograph-infra is a private repo, so this typically needs embedded read credentials, e.g. a Forgejo deploy token: \"https://<token-name>:<token>@git.thermograph.org/emi/thermograph-infra.git\"."
type = string type = string
default = "https://git.thermograph.org/emi/thermograph-infra.git" default = "https://git.thermograph.org/emi/thermograph-infra.git"
sensitive = true sensitive = true