# 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 container registry, and the # ingress in front of it. # # The Actions RUNNER is deliberately NOT a service in this stack. Per # 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. An # earlier revision of this file ran the runner + a Docker-in-Docker sidecar as # Swarm services pinned to beta; that's gone now in favor of the canonical # placement. # # Deploy from the manager node (prod), after all three nodes (prod, beta, # desktop) have joined the swarm and beta is labeled role=forge: # # 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 # (forgejo_runner_token is no longer a Swarm secret — see register-lan-runner.sh, # which takes the registration token as a plain argument on the desktop instead.) # # DNS: point the Forgejo domain at prod's or beta's public IP (not the # desktop's — it isn't a stable, publicly-reachable address). Swarm's routing # mesh accepts published ports on every node and forwards to wherever the # task actually runs, so either of the two VPS IPs works. # # Registry exposure (hazard #15 in docs/runbooks/hop1-forgejo-registry-cutover.md) # is still an open call, not resolved here: this file exposes Forgejo (and its # built-in registry) publicly via Traefik/Let's Encrypt. The alternative — # mesh-only registry + DNS-01 ACME + an outbound pull-mirror — is a deliberate # design decision for whoever executes Track B, not something to guess at here. 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 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: traefik_certs: secrets: forgejo_db_password: external: true