thermograph/infra/deploy/forgejo/docker-stack.yml

158 lines
7.3 KiB
YAML
Raw Normal View History

Add agent VPS access, a 2-node Docker Swarm, and Forgejo CI/CD (#234) Three additive infrastructure layers on top of the two VPS boxes Terraform already provisions (prod: new 48 GB/12-core box, thermograph.org; beta: old VPS, 75.119.132.91). None of this touches backend/, Dockerfile, docker-compose*.yml, terraform/, or deploy/db/ — that stays owned by the app-containerization work in flight elsewhere; this is strictly the layer on top. See INFRA.md for the full runbook and order of operations. - deploy/provision-agent-access.sh: a dedicated, auditable full-sudo login (not raw root) for agent-driven ops — passwordless sudo under a distinct username, sshd hardened to key-only auth, auditd logging every root-effective command. One line to revoke. - deploy/swarm/: a 2-node Swarm (prod=manager, beta=worker) joined over a WireGuard tunnel rather than trusting the public internet with the overlay data plane, which Docker's own guidance says should never face it directly. Swarm ports locked to the tunnel interface once joined. This cluster's only workload is Forgejo — it does not orchestrate the Terraform-managed app deploys, so nothing here can strand the app's single-writer database. - deploy/forgejo/ + .forgejo/workflows/: Forgejo + Traefik + a Docker-in-Docker-sandboxed runner as a Swarm stack pinned to beta, plus Forgejo Actions workflows mirroring .github/workflows/*.yml. The custom auto-merge workflow step is dropped — it existed only to work around GitHub's paywalled branch protection on private free-tier repos, which Forgejo has no such tier for; native "auto merge when checks succeed" replaces it, and as a real git push (unlike GitHub's non-triggering token-merge) it fires the LAN deploy naturally with no double-trigger logic needed. appleboy/ssh-action is referenced by full URL (not mirrored on Forgejo's default action registry); actions/checkout and actions/setup-python resolve unchanged. Migration is mirror-first: the GitHub repo import and workflow files land here, but cutting deploy secrets over and retiring GitHub happens only after verification (INFRA.md 3d) — GitHub stays live as a fallback throughout. One flagged, unresolved mismatch: deploy.yml still triggers on `main`, but terraform.tfvars.example names prod's deploy branch `release`. Left as a faithful mirror rather than guessed at — reconcile with whoever's driving Terraform/deploy.
2026-07-21 00:36:39 +00:00
# Forgejo (self-hosted Git + CI/CD) as a Docker Swarm stack — the only workload
# this Swarm cluster runs (see deploy/swarm/README.md). Deliberately separate
# from the Terraform-managed docker-compose.yml that runs the Thermograph app
# itself: this stack's only job is Forgejo and its container registry.
Add agent VPS access, a 2-node Docker Swarm, and Forgejo CI/CD (#234) Three additive infrastructure layers on top of the two VPS boxes Terraform already provisions (prod: new 48 GB/12-core box, thermograph.org; beta: old VPS, 75.119.132.91). None of this touches backend/, Dockerfile, docker-compose*.yml, terraform/, or deploy/db/ — that stays owned by the app-containerization work in flight elsewhere; this is strictly the layer on top. See INFRA.md for the full runbook and order of operations. - deploy/provision-agent-access.sh: a dedicated, auditable full-sudo login (not raw root) for agent-driven ops — passwordless sudo under a distinct username, sshd hardened to key-only auth, auditd logging every root-effective command. One line to revoke. - deploy/swarm/: a 2-node Swarm (prod=manager, beta=worker) joined over a WireGuard tunnel rather than trusting the public internet with the overlay data plane, which Docker's own guidance says should never face it directly. Swarm ports locked to the tunnel interface once joined. This cluster's only workload is Forgejo — it does not orchestrate the Terraform-managed app deploys, so nothing here can strand the app's single-writer database. - deploy/forgejo/ + .forgejo/workflows/: Forgejo + Traefik + a Docker-in-Docker-sandboxed runner as a Swarm stack pinned to beta, plus Forgejo Actions workflows mirroring .github/workflows/*.yml. The custom auto-merge workflow step is dropped — it existed only to work around GitHub's paywalled branch protection on private free-tier repos, which Forgejo has no such tier for; native "auto merge when checks succeed" replaces it, and as a real git push (unlike GitHub's non-triggering token-merge) it fires the LAN deploy naturally with no double-trigger logic needed. appleboy/ssh-action is referenced by full URL (not mirrored on Forgejo's default action registry); actions/checkout and actions/setup-python resolve unchanged. Migration is mirror-first: the GitHub repo import and workflow files land here, but cutting deploy secrets over and retiring GitHub happens only after verification (INFRA.md 3d) — GitHub stays live as a fallback throughout. One flagged, unresolved mismatch: deploy.yml still triggers on `main`, but terraform.tfvars.example names prod's deploy branch `release`. Left as a faithful mirror rather than guessed at — reconcile with whoever's driving Terraform/deploy.
2026-07-21 00:36:39 +00:00
#
# The Actions RUNNER is deliberately NOT a service in this stack. Per
# thermograph-docs/runbooks/implementation-handoff.md (Track B step 5), the canonical
# design registers the runner on the desktop node as a plain systemd service
# (deploy/forgejo/register-lan-runner.sh) — the same place the pre-Forgejo
# GitHub self-hosted runner already lived, not a Swarm-scheduled container.
#
infra: split the estate into vps1/vps2, moving beta next to prod and dev onto vps1 Environments stop being machines. Until now each box WAS an environment -- "beta" named both a deploy target and a host, "the desktop" named both the operator's computer and the dev server -- so every path could assume one environment per host and hardcode /opt/thermograph, /etc/thermograph.env and ports 8137/8080. That assumption ends here: vps1 75.119.132.91 Forgejo, Grafana/Loki, the portfolio site, and DEV (own Postgres, mesh-only on 10.10.0.2:8137) vps2 169.58.46.181 PROD and BETA as two Swarm stacks sharing one TimescaleDB instance, plus Centralis, Postfix, backups desktop AI model hosting + flex Swarm capacity, no environment Nothing live has moved; the ordered cutover is in infra/deploy/RUNBOOK-vps1-vps2-cutover.md. deploy/env-topology.sh is the single source of truth: env -> host, checkout, branch, deploy mode, stack name, env file, LB ports, DB role/database, service prefix. THERMOGRAPH_ENV is the input, and on vps2 it is the only thing distinguishing a beta deploy from a prod one -- deploy.sh refuses to run when it disagrees with the checkout it was invoked from. The host-wide secrets-env and deploy-mode markers survive only as a fallback for a single-environment box. Beta gets its own stack file rather than an overlay (a merge cannot REMOVE the db service, and beta having no database of its own is the point). Its services are prefixed beta-*: Swarm registers a service's short name as a DNS alias on every network it joins, so two stacks both calling a service `web` on the shared data network would let prod's frontend resolve a beta task. Prod's stack, env and LB config are untouched. One Postgres, two databases with two roles: deploy/db/provision-env-db.sh creates thermograph_beta (NOSUPERUSER, owns only its own database, CONNECT revoked from PUBLIC) plus a <role>_ro for ad-hoc queries, and refuses to run for the environment that owns the instance -- doing so would demote prod's bootstrap superuser. Fixes that co-residency would otherwise have broken silently: - CI secrets are keyed by host (VPS1_SSH_*, VPS2_SSH_*). SSH_* meant "beta" and also "the Forgejo box" because those shared a machine; that conflation is what once had the prod backup dumping beta. - The nightly backup dumps BOTH application databases to separate off-box prefixes, and fails loudly on a missing one instead of skipping it. - Alloy stopped deriving the `host` label from the node -- on vps2 that would have filed every beta line as prod, feeding prod's alert rules with beta's traffic. It is now derived per source, with a new `node` label for the machine, and beta's log volume is mounted via a vps2-only overlay. - ops/dbq.sh, ops/iceberg.sh and the secrets seed scripts derived their SSH target and env-file path from the topology instead of hardcoding beta to 75.119.132.91 -- which is vps1's address now. - Dev's overlay binds DEV_BIND_ADDR (loopback by default, the mesh address on vps1) instead of 0.0.0.0: correct for a home LAN box, a public exposure of unreviewed branches on a VPS. Terraform's hosts variable is keyed by machine with a nested environments map, and main.tf flattens (host, environment) pairs so two environments on one box cannot share a checkout. Caddy's single reference config is split per host. Docs, onboarding and the runbooks are updated throughout, including the security rationales that co-residency makes false: separate SSH credentials no longer put a host boundary between beta and prod, and the boundary that remains is the database and the filesystem.
2026-07-25 22:01:29 +00:00
# No Traefik here. Forgejo is pinned to vps1 (node.labels.role == forge)
# because that's what was chosen, and vps1 is ALSO the emigriffith.dev portfolio
# host — Caddy already owns its ports 80/443 (see /etc/caddy/Caddyfile on that
# box). A second reverse proxy binding those same ports would either fail to
# start or fight Caddy. Instead: forgejo's web port publishes to
infra: split the estate into vps1/vps2, moving beta next to prod and dev onto vps1 Environments stop being machines. Until now each box WAS an environment -- "beta" named both a deploy target and a host, "the desktop" named both the operator's computer and the dev server -- so every path could assume one environment per host and hardcode /opt/thermograph, /etc/thermograph.env and ports 8137/8080. That assumption ends here: vps1 75.119.132.91 Forgejo, Grafana/Loki, the portfolio site, and DEV (own Postgres, mesh-only on 10.10.0.2:8137) vps2 169.58.46.181 PROD and BETA as two Swarm stacks sharing one TimescaleDB instance, plus Centralis, Postfix, backups desktop AI model hosting + flex Swarm capacity, no environment Nothing live has moved; the ordered cutover is in infra/deploy/RUNBOOK-vps1-vps2-cutover.md. deploy/env-topology.sh is the single source of truth: env -> host, checkout, branch, deploy mode, stack name, env file, LB ports, DB role/database, service prefix. THERMOGRAPH_ENV is the input, and on vps2 it is the only thing distinguishing a beta deploy from a prod one -- deploy.sh refuses to run when it disagrees with the checkout it was invoked from. The host-wide secrets-env and deploy-mode markers survive only as a fallback for a single-environment box. Beta gets its own stack file rather than an overlay (a merge cannot REMOVE the db service, and beta having no database of its own is the point). Its services are prefixed beta-*: Swarm registers a service's short name as a DNS alias on every network it joins, so two stacks both calling a service `web` on the shared data network would let prod's frontend resolve a beta task. Prod's stack, env and LB config are untouched. One Postgres, two databases with two roles: deploy/db/provision-env-db.sh creates thermograph_beta (NOSUPERUSER, owns only its own database, CONNECT revoked from PUBLIC) plus a <role>_ro for ad-hoc queries, and refuses to run for the environment that owns the instance -- doing so would demote prod's bootstrap superuser. Fixes that co-residency would otherwise have broken silently: - CI secrets are keyed by host (VPS1_SSH_*, VPS2_SSH_*). SSH_* meant "beta" and also "the Forgejo box" because those shared a machine; that conflation is what once had the prod backup dumping beta. - The nightly backup dumps BOTH application databases to separate off-box prefixes, and fails loudly on a missing one instead of skipping it. - Alloy stopped deriving the `host` label from the node -- on vps2 that would have filed every beta line as prod, feeding prod's alert rules with beta's traffic. It is now derived per source, with a new `node` label for the machine, and beta's log volume is mounted via a vps2-only overlay. - ops/dbq.sh, ops/iceberg.sh and the secrets seed scripts derived their SSH target and env-file path from the topology instead of hardcoding beta to 75.119.132.91 -- which is vps1's address now. - Dev's overlay binds DEV_BIND_ADDR (loopback by default, the mesh address on vps1) instead of 0.0.0.0: correct for a home LAN box, a public exposure of unreviewed branches on a VPS. Terraform's hosts variable is keyed by machine with a nested environments map, and main.tf flattens (host, environment) pairs so two environments on one box cannot share a checkout. Caddy's single reference config is split per host. Docs, onboarding and the runbooks are updated throughout, including the security rationales that co-residency makes false: separate SSH credentials no longer put a host boundary between beta and prod, and the boundary that remains is the database and the filesystem.
2026-07-25 22:01:29 +00:00
# 127.0.0.1:3080 only (host-local, mode: host), and vps1's existing Caddy gets
# a new site block reverse-proxying git.thermograph.org -> 127.0.0.1:3080,
infra: split the estate into vps1/vps2, moving beta next to prod and dev onto vps1 Environments stop being machines. Until now each box WAS an environment -- "beta" named both a deploy target and a host, "the desktop" named both the operator's computer and the dev server -- so every path could assume one environment per host and hardcode /opt/thermograph, /etc/thermograph.env and ports 8137/8080. That assumption ends here: vps1 75.119.132.91 Forgejo, Grafana/Loki, the portfolio site, and DEV (own Postgres, mesh-only on 10.10.0.2:8137) vps2 169.58.46.181 PROD and BETA as two Swarm stacks sharing one TimescaleDB instance, plus Centralis, Postfix, backups desktop AI model hosting + flex Swarm capacity, no environment Nothing live has moved; the ordered cutover is in infra/deploy/RUNBOOK-vps1-vps2-cutover.md. deploy/env-topology.sh is the single source of truth: env -> host, checkout, branch, deploy mode, stack name, env file, LB ports, DB role/database, service prefix. THERMOGRAPH_ENV is the input, and on vps2 it is the only thing distinguishing a beta deploy from a prod one -- deploy.sh refuses to run when it disagrees with the checkout it was invoked from. The host-wide secrets-env and deploy-mode markers survive only as a fallback for a single-environment box. Beta gets its own stack file rather than an overlay (a merge cannot REMOVE the db service, and beta having no database of its own is the point). Its services are prefixed beta-*: Swarm registers a service's short name as a DNS alias on every network it joins, so two stacks both calling a service `web` on the shared data network would let prod's frontend resolve a beta task. Prod's stack, env and LB config are untouched. One Postgres, two databases with two roles: deploy/db/provision-env-db.sh creates thermograph_beta (NOSUPERUSER, owns only its own database, CONNECT revoked from PUBLIC) plus a <role>_ro for ad-hoc queries, and refuses to run for the environment that owns the instance -- doing so would demote prod's bootstrap superuser. Fixes that co-residency would otherwise have broken silently: - CI secrets are keyed by host (VPS1_SSH_*, VPS2_SSH_*). SSH_* meant "beta" and also "the Forgejo box" because those shared a machine; that conflation is what once had the prod backup dumping beta. - The nightly backup dumps BOTH application databases to separate off-box prefixes, and fails loudly on a missing one instead of skipping it. - Alloy stopped deriving the `host` label from the node -- on vps2 that would have filed every beta line as prod, feeding prod's alert rules with beta's traffic. It is now derived per source, with a new `node` label for the machine, and beta's log volume is mounted via a vps2-only overlay. - ops/dbq.sh, ops/iceberg.sh and the secrets seed scripts derived their SSH target and env-file path from the topology instead of hardcoding beta to 75.119.132.91 -- which is vps1's address now. - Dev's overlay binds DEV_BIND_ADDR (loopback by default, the mesh address on vps1) instead of 0.0.0.0: correct for a home LAN box, a public exposure of unreviewed branches on a VPS. Terraform's hosts variable is keyed by machine with a nested environments map, and main.tf flattens (host, environment) pairs so two environments on one box cannot share a checkout. Caddy's single reference config is split per host. Docs, onboarding and the runbooks are updated throughout, including the security rationales that co-residency makes false: separate SSH credentials no longer put a host boundary between beta and prod, and the boundary that remains is the database and the filesystem.
2026-07-25 22:01:29 +00:00
# same pattern as its other blocks. TLS is Caddy's existing automatic-HTTPS
# (HTTP-01), not a second ACME flow.
#
infra: split the estate into vps1/vps2, moving beta next to prod and dev onto vps1 Environments stop being machines. Until now each box WAS an environment -- "beta" named both a deploy target and a host, "the desktop" named both the operator's computer and the dev server -- so every path could assume one environment per host and hardcode /opt/thermograph, /etc/thermograph.env and ports 8137/8080. That assumption ends here: vps1 75.119.132.91 Forgejo, Grafana/Loki, the portfolio site, and DEV (own Postgres, mesh-only on 10.10.0.2:8137) vps2 169.58.46.181 PROD and BETA as two Swarm stacks sharing one TimescaleDB instance, plus Centralis, Postfix, backups desktop AI model hosting + flex Swarm capacity, no environment Nothing live has moved; the ordered cutover is in infra/deploy/RUNBOOK-vps1-vps2-cutover.md. deploy/env-topology.sh is the single source of truth: env -> host, checkout, branch, deploy mode, stack name, env file, LB ports, DB role/database, service prefix. THERMOGRAPH_ENV is the input, and on vps2 it is the only thing distinguishing a beta deploy from a prod one -- deploy.sh refuses to run when it disagrees with the checkout it was invoked from. The host-wide secrets-env and deploy-mode markers survive only as a fallback for a single-environment box. Beta gets its own stack file rather than an overlay (a merge cannot REMOVE the db service, and beta having no database of its own is the point). Its services are prefixed beta-*: Swarm registers a service's short name as a DNS alias on every network it joins, so two stacks both calling a service `web` on the shared data network would let prod's frontend resolve a beta task. Prod's stack, env and LB config are untouched. One Postgres, two databases with two roles: deploy/db/provision-env-db.sh creates thermograph_beta (NOSUPERUSER, owns only its own database, CONNECT revoked from PUBLIC) plus a <role>_ro for ad-hoc queries, and refuses to run for the environment that owns the instance -- doing so would demote prod's bootstrap superuser. Fixes that co-residency would otherwise have broken silently: - CI secrets are keyed by host (VPS1_SSH_*, VPS2_SSH_*). SSH_* meant "beta" and also "the Forgejo box" because those shared a machine; that conflation is what once had the prod backup dumping beta. - The nightly backup dumps BOTH application databases to separate off-box prefixes, and fails loudly on a missing one instead of skipping it. - Alloy stopped deriving the `host` label from the node -- on vps2 that would have filed every beta line as prod, feeding prod's alert rules with beta's traffic. It is now derived per source, with a new `node` label for the machine, and beta's log volume is mounted via a vps2-only overlay. - ops/dbq.sh, ops/iceberg.sh and the secrets seed scripts derived their SSH target and env-file path from the topology instead of hardcoding beta to 75.119.132.91 -- which is vps1's address now. - Dev's overlay binds DEV_BIND_ADDR (loopback by default, the mesh address on vps1) instead of 0.0.0.0: correct for a home LAN box, a public exposure of unreviewed branches on a VPS. Terraform's hosts variable is keyed by machine with a nested environments map, and main.tf flattens (host, environment) pairs so two environments on one box cannot share a checkout. Caddy's single reference config is split per host. Docs, onboarding and the runbooks are updated throughout, including the security rationales that co-residency makes false: separate SSH credentials no longer put a host boundary between beta and prod, and the boundary that remains is the database and the filesystem.
2026-07-25 22:01:29 +00:00
# Deploy from the manager node (vps2, which runs prod), after all three nodes
# (vps1, vps2, desktop) have joined the swarm and vps1 is labeled role=forge:
Add agent VPS access, a 2-node Docker Swarm, and Forgejo CI/CD (#234) Three additive infrastructure layers on top of the two VPS boxes Terraform already provisions (prod: new 48 GB/12-core box, thermograph.org; beta: old VPS, 75.119.132.91). None of this touches backend/, Dockerfile, docker-compose*.yml, terraform/, or deploy/db/ — that stays owned by the app-containerization work in flight elsewhere; this is strictly the layer on top. See INFRA.md for the full runbook and order of operations. - deploy/provision-agent-access.sh: a dedicated, auditable full-sudo login (not raw root) for agent-driven ops — passwordless sudo under a distinct username, sshd hardened to key-only auth, auditd logging every root-effective command. One line to revoke. - deploy/swarm/: a 2-node Swarm (prod=manager, beta=worker) joined over a WireGuard tunnel rather than trusting the public internet with the overlay data plane, which Docker's own guidance says should never face it directly. Swarm ports locked to the tunnel interface once joined. This cluster's only workload is Forgejo — it does not orchestrate the Terraform-managed app deploys, so nothing here can strand the app's single-writer database. - deploy/forgejo/ + .forgejo/workflows/: Forgejo + Traefik + a Docker-in-Docker-sandboxed runner as a Swarm stack pinned to beta, plus Forgejo Actions workflows mirroring .github/workflows/*.yml. The custom auto-merge workflow step is dropped — it existed only to work around GitHub's paywalled branch protection on private free-tier repos, which Forgejo has no such tier for; native "auto merge when checks succeed" replaces it, and as a real git push (unlike GitHub's non-triggering token-merge) it fires the LAN deploy naturally with no double-trigger logic needed. appleboy/ssh-action is referenced by full URL (not mirrored on Forgejo's default action registry); actions/checkout and actions/setup-python resolve unchanged. Migration is mirror-first: the GitHub repo import and workflow files land here, but cutting deploy secrets over and retiring GitHub happens only after verification (INFRA.md 3d) — GitHub stays live as a fallback throughout. One flagged, unresolved mismatch: deploy.yml still triggers on `main`, but terraform.tfvars.example names prod's deploy branch `release`. Left as a faithful mirror rather than guessed at — reconcile with whoever's driving Terraform/deploy.
2026-07-21 00:36:39 +00:00
#
# docker stack deploy -c deploy/forgejo/docker-stack.yml forgejo
#
# Requires this Swarm secret to exist first (see deploy/forgejo/README.md):
# forgejo_db_password
Add agent VPS access, a 2-node Docker Swarm, and Forgejo CI/CD (#234) Three additive infrastructure layers on top of the two VPS boxes Terraform already provisions (prod: new 48 GB/12-core box, thermograph.org; beta: old VPS, 75.119.132.91). None of this touches backend/, Dockerfile, docker-compose*.yml, terraform/, or deploy/db/ — that stays owned by the app-containerization work in flight elsewhere; this is strictly the layer on top. See INFRA.md for the full runbook and order of operations. - deploy/provision-agent-access.sh: a dedicated, auditable full-sudo login (not raw root) for agent-driven ops — passwordless sudo under a distinct username, sshd hardened to key-only auth, auditd logging every root-effective command. One line to revoke. - deploy/swarm/: a 2-node Swarm (prod=manager, beta=worker) joined over a WireGuard tunnel rather than trusting the public internet with the overlay data plane, which Docker's own guidance says should never face it directly. Swarm ports locked to the tunnel interface once joined. This cluster's only workload is Forgejo — it does not orchestrate the Terraform-managed app deploys, so nothing here can strand the app's single-writer database. - deploy/forgejo/ + .forgejo/workflows/: Forgejo + Traefik + a Docker-in-Docker-sandboxed runner as a Swarm stack pinned to beta, plus Forgejo Actions workflows mirroring .github/workflows/*.yml. The custom auto-merge workflow step is dropped — it existed only to work around GitHub's paywalled branch protection on private free-tier repos, which Forgejo has no such tier for; native "auto merge when checks succeed" replaces it, and as a real git push (unlike GitHub's non-triggering token-merge) it fires the LAN deploy naturally with no double-trigger logic needed. appleboy/ssh-action is referenced by full URL (not mirrored on Forgejo's default action registry); actions/checkout and actions/setup-python resolve unchanged. Migration is mirror-first: the GitHub repo import and workflow files land here, but cutting deploy secrets over and retiring GitHub happens only after verification (INFRA.md 3d) — GitHub stays live as a fallback throughout. One flagged, unresolved mismatch: deploy.yml still triggers on `main`, but terraform.tfvars.example names prod's deploy branch `release`. Left as a faithful mirror rather than guessed at — reconcile with whoever's driving Terraform/deploy.
2026-07-21 00:36:39 +00:00
#
# Registry exposure (hazard #15 in thermograph-docs/runbooks/hop1-forgejo-registry-cutover.md):
# resolved as the runbook's first listed option — serve Git/UI publicly but
# firewall the /v2/ registry API paths so only WireGuard-mesh clients can
# reach them. That's implemented in the Caddy site block (see
# deploy/forgejo/README.md), not here — nothing in this stack file is
infra: split the estate into vps1/vps2, moving beta next to prod and dev onto vps1 Environments stop being machines. Until now each box WAS an environment -- "beta" named both a deploy target and a host, "the desktop" named both the operator's computer and the dev server -- so every path could assume one environment per host and hardcode /opt/thermograph, /etc/thermograph.env and ports 8137/8080. That assumption ends here: vps1 75.119.132.91 Forgejo, Grafana/Loki, the portfolio site, and DEV (own Postgres, mesh-only on 10.10.0.2:8137) vps2 169.58.46.181 PROD and BETA as two Swarm stacks sharing one TimescaleDB instance, plus Centralis, Postfix, backups desktop AI model hosting + flex Swarm capacity, no environment Nothing live has moved; the ordered cutover is in infra/deploy/RUNBOOK-vps1-vps2-cutover.md. deploy/env-topology.sh is the single source of truth: env -> host, checkout, branch, deploy mode, stack name, env file, LB ports, DB role/database, service prefix. THERMOGRAPH_ENV is the input, and on vps2 it is the only thing distinguishing a beta deploy from a prod one -- deploy.sh refuses to run when it disagrees with the checkout it was invoked from. The host-wide secrets-env and deploy-mode markers survive only as a fallback for a single-environment box. Beta gets its own stack file rather than an overlay (a merge cannot REMOVE the db service, and beta having no database of its own is the point). Its services are prefixed beta-*: Swarm registers a service's short name as a DNS alias on every network it joins, so two stacks both calling a service `web` on the shared data network would let prod's frontend resolve a beta task. Prod's stack, env and LB config are untouched. One Postgres, two databases with two roles: deploy/db/provision-env-db.sh creates thermograph_beta (NOSUPERUSER, owns only its own database, CONNECT revoked from PUBLIC) plus a <role>_ro for ad-hoc queries, and refuses to run for the environment that owns the instance -- doing so would demote prod's bootstrap superuser. Fixes that co-residency would otherwise have broken silently: - CI secrets are keyed by host (VPS1_SSH_*, VPS2_SSH_*). SSH_* meant "beta" and also "the Forgejo box" because those shared a machine; that conflation is what once had the prod backup dumping beta. - The nightly backup dumps BOTH application databases to separate off-box prefixes, and fails loudly on a missing one instead of skipping it. - Alloy stopped deriving the `host` label from the node -- on vps2 that would have filed every beta line as prod, feeding prod's alert rules with beta's traffic. It is now derived per source, with a new `node` label for the machine, and beta's log volume is mounted via a vps2-only overlay. - ops/dbq.sh, ops/iceberg.sh and the secrets seed scripts derived their SSH target and env-file path from the topology instead of hardcoding beta to 75.119.132.91 -- which is vps1's address now. - Dev's overlay binds DEV_BIND_ADDR (loopback by default, the mesh address on vps1) instead of 0.0.0.0: correct for a home LAN box, a public exposure of unreviewed branches on a VPS. Terraform's hosts variable is keyed by machine with a nested environments map, and main.tf flattens (host, environment) pairs so two environments on one box cannot share a checkout. Caddy's single reference config is split per host. Docs, onboarding and the runbooks are updated throughout, including the security rationales that co-residency makes false: separate SSH credentials no longer put a host boundary between beta and prod, and the boundary that remains is the database and the filesystem.
2026-07-25 22:01:29 +00:00
# registry-specific, the restriction lives entirely in Caddy's config on vps1.
Add agent VPS access, a 2-node Docker Swarm, and Forgejo CI/CD (#234) Three additive infrastructure layers on top of the two VPS boxes Terraform already provisions (prod: new 48 GB/12-core box, thermograph.org; beta: old VPS, 75.119.132.91). None of this touches backend/, Dockerfile, docker-compose*.yml, terraform/, or deploy/db/ — that stays owned by the app-containerization work in flight elsewhere; this is strictly the layer on top. See INFRA.md for the full runbook and order of operations. - deploy/provision-agent-access.sh: a dedicated, auditable full-sudo login (not raw root) for agent-driven ops — passwordless sudo under a distinct username, sshd hardened to key-only auth, auditd logging every root-effective command. One line to revoke. - deploy/swarm/: a 2-node Swarm (prod=manager, beta=worker) joined over a WireGuard tunnel rather than trusting the public internet with the overlay data plane, which Docker's own guidance says should never face it directly. Swarm ports locked to the tunnel interface once joined. This cluster's only workload is Forgejo — it does not orchestrate the Terraform-managed app deploys, so nothing here can strand the app's single-writer database. - deploy/forgejo/ + .forgejo/workflows/: Forgejo + Traefik + a Docker-in-Docker-sandboxed runner as a Swarm stack pinned to beta, plus Forgejo Actions workflows mirroring .github/workflows/*.yml. The custom auto-merge workflow step is dropped — it existed only to work around GitHub's paywalled branch protection on private free-tier repos, which Forgejo has no such tier for; native "auto merge when checks succeed" replaces it, and as a real git push (unlike GitHub's non-triggering token-merge) it fires the LAN deploy naturally with no double-trigger logic needed. appleboy/ssh-action is referenced by full URL (not mirrored on Forgejo's default action registry); actions/checkout and actions/setup-python resolve unchanged. Migration is mirror-first: the GitHub repo import and workflow files land here, but cutting deploy secrets over and retiring GitHub happens only after verification (INFRA.md 3d) — GitHub stays live as a fallback throughout. One flagged, unresolved mismatch: deploy.yml still triggers on `main`, but terraform.tfvars.example names prod's deploy branch `release`. Left as a faithful mirror rather than guessed at — reconcile with whoever's driving Terraform/deploy.
2026-07-21 00:36:39 +00:00
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: forgejo
POSTGRES_DB: forgejo
POSTGRES_PASSWORD_FILE: /run/secrets/forgejo_db_password
secrets: [forgejo_db_password]
volumes:
- forgejo_db:/var/lib/postgresql/data
networks: [forgejo_net]
deploy:
placement:
constraints: [node.labels.role == forge]
resources:
limits:
cpus: "${FORGEJO_DB_CPUS:-1}"
memory: ${FORGEJO_DB_MEMORY:-1g}
Add agent VPS access, a 2-node Docker Swarm, and Forgejo CI/CD (#234) Three additive infrastructure layers on top of the two VPS boxes Terraform already provisions (prod: new 48 GB/12-core box, thermograph.org; beta: old VPS, 75.119.132.91). None of this touches backend/, Dockerfile, docker-compose*.yml, terraform/, or deploy/db/ — that stays owned by the app-containerization work in flight elsewhere; this is strictly the layer on top. See INFRA.md for the full runbook and order of operations. - deploy/provision-agent-access.sh: a dedicated, auditable full-sudo login (not raw root) for agent-driven ops — passwordless sudo under a distinct username, sshd hardened to key-only auth, auditd logging every root-effective command. One line to revoke. - deploy/swarm/: a 2-node Swarm (prod=manager, beta=worker) joined over a WireGuard tunnel rather than trusting the public internet with the overlay data plane, which Docker's own guidance says should never face it directly. Swarm ports locked to the tunnel interface once joined. This cluster's only workload is Forgejo — it does not orchestrate the Terraform-managed app deploys, so nothing here can strand the app's single-writer database. - deploy/forgejo/ + .forgejo/workflows/: Forgejo + Traefik + a Docker-in-Docker-sandboxed runner as a Swarm stack pinned to beta, plus Forgejo Actions workflows mirroring .github/workflows/*.yml. The custom auto-merge workflow step is dropped — it existed only to work around GitHub's paywalled branch protection on private free-tier repos, which Forgejo has no such tier for; native "auto merge when checks succeed" replaces it, and as a real git push (unlike GitHub's non-triggering token-merge) it fires the LAN deploy naturally with no double-trigger logic needed. appleboy/ssh-action is referenced by full URL (not mirrored on Forgejo's default action registry); actions/checkout and actions/setup-python resolve unchanged. Migration is mirror-first: the GitHub repo import and workflow files land here, but cutting deploy secrets over and retiring GitHub happens only after verification (INFRA.md 3d) — GitHub stays live as a fallback throughout. One flagged, unresolved mismatch: deploy.yml still triggers on `main`, but terraform.tfvars.example names prod's deploy branch `release`. Left as a faithful mirror rather than guessed at — reconcile with whoever's driving Terraform/deploy.
2026-07-21 00:36:39 +00:00
restart_policy:
condition: on-failure
forgejo:
image: codeberg.org/forgejo/forgejo:9-rootless
depends_on: [db]
environment:
FORGEJO__database__DB_TYPE: postgres
FORGEJO__database__HOST: db:5432
FORGEJO__database__NAME: forgejo
FORGEJO__database__USER: forgejo
FORGEJO__server__DOMAIN: "${FORGEJO_DOMAIN:-git.thermograph.org}"
FORGEJO__server__ROOT_URL: "https://${FORGEJO_DOMAIN:-git.thermograph.org}/"
FORGEJO__server__SSH_PORT: "2222"
# Without this, Forgejo starts no SSH server at all despite SSH_PORT
# being set — git@ clones get "connection refused", not a slow failure.
FORGEJO__server__START_SSH_SERVER: "true"
# Config is fully supplied via env, so lock the install wizard rather
# than leave it open on the public internet waiting for someone to
# complete it first (DB config alone does NOT imply this).
FORGEJO__security__INSTALL_LOCK: "true"
Add agent VPS access, a 2-node Docker Swarm, and Forgejo CI/CD (#234) Three additive infrastructure layers on top of the two VPS boxes Terraform already provisions (prod: new 48 GB/12-core box, thermograph.org; beta: old VPS, 75.119.132.91). None of this touches backend/, Dockerfile, docker-compose*.yml, terraform/, or deploy/db/ — that stays owned by the app-containerization work in flight elsewhere; this is strictly the layer on top. See INFRA.md for the full runbook and order of operations. - deploy/provision-agent-access.sh: a dedicated, auditable full-sudo login (not raw root) for agent-driven ops — passwordless sudo under a distinct username, sshd hardened to key-only auth, auditd logging every root-effective command. One line to revoke. - deploy/swarm/: a 2-node Swarm (prod=manager, beta=worker) joined over a WireGuard tunnel rather than trusting the public internet with the overlay data plane, which Docker's own guidance says should never face it directly. Swarm ports locked to the tunnel interface once joined. This cluster's only workload is Forgejo — it does not orchestrate the Terraform-managed app deploys, so nothing here can strand the app's single-writer database. - deploy/forgejo/ + .forgejo/workflows/: Forgejo + Traefik + a Docker-in-Docker-sandboxed runner as a Swarm stack pinned to beta, plus Forgejo Actions workflows mirroring .github/workflows/*.yml. The custom auto-merge workflow step is dropped — it existed only to work around GitHub's paywalled branch protection on private free-tier repos, which Forgejo has no such tier for; native "auto merge when checks succeed" replaces it, and as a real git push (unlike GitHub's non-triggering token-merge) it fires the LAN deploy naturally with no double-trigger logic needed. appleboy/ssh-action is referenced by full URL (not mirrored on Forgejo's default action registry); actions/checkout and actions/setup-python resolve unchanged. Migration is mirror-first: the GitHub repo import and workflow files land here, but cutting deploy secrets over and retiring GitHub happens only after verification (INFRA.md 3d) — GitHub stays live as a fallback throughout. One flagged, unresolved mismatch: deploy.yml still triggers on `main`, but terraform.tfvars.example names prod's deploy branch `release`. Left as a faithful mirror rather than guessed at — reconcile with whoever's driving Terraform/deploy.
2026-07-21 00:36:39 +00:00
# Actions on: repo/org/user-level runners register against this instance.
FORGEJO__actions__ENABLED: "true"
# --- Access model: OAuth-only login, multi-user via an approval gate ---
# Login is Google SSO only. Forgejo 9.0.3 has no setting to hide the
# password sign-in form, so "OAuth-only" is enforced operationally: every
# account is given an unusable (random) password and no NEW local account
# can be created (ALLOW_ONLY_EXTERNAL_REGISTRATION). ENABLE_PASSWORD_SIGNIN_FORM
# is set anyway — ignored on 9.0.3, effective if the instance is upgraded.
# Auto-registration stays ON so a Google login auto-links (by verified email)
# to a PRE-CREATED account (`forgejo admin user create --username X --email
# X@gmail.com --random-password`). A single required-claim-value can't
# allowlist more than one email, so instead of pinning one address the
# required-claim-value is CLEARED (a DB change on the auth source, persisted
# in the forgejo_data volume — not re-applied from here) and every NEW signup
# is gated: a stranger who signs in with Google lands INACTIVE
# (REGISTER_MANUAL_CONFIRM) and RESTRICTED (DEFAULT_USER_IS_RESTRICTED)
# pending admin approval, so an un-provisioned Google user gets zero access.
FORGEJO__oauth2_client__ACCOUNT_LINKING: "auto"
FORGEJO__oauth2_client__ENABLE_AUTO_REGISTRATION: "true"
FORGEJO__service__ALLOW_ONLY_EXTERNAL_REGISTRATION: "true"
FORGEJO__service__ENABLE_PASSWORD_SIGNIN_FORM: "false"
FORGEJO__service__REGISTER_MANUAL_CONFIRM: "true"
FORGEJO__service__DEFAULT_USER_IS_RESTRICTED: "true"
infra: split the estate into vps1/vps2, moving beta next to prod and dev onto vps1 Environments stop being machines. Until now each box WAS an environment -- "beta" named both a deploy target and a host, "the desktop" named both the operator's computer and the dev server -- so every path could assume one environment per host and hardcode /opt/thermograph, /etc/thermograph.env and ports 8137/8080. That assumption ends here: vps1 75.119.132.91 Forgejo, Grafana/Loki, the portfolio site, and DEV (own Postgres, mesh-only on 10.10.0.2:8137) vps2 169.58.46.181 PROD and BETA as two Swarm stacks sharing one TimescaleDB instance, plus Centralis, Postfix, backups desktop AI model hosting + flex Swarm capacity, no environment Nothing live has moved; the ordered cutover is in infra/deploy/RUNBOOK-vps1-vps2-cutover.md. deploy/env-topology.sh is the single source of truth: env -> host, checkout, branch, deploy mode, stack name, env file, LB ports, DB role/database, service prefix. THERMOGRAPH_ENV is the input, and on vps2 it is the only thing distinguishing a beta deploy from a prod one -- deploy.sh refuses to run when it disagrees with the checkout it was invoked from. The host-wide secrets-env and deploy-mode markers survive only as a fallback for a single-environment box. Beta gets its own stack file rather than an overlay (a merge cannot REMOVE the db service, and beta having no database of its own is the point). Its services are prefixed beta-*: Swarm registers a service's short name as a DNS alias on every network it joins, so two stacks both calling a service `web` on the shared data network would let prod's frontend resolve a beta task. Prod's stack, env and LB config are untouched. One Postgres, two databases with two roles: deploy/db/provision-env-db.sh creates thermograph_beta (NOSUPERUSER, owns only its own database, CONNECT revoked from PUBLIC) plus a <role>_ro for ad-hoc queries, and refuses to run for the environment that owns the instance -- doing so would demote prod's bootstrap superuser. Fixes that co-residency would otherwise have broken silently: - CI secrets are keyed by host (VPS1_SSH_*, VPS2_SSH_*). SSH_* meant "beta" and also "the Forgejo box" because those shared a machine; that conflation is what once had the prod backup dumping beta. - The nightly backup dumps BOTH application databases to separate off-box prefixes, and fails loudly on a missing one instead of skipping it. - Alloy stopped deriving the `host` label from the node -- on vps2 that would have filed every beta line as prod, feeding prod's alert rules with beta's traffic. It is now derived per source, with a new `node` label for the machine, and beta's log volume is mounted via a vps2-only overlay. - ops/dbq.sh, ops/iceberg.sh and the secrets seed scripts derived their SSH target and env-file path from the topology instead of hardcoding beta to 75.119.132.91 -- which is vps1's address now. - Dev's overlay binds DEV_BIND_ADDR (loopback by default, the mesh address on vps1) instead of 0.0.0.0: correct for a home LAN box, a public exposure of unreviewed branches on a VPS. Terraform's hosts variable is keyed by machine with a nested environments map, and main.tf flattens (host, environment) pairs so two environments on one box cannot share a checkout. Caddy's single reference config is split per host. Docs, onboarding and the runbooks are updated throughout, including the security rationales that co-residency makes false: separate SSH credentials no longer put a host boundary between beta and prod, and the boundary that remains is the database and the filesystem.
2026-07-25 22:01:29 +00:00
# Outbound mail via vps2's Postfix null client (prod's box) over the
# WireGuard mesh (10.10.0.1:25 — mynetworks permits vps1, 10.10.0.2,
# where this Forgejo runs); self-signed cert on :25, so trust it.
# Send-only; used for admin/approval and notification mail.
FORGEJO__mailer__ENABLED: "true"
FORGEJO__mailer__PROTOCOL: "smtp"
FORGEJO__mailer__SMTP_ADDR: "10.10.0.1"
FORGEJO__mailer__SMTP_PORT: "25"
FORGEJO__mailer__FORCE_TRUST_SERVER_CERT: "true"
FORGEJO__mailer__FROM: "Thermograph Git <no-reply@thermograph.org>"
Add agent VPS access, a 2-node Docker Swarm, and Forgejo CI/CD (#234) Three additive infrastructure layers on top of the two VPS boxes Terraform already provisions (prod: new 48 GB/12-core box, thermograph.org; beta: old VPS, 75.119.132.91). None of this touches backend/, Dockerfile, docker-compose*.yml, terraform/, or deploy/db/ — that stays owned by the app-containerization work in flight elsewhere; this is strictly the layer on top. See INFRA.md for the full runbook and order of operations. - deploy/provision-agent-access.sh: a dedicated, auditable full-sudo login (not raw root) for agent-driven ops — passwordless sudo under a distinct username, sshd hardened to key-only auth, auditd logging every root-effective command. One line to revoke. - deploy/swarm/: a 2-node Swarm (prod=manager, beta=worker) joined over a WireGuard tunnel rather than trusting the public internet with the overlay data plane, which Docker's own guidance says should never face it directly. Swarm ports locked to the tunnel interface once joined. This cluster's only workload is Forgejo — it does not orchestrate the Terraform-managed app deploys, so nothing here can strand the app's single-writer database. - deploy/forgejo/ + .forgejo/workflows/: Forgejo + Traefik + a Docker-in-Docker-sandboxed runner as a Swarm stack pinned to beta, plus Forgejo Actions workflows mirroring .github/workflows/*.yml. The custom auto-merge workflow step is dropped — it existed only to work around GitHub's paywalled branch protection on private free-tier repos, which Forgejo has no such tier for; native "auto merge when checks succeed" replaces it, and as a real git push (unlike GitHub's non-triggering token-merge) it fires the LAN deploy naturally with no double-trigger logic needed. appleboy/ssh-action is referenced by full URL (not mirrored on Forgejo's default action registry); actions/checkout and actions/setup-python resolve unchanged. Migration is mirror-first: the GitHub repo import and workflow files land here, but cutting deploy secrets over and retiring GitHub happens only after verification (INFRA.md 3d) — GitHub stays live as a fallback throughout. One flagged, unresolved mismatch: deploy.yml still triggers on `main`, but terraform.tfvars.example names prod's deploy branch `release`. Left as a faithful mirror rather than guessed at — reconcile with whoever's driving Terraform/deploy.
2026-07-21 00:36:39 +00:00
# Gitea/Forgejo's app.ini env-mapping honors a __FILE suffix to read a
# value from a file instead of the literal env var — same convention as
# the official Postgres image's POSTGRES_PASSWORD_FILE above.
FORGEJO__database__PASSWD__FILE: /run/secrets/forgejo_db_password
secrets:
- source: forgejo_db_password
target: forgejo_db_password
volumes:
- forgejo_data:/var/lib/gitea
networks: [forgejo_net]
ports:
# SSH for git@ clones — published on whichever node the task lands on
infra: split the estate into vps1/vps2, moving beta next to prod and dev onto vps1 Environments stop being machines. Until now each box WAS an environment -- "beta" named both a deploy target and a host, "the desktop" named both the operator's computer and the dev server -- so every path could assume one environment per host and hardcode /opt/thermograph, /etc/thermograph.env and ports 8137/8080. That assumption ends here: vps1 75.119.132.91 Forgejo, Grafana/Loki, the portfolio site, and DEV (own Postgres, mesh-only on 10.10.0.2:8137) vps2 169.58.46.181 PROD and BETA as two Swarm stacks sharing one TimescaleDB instance, plus Centralis, Postfix, backups desktop AI model hosting + flex Swarm capacity, no environment Nothing live has moved; the ordered cutover is in infra/deploy/RUNBOOK-vps1-vps2-cutover.md. deploy/env-topology.sh is the single source of truth: env -> host, checkout, branch, deploy mode, stack name, env file, LB ports, DB role/database, service prefix. THERMOGRAPH_ENV is the input, and on vps2 it is the only thing distinguishing a beta deploy from a prod one -- deploy.sh refuses to run when it disagrees with the checkout it was invoked from. The host-wide secrets-env and deploy-mode markers survive only as a fallback for a single-environment box. Beta gets its own stack file rather than an overlay (a merge cannot REMOVE the db service, and beta having no database of its own is the point). Its services are prefixed beta-*: Swarm registers a service's short name as a DNS alias on every network it joins, so two stacks both calling a service `web` on the shared data network would let prod's frontend resolve a beta task. Prod's stack, env and LB config are untouched. One Postgres, two databases with two roles: deploy/db/provision-env-db.sh creates thermograph_beta (NOSUPERUSER, owns only its own database, CONNECT revoked from PUBLIC) plus a <role>_ro for ad-hoc queries, and refuses to run for the environment that owns the instance -- doing so would demote prod's bootstrap superuser. Fixes that co-residency would otherwise have broken silently: - CI secrets are keyed by host (VPS1_SSH_*, VPS2_SSH_*). SSH_* meant "beta" and also "the Forgejo box" because those shared a machine; that conflation is what once had the prod backup dumping beta. - The nightly backup dumps BOTH application databases to separate off-box prefixes, and fails loudly on a missing one instead of skipping it. - Alloy stopped deriving the `host` label from the node -- on vps2 that would have filed every beta line as prod, feeding prod's alert rules with beta's traffic. It is now derived per source, with a new `node` label for the machine, and beta's log volume is mounted via a vps2-only overlay. - ops/dbq.sh, ops/iceberg.sh and the secrets seed scripts derived their SSH target and env-file path from the topology instead of hardcoding beta to 75.119.132.91 -- which is vps1's address now. - Dev's overlay binds DEV_BIND_ADDR (loopback by default, the mesh address on vps1) instead of 0.0.0.0: correct for a home LAN box, a public exposure of unreviewed branches on a VPS. Terraform's hosts variable is keyed by machine with a nested environments map, and main.tf flattens (host, environment) pairs so two environments on one box cannot share a checkout. Caddy's single reference config is split per host. Docs, onboarding and the runbooks are updated throughout, including the security rationales that co-residency makes false: separate SSH credentials no longer put a host boundary between beta and prod, and the boundary that remains is the database and the filesystem.
2026-07-25 22:01:29 +00:00
# (pinned to vps1 by the placement constraint below, so effectively
# always vps1's public IP, port 2222).
Add agent VPS access, a 2-node Docker Swarm, and Forgejo CI/CD (#234) Three additive infrastructure layers on top of the two VPS boxes Terraform already provisions (prod: new 48 GB/12-core box, thermograph.org; beta: old VPS, 75.119.132.91). None of this touches backend/, Dockerfile, docker-compose*.yml, terraform/, or deploy/db/ — that stays owned by the app-containerization work in flight elsewhere; this is strictly the layer on top. See INFRA.md for the full runbook and order of operations. - deploy/provision-agent-access.sh: a dedicated, auditable full-sudo login (not raw root) for agent-driven ops — passwordless sudo under a distinct username, sshd hardened to key-only auth, auditd logging every root-effective command. One line to revoke. - deploy/swarm/: a 2-node Swarm (prod=manager, beta=worker) joined over a WireGuard tunnel rather than trusting the public internet with the overlay data plane, which Docker's own guidance says should never face it directly. Swarm ports locked to the tunnel interface once joined. This cluster's only workload is Forgejo — it does not orchestrate the Terraform-managed app deploys, so nothing here can strand the app's single-writer database. - deploy/forgejo/ + .forgejo/workflows/: Forgejo + Traefik + a Docker-in-Docker-sandboxed runner as a Swarm stack pinned to beta, plus Forgejo Actions workflows mirroring .github/workflows/*.yml. The custom auto-merge workflow step is dropped — it existed only to work around GitHub's paywalled branch protection on private free-tier repos, which Forgejo has no such tier for; native "auto merge when checks succeed" replaces it, and as a real git push (unlike GitHub's non-triggering token-merge) it fires the LAN deploy naturally with no double-trigger logic needed. appleboy/ssh-action is referenced by full URL (not mirrored on Forgejo's default action registry); actions/checkout and actions/setup-python resolve unchanged. Migration is mirror-first: the GitHub repo import and workflow files land here, but cutting deploy secrets over and retiring GitHub happens only after verification (INFRA.md 3d) — GitHub stays live as a fallback throughout. One flagged, unresolved mismatch: deploy.yml still triggers on `main`, but terraform.tfvars.example names prod's deploy branch `release`. Left as a faithful mirror rather than guessed at — reconcile with whoever's driving Terraform/deploy.
2026-07-21 00:36:39 +00:00
- target: 2222
published: 2222
protocol: tcp
mode: host
# Web UI/API. Swarm's port schema has no host_ip scoping, so this binds
infra: split the estate into vps1/vps2, moving beta next to prod and dev onto vps1 Environments stop being machines. Until now each box WAS an environment -- "beta" named both a deploy target and a host, "the desktop" named both the operator's computer and the dev server -- so every path could assume one environment per host and hardcode /opt/thermograph, /etc/thermograph.env and ports 8137/8080. That assumption ends here: vps1 75.119.132.91 Forgejo, Grafana/Loki, the portfolio site, and DEV (own Postgres, mesh-only on 10.10.0.2:8137) vps2 169.58.46.181 PROD and BETA as two Swarm stacks sharing one TimescaleDB instance, plus Centralis, Postfix, backups desktop AI model hosting + flex Swarm capacity, no environment Nothing live has moved; the ordered cutover is in infra/deploy/RUNBOOK-vps1-vps2-cutover.md. deploy/env-topology.sh is the single source of truth: env -> host, checkout, branch, deploy mode, stack name, env file, LB ports, DB role/database, service prefix. THERMOGRAPH_ENV is the input, and on vps2 it is the only thing distinguishing a beta deploy from a prod one -- deploy.sh refuses to run when it disagrees with the checkout it was invoked from. The host-wide secrets-env and deploy-mode markers survive only as a fallback for a single-environment box. Beta gets its own stack file rather than an overlay (a merge cannot REMOVE the db service, and beta having no database of its own is the point). Its services are prefixed beta-*: Swarm registers a service's short name as a DNS alias on every network it joins, so two stacks both calling a service `web` on the shared data network would let prod's frontend resolve a beta task. Prod's stack, env and LB config are untouched. One Postgres, two databases with two roles: deploy/db/provision-env-db.sh creates thermograph_beta (NOSUPERUSER, owns only its own database, CONNECT revoked from PUBLIC) plus a <role>_ro for ad-hoc queries, and refuses to run for the environment that owns the instance -- doing so would demote prod's bootstrap superuser. Fixes that co-residency would otherwise have broken silently: - CI secrets are keyed by host (VPS1_SSH_*, VPS2_SSH_*). SSH_* meant "beta" and also "the Forgejo box" because those shared a machine; that conflation is what once had the prod backup dumping beta. - The nightly backup dumps BOTH application databases to separate off-box prefixes, and fails loudly on a missing one instead of skipping it. - Alloy stopped deriving the `host` label from the node -- on vps2 that would have filed every beta line as prod, feeding prod's alert rules with beta's traffic. It is now derived per source, with a new `node` label for the machine, and beta's log volume is mounted via a vps2-only overlay. - ops/dbq.sh, ops/iceberg.sh and the secrets seed scripts derived their SSH target and env-file path from the topology instead of hardcoding beta to 75.119.132.91 -- which is vps1's address now. - Dev's overlay binds DEV_BIND_ADDR (loopback by default, the mesh address on vps1) instead of 0.0.0.0: correct for a home LAN box, a public exposure of unreviewed branches on a VPS. Terraform's hosts variable is keyed by machine with a nested environments map, and main.tf flattens (host, environment) pairs so two environments on one box cannot share a checkout. Caddy's single reference config is split per host. Docs, onboarding and the runbooks are updated throughout, including the security rationales that co-residency makes false: separate SSH credentials no longer put a host boundary between beta and prod, and the boundary that remains is the database and the filesystem.
2026-07-25 22:01:29 +00:00
# 0.0.0.0:3080 on vps1 — NOT actually localhost-only by itself. A
# DOCKER-USER iptables rule (see deploy/forgejo/README.md) is what
# actually restricts it, since Docker's own iptables rules bypass ufw
infra: split the estate into vps1/vps2, moving beta next to prod and dev onto vps1 Environments stop being machines. Until now each box WAS an environment -- "beta" named both a deploy target and a host, "the desktop" named both the operator's computer and the dev server -- so every path could assume one environment per host and hardcode /opt/thermograph, /etc/thermograph.env and ports 8137/8080. That assumption ends here: vps1 75.119.132.91 Forgejo, Grafana/Loki, the portfolio site, and DEV (own Postgres, mesh-only on 10.10.0.2:8137) vps2 169.58.46.181 PROD and BETA as two Swarm stacks sharing one TimescaleDB instance, plus Centralis, Postfix, backups desktop AI model hosting + flex Swarm capacity, no environment Nothing live has moved; the ordered cutover is in infra/deploy/RUNBOOK-vps1-vps2-cutover.md. deploy/env-topology.sh is the single source of truth: env -> host, checkout, branch, deploy mode, stack name, env file, LB ports, DB role/database, service prefix. THERMOGRAPH_ENV is the input, and on vps2 it is the only thing distinguishing a beta deploy from a prod one -- deploy.sh refuses to run when it disagrees with the checkout it was invoked from. The host-wide secrets-env and deploy-mode markers survive only as a fallback for a single-environment box. Beta gets its own stack file rather than an overlay (a merge cannot REMOVE the db service, and beta having no database of its own is the point). Its services are prefixed beta-*: Swarm registers a service's short name as a DNS alias on every network it joins, so two stacks both calling a service `web` on the shared data network would let prod's frontend resolve a beta task. Prod's stack, env and LB config are untouched. One Postgres, two databases with two roles: deploy/db/provision-env-db.sh creates thermograph_beta (NOSUPERUSER, owns only its own database, CONNECT revoked from PUBLIC) plus a <role>_ro for ad-hoc queries, and refuses to run for the environment that owns the instance -- doing so would demote prod's bootstrap superuser. Fixes that co-residency would otherwise have broken silently: - CI secrets are keyed by host (VPS1_SSH_*, VPS2_SSH_*). SSH_* meant "beta" and also "the Forgejo box" because those shared a machine; that conflation is what once had the prod backup dumping beta. - The nightly backup dumps BOTH application databases to separate off-box prefixes, and fails loudly on a missing one instead of skipping it. - Alloy stopped deriving the `host` label from the node -- on vps2 that would have filed every beta line as prod, feeding prod's alert rules with beta's traffic. It is now derived per source, with a new `node` label for the machine, and beta's log volume is mounted via a vps2-only overlay. - ops/dbq.sh, ops/iceberg.sh and the secrets seed scripts derived their SSH target and env-file path from the topology instead of hardcoding beta to 75.119.132.91 -- which is vps1's address now. - Dev's overlay binds DEV_BIND_ADDR (loopback by default, the mesh address on vps1) instead of 0.0.0.0: correct for a home LAN box, a public exposure of unreviewed branches on a VPS. Terraform's hosts variable is keyed by machine with a nested environments map, and main.tf flattens (host, environment) pairs so two environments on one box cannot share a checkout. Caddy's single reference config is split per host. Docs, onboarding and the runbooks are updated throughout, including the security rationales that co-residency makes false: separate SSH credentials no longer put a host boundary between beta and prod, and the boundary that remains is the database and the filesystem.
2026-07-25 22:01:29 +00:00
# for published ports. vps1's Caddy reverse-proxies to 127.0.0.1:3080.
- target: 3000
published: 3080
protocol: tcp
Add agent VPS access, a 2-node Docker Swarm, and Forgejo CI/CD (#234) Three additive infrastructure layers on top of the two VPS boxes Terraform already provisions (prod: new 48 GB/12-core box, thermograph.org; beta: old VPS, 75.119.132.91). None of this touches backend/, Dockerfile, docker-compose*.yml, terraform/, or deploy/db/ — that stays owned by the app-containerization work in flight elsewhere; this is strictly the layer on top. See INFRA.md for the full runbook and order of operations. - deploy/provision-agent-access.sh: a dedicated, auditable full-sudo login (not raw root) for agent-driven ops — passwordless sudo under a distinct username, sshd hardened to key-only auth, auditd logging every root-effective command. One line to revoke. - deploy/swarm/: a 2-node Swarm (prod=manager, beta=worker) joined over a WireGuard tunnel rather than trusting the public internet with the overlay data plane, which Docker's own guidance says should never face it directly. Swarm ports locked to the tunnel interface once joined. This cluster's only workload is Forgejo — it does not orchestrate the Terraform-managed app deploys, so nothing here can strand the app's single-writer database. - deploy/forgejo/ + .forgejo/workflows/: Forgejo + Traefik + a Docker-in-Docker-sandboxed runner as a Swarm stack pinned to beta, plus Forgejo Actions workflows mirroring .github/workflows/*.yml. The custom auto-merge workflow step is dropped — it existed only to work around GitHub's paywalled branch protection on private free-tier repos, which Forgejo has no such tier for; native "auto merge when checks succeed" replaces it, and as a real git push (unlike GitHub's non-triggering token-merge) it fires the LAN deploy naturally with no double-trigger logic needed. appleboy/ssh-action is referenced by full URL (not mirrored on Forgejo's default action registry); actions/checkout and actions/setup-python resolve unchanged. Migration is mirror-first: the GitHub repo import and workflow files land here, but cutting deploy secrets over and retiring GitHub happens only after verification (INFRA.md 3d) — GitHub stays live as a fallback throughout. One flagged, unresolved mismatch: deploy.yml still triggers on `main`, but terraform.tfvars.example names prod's deploy branch `release`. Left as a faithful mirror rather than guessed at — reconcile with whoever's driving Terraform/deploy.
2026-07-21 00:36:39 +00:00
mode: host
deploy:
placement:
constraints: [node.labels.role == forge]
resources:
limits:
cpus: "${FORGEJO_CPUS:-2}"
memory: ${FORGEJO_MEMORY:-2g}
Add agent VPS access, a 2-node Docker Swarm, and Forgejo CI/CD (#234) Three additive infrastructure layers on top of the two VPS boxes Terraform already provisions (prod: new 48 GB/12-core box, thermograph.org; beta: old VPS, 75.119.132.91). None of this touches backend/, Dockerfile, docker-compose*.yml, terraform/, or deploy/db/ — that stays owned by the app-containerization work in flight elsewhere; this is strictly the layer on top. See INFRA.md for the full runbook and order of operations. - deploy/provision-agent-access.sh: a dedicated, auditable full-sudo login (not raw root) for agent-driven ops — passwordless sudo under a distinct username, sshd hardened to key-only auth, auditd logging every root-effective command. One line to revoke. - deploy/swarm/: a 2-node Swarm (prod=manager, beta=worker) joined over a WireGuard tunnel rather than trusting the public internet with the overlay data plane, which Docker's own guidance says should never face it directly. Swarm ports locked to the tunnel interface once joined. This cluster's only workload is Forgejo — it does not orchestrate the Terraform-managed app deploys, so nothing here can strand the app's single-writer database. - deploy/forgejo/ + .forgejo/workflows/: Forgejo + Traefik + a Docker-in-Docker-sandboxed runner as a Swarm stack pinned to beta, plus Forgejo Actions workflows mirroring .github/workflows/*.yml. The custom auto-merge workflow step is dropped — it existed only to work around GitHub's paywalled branch protection on private free-tier repos, which Forgejo has no such tier for; native "auto merge when checks succeed" replaces it, and as a real git push (unlike GitHub's non-triggering token-merge) it fires the LAN deploy naturally with no double-trigger logic needed. appleboy/ssh-action is referenced by full URL (not mirrored on Forgejo's default action registry); actions/checkout and actions/setup-python resolve unchanged. Migration is mirror-first: the GitHub repo import and workflow files land here, but cutting deploy secrets over and retiring GitHub happens only after verification (INFRA.md 3d) — GitHub stays live as a fallback throughout. One flagged, unresolved mismatch: deploy.yml still triggers on `main`, but terraform.tfvars.example names prod's deploy branch `release`. Left as a faithful mirror rather than guessed at — reconcile with whoever's driving Terraform/deploy.
2026-07-21 00:36:39 +00:00
restart_policy:
condition: on-failure
networks:
forgejo_net:
driver: overlay
attachable: false
volumes:
forgejo_db:
forgejo_data:
secrets:
forgejo_db_password:
external: true