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.
174 lines
5.9 KiB
YAML
174 lines
5.9 KiB
YAML
# 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, its runner, and the ingress in
|
|
# front of it.
|
|
#
|
|
# Deploy from the manager node (prod), after both boxes have joined the swarm
|
|
# and beta is labeled role=forge:
|
|
#
|
|
# docker stack deploy -c deploy/forgejo/docker-stack.yml forgejo
|
|
#
|
|
# Requires these Swarm secrets to exist first (see deploy/forgejo/README.md):
|
|
# forgejo_db_password, forgejo_runner_token
|
|
#
|
|
# DNS: point git.thermograph.org (or your chosen domain — see plan) at EITHER
|
|
# node's public IP. Swarm's routing mesh accepts published ports on every node
|
|
# and forwards to wherever the task actually runs, so either IP works.
|
|
|
|
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]
|
|
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"
|
|
# Actions on: repo/org/user-level runners register against this instance.
|
|
FORGEJO__actions__ENABLED: "true"
|
|
# 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
|
|
FORGEJO__database__USER: forgejo
|
|
secrets:
|
|
- source: forgejo_db_password
|
|
target: forgejo_db_password
|
|
volumes:
|
|
- forgejo_data:/var/lib/gitea
|
|
networks: [forgejo_net]
|
|
ports:
|
|
# SSH for git@ clones — Swarm's routing mesh publishes this on both nodes.
|
|
- target: 2222
|
|
published: 2222
|
|
protocol: tcp
|
|
mode: host
|
|
deploy:
|
|
placement:
|
|
constraints: [node.labels.role == forge]
|
|
restart_policy:
|
|
condition: on-failure
|
|
labels:
|
|
- traefik.enable=true
|
|
- traefik.http.routers.forgejo.rule=Host(`${FORGEJO_DOMAIN:-git.thermograph.org}`)
|
|
- traefik.http.routers.forgejo.entrypoints=websecure
|
|
- traefik.http.routers.forgejo.tls.certresolver=le
|
|
- traefik.http.services.forgejo.loadbalancer.server.port=3000
|
|
|
|
# Docker-in-Docker sidecar for the runner. Chosen over mounting the host
|
|
# docker.sock (root-equivalent host access, zero isolation) — DinD is the
|
|
# documented mitigation for a trusted single-maintainer runner that only
|
|
# ever executes this repo's own workflows.
|
|
runner-dind:
|
|
image: docker:27-dind
|
|
privileged: true
|
|
environment:
|
|
DOCKER_TLS_CERTDIR: ""
|
|
volumes:
|
|
- runner_dind_certs:/certs
|
|
networks: [forgejo_net]
|
|
deploy:
|
|
placement:
|
|
constraints: [node.labels.role == forge]
|
|
restart_policy:
|
|
condition: on-failure
|
|
|
|
runner:
|
|
image: code.forgejo.org/forgejo/runner:6
|
|
depends_on: [runner-dind, forgejo]
|
|
environment:
|
|
DOCKER_HOST: tcp://runner-dind:2375
|
|
FORGEJO_INSTANCE_URL: "https://${FORGEJO_DOMAIN:-git.thermograph.org}"
|
|
secrets:
|
|
- source: forgejo_runner_token
|
|
target: forgejo_runner_token
|
|
# Registers on first start using the token secret (see README for how to
|
|
# mint one), then just executes jobs — no docker.sock mount.
|
|
entrypoint: >
|
|
sh -c '
|
|
if [ ! -f /data/.runner ]; then
|
|
forgejo-runner register --no-interactive
|
|
--instance "$$FORGEJO_INSTANCE_URL"
|
|
--token "$$(cat /run/secrets/forgejo_runner_token)"
|
|
--name swarm-runner --labels docker:docker://node:20-bookworm;
|
|
fi;
|
|
forgejo-runner daemon
|
|
'
|
|
volumes:
|
|
- runner_data:/data
|
|
networks: [forgejo_net]
|
|
deploy:
|
|
placement:
|
|
constraints: [node.labels.role == forge]
|
|
restart_policy:
|
|
condition: on-failure
|
|
|
|
traefik:
|
|
image: traefik:v3.1
|
|
command:
|
|
- --providers.swarm.endpoint=unix:///var/run/docker.sock
|
|
- --providers.swarm.exposedbydefault=false
|
|
- --entrypoints.web.address=:80
|
|
- --entrypoints.websecure.address=:443
|
|
- --entrypoints.web.http.redirections.entrypoint.to=websecure
|
|
- --entrypoints.web.http.redirections.entrypoint.scheme=https
|
|
- --certificatesresolvers.le.acme.email=${ACME_EMAIL:-admin@thermograph.org}
|
|
- --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json
|
|
- --certificatesresolvers.le.acme.httpchallenge.entrypoint=web
|
|
ports:
|
|
- target: 80
|
|
published: 80
|
|
mode: host
|
|
- target: 443
|
|
published: 443
|
|
mode: host
|
|
volumes:
|
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
- traefik_certs:/letsencrypt
|
|
networks: [forgejo_net]
|
|
deploy:
|
|
placement:
|
|
# Pinned to the same node as the ACME cert store volume — a floating
|
|
# Traefik would re-request certs from Let's Encrypt on every
|
|
# reschedule and hit its rate limit.
|
|
constraints: [node.labels.role == forge]
|
|
restart_policy:
|
|
condition: on-failure
|
|
|
|
networks:
|
|
forgejo_net:
|
|
driver: overlay
|
|
attachable: false
|
|
|
|
volumes:
|
|
forgejo_db:
|
|
forgejo_data:
|
|
runner_data:
|
|
runner_dind_certs:
|
|
traefik_certs:
|
|
|
|
secrets:
|
|
forgejo_db_password:
|
|
external: true
|
|
forgejo_runner_token:
|
|
external: true
|