# 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