deploy-prod.yml 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. name: "[Prod] Deploy"
  2. on:
  3. workflow_dispatch:
  4. inputs:
  5. tag:
  6. description: "Version tag to deploy (e.g. v1.2.39). Leave blank to deploy the newest published release. 'latest' is refused."
  7. required: false
  8. default: ""
  9. jobs:
  10. deploy:
  11. runs-on: [self-hosted, Linux, X64, hetzner]
  12. steps:
  13. - name: Resolve version tag
  14. id: tag
  15. run: |
  16. TAG="${{ inputs.tag }}"
  17. if [ -z "$TAG" ]; then
  18. # Newest published release (drafts are excluded, so publishing the
  19. # release in GitHub is the gate that makes it deployable).
  20. TAG=$(curl -fsSL -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
  21. "https://api.github.com/repos/${{ github.repository }}/releases/latest" \
  22. | grep -m1 '"tag_name"' | cut -d'"' -f4)
  23. echo "Resolved newest published release: $TAG"
  24. fi
  25. if [ -z "$TAG" ] || [ "$TAG" = "latest" ]; then
  26. echo "::error::No deployable version tag ('latest' is refused; rollback needs a named target)."
  27. exit 1
  28. fi
  29. echo "value=$TAG" >> "$GITHUB_OUTPUT"
  30. - name: Log in to GitHub Container Registry
  31. run: echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
  32. - name: Create deployment files
  33. env:
  34. VIRTUAL_HOST: ${{ secrets.CLOUD_VIRTUAL_HOST }}
  35. DATABASE_URL: ${{ secrets.CLOUD_DATABASE_URL }}
  36. BETTER_AUTH_SECRET: ${{ secrets.CLOUD_BETTER_AUTH_SECRET }}
  37. APP_URL: ${{ secrets.CLOUD_APP_URL }}
  38. STRIPE_SECRET_KEY: ${{ secrets.CLOUD_STRIPE_SECRET_KEY }}
  39. STRIPE_WEBHOOK_SECRET: ${{ secrets.CLOUD_STRIPE_WEBHOOK_SECRET }}
  40. STRIPE_PRO_PRICE_ID: ${{ secrets.CLOUD_STRIPE_PRO_PRICE_ID }}
  41. STRIPE_ENTERPRISE_PRICE_ID: ${{ secrets.CLOUD_STRIPE_ENTERPRISE_PRICE_ID }}
  42. DATA_PATH: ${{ secrets.DATA_PATH }}
  43. POSTHOG_HOST: ${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }}
  44. POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}
  45. BACKUP_HEARTBEAT_TOKEN: ${{ secrets.BACKUP_HEARTBEAT_TOKEN }}
  46. INTEGRATIONS_ENCRYPTION_KEY: ${{ secrets.CLOUD_INTEGRATIONS_ENCRYPTION_KEY }}
  47. GOOGLE_INTEGRATION_CLIENT_ID: ${{ secrets.CLOUD_GOOGLE_INTEGRATION_CLIENT_ID }}
  48. GOOGLE_INTEGRATION_CLIENT_SECRET: ${{ secrets.CLOUD_GOOGLE_INTEGRATION_CLIENT_SECRET }}
  49. GOOGLE_AUTH_CLIENT_ID: ${{ secrets.CLOUD_GOOGLE_AUTH_CLIENT_ID }}
  50. GOOGLE_AUTH_CLIENT_SECRET: ${{ secrets.CLOUD_GOOGLE_AUTH_CLIENT_SECRET }}
  51. MICROSOFT_INTEGRATION_CLIENT_ID: ${{ secrets.CLOUD_MICROSOFT_INTEGRATION_CLIENT_ID }}
  52. MICROSOFT_INTEGRATION_CLIENT_SECRET: ${{ secrets.CLOUD_MICROSOFT_INTEGRATION_CLIENT_SECRET }}
  53. ZOOM_INTEGRATION_CLIENT_ID: ${{ secrets.CLOUD_ZOOM_INTEGRATION_CLIENT_ID }}
  54. ZOOM_INTEGRATION_CLIENT_SECRET: ${{ secrets.CLOUD_ZOOM_INTEGRATION_CLIENT_SECRET }}
  55. QUICKBOOKS_INTEGRATION_CLIENT_ID: ${{ secrets.CLOUD_QUICKBOOKS_INTEGRATION_CLIENT_ID }}
  56. QUICKBOOKS_INTEGRATION_CLIENT_SECRET: ${{ secrets.CLOUD_QUICKBOOKS_INTEGRATION_CLIENT_SECRET }}
  57. run: |
  58. mkdir -p $HOME/torqvoice-deploy/prod
  59. cd $HOME/torqvoice-deploy/prod
  60. env | grep -E '^(VIRTUAL_HOST|DATA_PATH|DATABASE_URL|BETTER_AUTH_SECRET|APP_URL|STRIPE_|POSTHOG_|BACKUP_|INTEGRATIONS_|GOOGLE_INTEGRATION_|GOOGLE_AUTH_|MICROSOFT_INTEGRATION_|ZOOM_INTEGRATION_|QUICKBOOKS_INTEGRATION_)' > .env
  61. cat > docker-compose.yml << 'COMPOSE'
  62. networks:
  63. proxy:
  64. external: true
  65. name: proxy
  66. services:
  67. torqvoice-app:
  68. container_name: torqvoice-app
  69. image: ghcr.io/torqvoice/torqvoice:${APP_TAG:-latest}
  70. restart: unless-stopped
  71. volumes:
  72. - ${DATA_PATH}/torqvoice_data:/app/data
  73. expose:
  74. - "3000"
  75. environment:
  76. VIRTUAL_HOST: ${VIRTUAL_HOST}
  77. VIRTUAL_PORT: "3000"
  78. LETSENCRYPT_HOST: ${VIRTUAL_HOST}
  79. DATABASE_URL: ${DATABASE_URL}
  80. BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
  81. NEXT_PUBLIC_APP_URL: ${APP_URL}
  82. TORQVOICE_MODE: cloud
  83. # Production sits behind Cloudflare and the origin only accepts its
  84. # edges, so the visitor address is the one Cloudflare says it is.
  85. # Without this every visitor shares the edge's rate-limit bucket.
  86. TRUST_CF_CONNECTING_IP: "true"
  87. STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
  88. STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
  89. STRIPE_PRO_PRICE_ID: ${STRIPE_PRO_PRICE_ID}
  90. STRIPE_ENTERPRISE_PRICE_ID: ${STRIPE_ENTERPRISE_PRICE_ID}
  91. POSTHOG_HOST: ${POSTHOG_HOST}
  92. POSTHOG_KEY: ${POSTHOG_KEY}
  93. BACKUP_HEARTBEAT_TOKEN: ${BACKUP_HEARTBEAT_TOKEN}
  94. INTEGRATIONS_ENCRYPTION_KEY: ${INTEGRATIONS_ENCRYPTION_KEY}
  95. GOOGLE_INTEGRATION_CLIENT_ID: ${GOOGLE_INTEGRATION_CLIENT_ID}
  96. GOOGLE_INTEGRATION_CLIENT_SECRET: ${GOOGLE_INTEGRATION_CLIENT_SECRET}
  97. GOOGLE_AUTH_CLIENT_ID: ${GOOGLE_AUTH_CLIENT_ID}
  98. GOOGLE_AUTH_CLIENT_SECRET: ${GOOGLE_AUTH_CLIENT_SECRET}
  99. MICROSOFT_INTEGRATION_CLIENT_ID: ${MICROSOFT_INTEGRATION_CLIENT_ID}
  100. MICROSOFT_INTEGRATION_CLIENT_SECRET: ${MICROSOFT_INTEGRATION_CLIENT_SECRET}
  101. ZOOM_INTEGRATION_CLIENT_ID: ${ZOOM_INTEGRATION_CLIENT_ID}
  102. ZOOM_INTEGRATION_CLIENT_SECRET: ${ZOOM_INTEGRATION_CLIENT_SECRET}
  103. QUICKBOOKS_INTEGRATION_CLIENT_ID: ${QUICKBOOKS_INTEGRATION_CLIENT_ID}
  104. QUICKBOOKS_INTEGRATION_CLIENT_SECRET: ${QUICKBOOKS_INTEGRATION_CLIENT_SECRET}
  105. networks:
  106. - proxy
  107. COMPOSE
  108. - name: Ensure data directory permissions
  109. run: |
  110. DATA_DIR="${{ secrets.DATA_PATH }}/torqvoice_data"
  111. mkdir -p "$DATA_DIR"
  112. docker run --rm -v "$DATA_DIR":/data alpine chown -R 1001:1001 /data
  113. - name: Check free disk space
  114. run: |
  115. MIN_GB=5
  116. DOCKER_ROOT=$(docker info --format '{{.DockerRootDir}}' 2>/dev/null || echo /var/lib/docker)
  117. AVAIL_KB=$(df -Pk "$DOCKER_ROOT" | awk 'NR==2 {print $4}')
  118. AVAIL_GB=$(awk "BEGIN {printf \"%.1f\", $AVAIL_KB/1024/1024}")
  119. echo "Free space on $DOCKER_ROOT: ${AVAIL_GB} GB (minimum ${MIN_GB} GB)"
  120. if [ "$AVAIL_KB" -lt $((MIN_GB * 1024 * 1024)) ]; then
  121. echo "::error::Not enough disk space to pull image: ${AVAIL_GB} GB free, need ${MIN_GB} GB. Aborting deploy."
  122. exit 1
  123. fi
  124. - name: Back up production database
  125. env:
  126. DATABASE_URL: ${{ secrets.CLOUD_DATABASE_URL }}
  127. run: |
  128. set -euo pipefail
  129. DB_USER=$(echo "$DATABASE_URL" | sed -E 's|^[^:]+://([^:]+):.*|\1|')
  130. DB_NAME=$(echo "$DATABASE_URL" | sed -E 's|.*/([^/?]+)(\?.*)?$|\1|')
  131. BACKUP_DIR="${{ secrets.DATA_PATH }}/db-backups/$DB_NAME"
  132. # The volume is root-owned and the runner is not root. Binding the
  133. # path into a container makes Docker create it, and the chown hands
  134. # it to the runner so the dump redirect below can write there.
  135. docker run --rm -v "$BACKUP_DIR":/b alpine chown "$(id -u):$(id -g)" /b
  136. # The Postgres data directory lives on this filesystem: filling it
  137. # takes the database down, not just the backup.
  138. MIN_GB=2
  139. AVAIL_KB=$(df -Pk "$BACKUP_DIR" | awk 'NR==2 {print $4}')
  140. if [ "$AVAIL_KB" -lt $((MIN_GB * 1024 * 1024)) ]; then
  141. echo "::error::Only $(awk "BEGIN {printf \"%.1f\", $AVAIL_KB/1024/1024}") GB free where the database lives. Refusing to dump."
  142. exit 1
  143. fi
  144. DUMP="$BACKUP_DIR/${DB_NAME}-$(date +%Y%m%d-%H%M%S)-pre-${{ steps.tag.outputs.value }}.dump"
  145. echo "Dumping $DB_NAME to $DUMP"
  146. docker exec torqvoice-db pg_dump -U "$DB_USER" -d "$DB_NAME" -Fc > "$DUMP"
  147. # A dump nobody can read is worse than no dump. No backup, no deploy.
  148. docker exec -i torqvoice-db pg_restore --list < "$DUMP" > /dev/null
  149. echo "Verified $(du -h "$DUMP" | cut -f1) backup"
  150. # Keep the 10 most recent pre-deploy dumps; hourly dumps in the same
  151. # folder belong to the backup cron and age out there.
  152. ls -t "$BACKUP_DIR"/*-pre-*.dump 2>/dev/null | tail -n +11 | xargs -r rm --
  153. - name: Deploy
  154. run: |
  155. cd $HOME/torqvoice-deploy/prod
  156. APP_TAG=${{ steps.tag.outputs.value }} docker compose up -d --pull always
  157. - name: Health check
  158. run: |
  159. echo "Waiting for prod container to be ready..."
  160. for i in $(seq 1 12); do
  161. STATUS=$(docker inspect --format='{{.State.Status}}' torqvoice-app 2>/dev/null || echo "not found")
  162. if [ "$STATUS" = "running" ]; then
  163. echo "Deployed ${{ steps.tag.outputs.value }}."
  164. exit 0
  165. fi
  166. echo "Status: $STATUS, retrying in 5s..."
  167. sleep 5
  168. done
  169. echo "::error::Container failed to start"
  170. docker logs torqvoice-app --tail 50
  171. exit 1
  172. - name: Prune old images and build cache
  173. run: docker image prune -f && docker builder prune -f