From 4d8d2fb6cf6c7c645f7313cb12cfa3ad9f810a87 Mon Sep 17 00:00:00 2001 From: claw Date: Mon, 30 Mar 2026 15:27:52 +0200 Subject: [PATCH] feat(deploy): add VPS compose deployment workflow Co-authored-by: claw --- .github/workflows/cd-vps.yml | 194 ++++++++++++++++++++++++++++ deploy/vps/Caddyfile | 9 ++ deploy/vps/bot.env.example | 39 ++++++ deploy/vps/caddy.env.example | 2 + deploy/vps/compose.yml | 65 ++++++++++ deploy/vps/miniapp.env.example | 1 + docs/runbooks/vps-compose-deploy.md | 34 +++++ scripts/ops/vps-bootstrap-ubuntu.sh | 27 ++++ scripts/ops/vps-deploy.sh | 50 +++++++ 9 files changed, 421 insertions(+) create mode 100644 .github/workflows/cd-vps.yml create mode 100644 deploy/vps/Caddyfile create mode 100644 deploy/vps/bot.env.example create mode 100644 deploy/vps/caddy.env.example create mode 100644 deploy/vps/compose.yml create mode 100644 deploy/vps/miniapp.env.example create mode 100755 scripts/ops/vps-bootstrap-ubuntu.sh create mode 100755 scripts/ops/vps-deploy.sh diff --git a/.github/workflows/cd-vps.yml b/.github/workflows/cd-vps.yml new file mode 100644 index 0000000..fb4451d --- /dev/null +++ b/.github/workflows/cd-vps.yml @@ -0,0 +1,194 @@ +name: CD / VPS + +on: + workflow_run: + workflows: + - CI + types: + - completed + branches: + - main + workflow_dispatch: + inputs: + ref: + description: 'Git ref to deploy (branch, tag, or SHA)' + required: true + default: 'main' + +permissions: + contents: read + packages: write + +concurrency: + group: cd-vps-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.ref_name }} + cancel-in-progress: false + +jobs: + detect: + runs-on: ubuntu-latest + outputs: + eligible: ${{ steps.detect.outputs.eligible }} + ref: ${{ steps.detect.outputs.ref }} + sha: ${{ steps.detect.outputs.sha }} + steps: + - id: detect + run: | + eligible=false + ref="" + sha="" + + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + eligible=true + ref="${{ inputs.ref }}" + elif [[ "${{ github.event_name }}" == "workflow_run" && "${{ github.event.workflow_run.conclusion }}" == "success" ]]; then + eligible=true + ref="${{ github.event.workflow_run.head_sha }}" + fi + + echo "eligible=$eligible" >> "$GITHUB_OUTPUT" + echo "ref=$ref" >> "$GITHUB_OUTPUT" + echo "sha=${ref}" >> "$GITHUB_OUTPUT" + + build-bot: + runs-on: ubuntu-latest + needs: detect + if: ${{ needs.detect.outputs.eligible == 'true' }} + outputs: + image: ${{ steps.meta.outputs.image }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.detect.outputs.ref }} + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - id: meta + run: | + owner="${{ github.repository_owner }}" + owner_lc="${owner,,}" + revision="$(git rev-parse HEAD)" + image="ghcr.io/${owner_lc}/household-bot-bot:${revision}" + echo "image=$image" >> "$GITHUB_OUTPUT" + + - uses: docker/build-push-action@v6 + with: + context: . + file: apps/bot/Dockerfile + push: true + tags: | + ${{ steps.meta.outputs.image }} + platforms: linux/amd64 + provenance: false + + build-miniapp: + runs-on: ubuntu-latest + needs: detect + if: ${{ needs.detect.outputs.eligible == 'true' }} + outputs: + image: ${{ steps.meta.outputs.image }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.detect.outputs.ref }} + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - id: meta + run: | + owner="${{ github.repository_owner }}" + owner_lc="${owner,,}" + revision="$(git rev-parse HEAD)" + image="ghcr.io/${owner_lc}/household-bot-miniapp:${revision}" + echo "image=$image" >> "$GITHUB_OUTPUT" + + - uses: docker/build-push-action@v6 + with: + context: . + file: apps/miniapp/Dockerfile + push: true + tags: | + ${{ steps.meta.outputs.image }} + platforms: linux/amd64 + provenance: false + + deploy: + runs-on: ubuntu-latest + needs: [detect, build-bot, build-miniapp] + if: ${{ needs.detect.outputs.eligible == 'true' }} + env: + VPS_HOST: ${{ vars.VPS_HOST }} + VPS_USER: ${{ vars.VPS_USER || 'root' }} + VPS_PORT: ${{ vars.VPS_PORT || '22' }} + DEPLOY_ROOT: ${{ vars.VPS_DEPLOY_ROOT || '/opt/household-bot' }} + BOT_IMAGE: ${{ needs.build-bot.outputs.image }} + MINIAPP_IMAGE: ${{ needs.build-miniapp.outputs.image }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.detect.outputs.ref }} + + - name: Validate deploy config + run: | + test -n "$VPS_HOST" + test -n "$BOT_IMAGE" + test -n "$MINIAPP_IMAGE" + test -n "${{ secrets.VPS_SSH_KEY }}" + + - name: Prepare SSH + run: | + install -m 700 -d ~/.ssh + printf '%s\n' '${{ secrets.VPS_SSH_KEY }}' > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan -p "$VPS_PORT" "$VPS_HOST" >> ~/.ssh/known_hosts + + - name: Upload deploy bundle + run: | + ssh -p "$VPS_PORT" "$VPS_USER@$VPS_HOST" "mkdir -p '$DEPLOY_ROOT/app'" + tar czf - deploy/vps scripts/ops/vps-deploy.sh | \ + ssh -p "$VPS_PORT" "$VPS_USER@$VPS_HOST" "tar xzf - -C '$DEPLOY_ROOT/app'" + + - name: Run remote deploy + run: | + ssh -p "$VPS_PORT" "$VPS_USER@$VPS_HOST" \ + "export DEPLOY_ROOT='$DEPLOY_ROOT' APP_DIR='$DEPLOY_ROOT/app' ENV_DIR='$DEPLOY_ROOT/env' BOT_IMAGE='$BOT_IMAGE' MINIAPP_IMAGE='$MINIAPP_IMAGE' GHCR_USERNAME='${{ github.actor }}' GHCR_TOKEN='${{ secrets.GITHUB_TOKEN }}'; '$DEPLOY_ROOT/app/scripts/ops/vps-deploy.sh'" + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version-file: .bun-version + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Smoke check + env: + BOT_API_URL: ${{ vars.VPS_BOT_URL || 'https://household-bot.whekin.dev' }} + MINI_APP_URL: ${{ vars.VPS_MINIAPP_URL || 'https://household.whekin.dev' }} + TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + TELEGRAM_EXPECTED_WEBHOOK_URL: ${{ vars.VPS_BOT_URL || 'https://household-bot.whekin.dev' }}/webhook/telegram + run: bun run ops:deploy:smoke + + - name: Set Telegram webhook + env: + TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + TELEGRAM_WEBHOOK_SECRET: ${{ secrets.TELEGRAM_WEBHOOK_SECRET }} + TELEGRAM_WEBHOOK_URL: ${{ vars.VPS_BOT_URL || 'https://household-bot.whekin.dev' }}/webhook/telegram + run: | + if [[ -z "${TELEGRAM_BOT_TOKEN:-}" || -z "${TELEGRAM_WEBHOOK_SECRET:-}" ]]; then + echo "Skipping webhook sync: TELEGRAM_BOT_TOKEN or TELEGRAM_WEBHOOK_SECRET is missing" + exit 0 + fi + + bun run ops:telegram:webhook set diff --git a/deploy/vps/Caddyfile b/deploy/vps/Caddyfile new file mode 100644 index 0000000..bf06b3c --- /dev/null +++ b/deploy/vps/Caddyfile @@ -0,0 +1,9 @@ +{$BOT_DOMAIN} { + encode zstd gzip + reverse_proxy bot:8080 +} + +{$MINIAPP_DOMAIN} { + encode zstd gzip + reverse_proxy miniapp:8080 +} diff --git a/deploy/vps/bot.env.example b/deploy/vps/bot.env.example new file mode 100644 index 0000000..bc4a45e --- /dev/null +++ b/deploy/vps/bot.env.example @@ -0,0 +1,39 @@ +NODE_ENV=production +LOG_LEVEL=info +PORT=8080 + +DATABASE_URL=postgres://... +DB_SCHEMA=public + +TELEGRAM_BOT_TOKEN=... +TELEGRAM_WEBHOOK_SECRET=... +TELEGRAM_WEBHOOK_PATH=/webhook/telegram + +MINI_APP_URL=https://household.whekin.dev +MINI_APP_ALLOWED_ORIGINS=https://household.whekin.dev + +OPENAI_API_KEY= +PURCHASE_PARSER_MODEL=gpt-4o-mini +ASSISTANT_MODEL=gpt-4o-mini +TOPIC_PROCESSOR_MODEL=gpt-4o-mini +TOPIC_PROCESSOR_TIMEOUT_MS=10000 +ASSISTANT_TIMEOUT_MS=20000 +ASSISTANT_MEMORY_MAX_TURNS=12 +ASSISTANT_RATE_LIMIT_BURST=5 +ASSISTANT_RATE_LIMIT_BURST_WINDOW_MS=60000 +ASSISTANT_RATE_LIMIT_ROLLING=50 +ASSISTANT_RATE_LIMIT_ROLLING_WINDOW_MS=86400000 + +SCHEDULER_SHARED_SECRET=replace-with-random-secret +SCHEDULED_DISPATCH_PROVIDER=self-hosted + +# Keep for cloud compatibility / fallback. +SCHEDULER_OIDC_ALLOWED_EMAILS= +SCHEDULED_DISPATCH_PUBLIC_BASE_URL= +GCP_SCHEDULED_DISPATCH_PROJECT_ID= +GCP_SCHEDULED_DISPATCH_LOCATION= +GCP_SCHEDULED_DISPATCH_QUEUE= +AWS_SCHEDULED_DISPATCH_REGION= +AWS_SCHEDULED_DISPATCH_TARGET_LAMBDA_ARN= +AWS_SCHEDULED_DISPATCH_ROLE_ARN= +AWS_SCHEDULED_DISPATCH_GROUP_NAME= diff --git a/deploy/vps/caddy.env.example b/deploy/vps/caddy.env.example new file mode 100644 index 0000000..afb11ee --- /dev/null +++ b/deploy/vps/caddy.env.example @@ -0,0 +1,2 @@ +BOT_DOMAIN=household-bot.whekin.dev +MINIAPP_DOMAIN=household.whekin.dev diff --git a/deploy/vps/compose.yml b/deploy/vps/compose.yml new file mode 100644 index 0000000..f45acdf --- /dev/null +++ b/deploy/vps/compose.yml @@ -0,0 +1,65 @@ +services: + bot: + image: ${BOT_IMAGE:?set BOT_IMAGE} + restart: unless-stopped + env_file: + - ${ENV_DIR:-/opt/household-bot/env}/bot.env + healthcheck: + test: ['CMD', 'bun', '-e', "fetch('http://127.0.0.1:' + (process.env.PORT ?? '8080') + '/healthz').then((res) => process.exit(res.ok ? 0 : 1)).catch(() => process.exit(1))"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + + miniapp: + image: ${MINIAPP_IMAGE:?set MINIAPP_IMAGE} + restart: unless-stopped + env_file: + - ${ENV_DIR:-/opt/household-bot/env}/miniapp.env + depends_on: + bot: + condition: service_healthy + + scheduler: + image: ${BOT_IMAGE:?set BOT_IMAGE} + restart: unless-stopped + command: ['bun', 'apps/bot/dist/scheduler-runner.js'] + env_file: + - ${ENV_DIR:-/opt/household-bot/env}/bot.env + environment: + BOT_INTERNAL_BASE_URL: http://bot:8080 + SCHEDULER_POLL_INTERVAL_MS: ${SCHEDULER_POLL_INTERVAL_MS:-60000} + SCHEDULER_DUE_SCAN_LIMIT: ${SCHEDULER_DUE_SCAN_LIMIT:-25} + depends_on: + bot: + condition: service_healthy + + migrate: + image: ${BOT_IMAGE:?set BOT_IMAGE} + profiles: ['ops'] + env_file: + - ${ENV_DIR:-/opt/household-bot/env}/bot.env + command: ['bun', 'packages/db/dist/migrate.js'] + restart: 'no' + + caddy: + image: caddy:2.8-alpine + restart: unless-stopped + env_file: + - ${ENV_DIR:-/opt/household-bot/env}/caddy.env + depends_on: + bot: + condition: service_healthy + miniapp: + condition: service_started + ports: + - '80:80' + - '443:443' + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + +volumes: + caddy_data: + caddy_config: diff --git a/deploy/vps/miniapp.env.example b/deploy/vps/miniapp.env.example new file mode 100644 index 0000000..7f315e1 --- /dev/null +++ b/deploy/vps/miniapp.env.example @@ -0,0 +1 @@ +BOT_API_URL=https://household-bot.whekin.dev diff --git a/docs/runbooks/vps-compose-deploy.md b/docs/runbooks/vps-compose-deploy.md index 986d203..08f5465 100644 --- a/docs/runbooks/vps-compose-deploy.md +++ b/docs/runbooks/vps-compose-deploy.md @@ -78,6 +78,7 @@ Phase 1: Optional later upgrade: - add 1Password-backed rendering or injection without changing app runtime contracts +- keep the runtime contract env-file-based so 1Password remains an overlay, not a hard dependency Compatibility rule: - do not remove existing env vars for GCP/AWS paths @@ -123,3 +124,36 @@ These can be adjusted later without changing the deployment shape. 5. Add GitHub Actions VPS CD 6. Validate builds/tests where practical 7. Push branch and open PR + + +## Runtime Env Files on VPS + +Expected files under `/opt/household-bot/env`: +- `bot.env` +- `miniapp.env` +- `caddy.env` + +Templates live in `deploy/vps/*.env.example`. + +## GitHub Actions Inputs / Secrets + +Recommended repository variables: +- `VPS_HOST` +- `VPS_USER` (default `root`) +- `VPS_PORT` (default `22`) +- `VPS_DEPLOY_ROOT` (default `/opt/household-bot`) +- `VPS_BOT_URL` (default `https://household-bot.whekin.dev`) +- `VPS_MINIAPP_URL` (default `https://household.whekin.dev`) + +Required repository secret: +- `VPS_SSH_KEY` + +Optional for webhook sync and smoke verification: +- `TELEGRAM_BOT_TOKEN` +- `TELEGRAM_WEBHOOK_SECRET` + +## First-Time VPS Bootstrap + +Use `scripts/ops/vps-bootstrap-ubuntu.sh` to install Docker Engine + Compose plugin on Ubuntu 24.04. + +Then place env files in `/opt/household-bot/env` before running the first deploy. diff --git a/scripts/ops/vps-bootstrap-ubuntu.sh b/scripts/ops/vps-bootstrap-ubuntu.sh new file mode 100755 index 0000000..2d3ff76 --- /dev/null +++ b/scripts/ops/vps-bootstrap-ubuntu.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$(id -u)" -ne 0 ]]; then + echo "Run as root" >&2 + exit 1 +fi + +apt-get update +apt-get install -y ca-certificates curl gnupg +install -m 0755 -d /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg +chmod a+r /etc/apt/keyrings/docker.gpg + +echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \ + $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \ + | tee /etc/apt/sources.list.d/docker.list >/dev/null + +apt-get update +apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin +systemctl enable docker +systemctl start docker + +mkdir -p /opt/household-bot/app /opt/household-bot/env + +echo "Docker installed. Next: place env files in /opt/household-bot/env and deploy the app bundle to /opt/household-bot/app" diff --git a/scripts/ops/vps-deploy.sh b/scripts/ops/vps-deploy.sh new file mode 100755 index 0000000..4eb5199 --- /dev/null +++ b/scripts/ops/vps-deploy.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +DEPLOY_ROOT="${DEPLOY_ROOT:-/opt/household-bot}" +APP_DIR="${APP_DIR:-$DEPLOY_ROOT/app}" +ENV_DIR="${ENV_DIR:-$DEPLOY_ROOT/env}" +COMPOSE_FILE="${COMPOSE_FILE:-$APP_DIR/deploy/vps/compose.yml}" + +require_file() { + local path="$1" + if [[ ! -f "$path" ]]; then + echo "Missing required file: $path" >&2 + exit 1 + fi +} + +require_var() { + local name="$1" + if [[ -z "${!name:-}" ]]; then + echo "Missing required variable: $name" >&2 + exit 1 + fi +} + +require_var BOT_IMAGE +require_var MINIAPP_IMAGE +require_file "$COMPOSE_FILE" +require_file "$ENV_DIR/bot.env" +require_file "$ENV_DIR/miniapp.env" +require_file "$ENV_DIR/caddy.env" + +if [[ -n "${GHCR_USERNAME:-}" && -n "${GHCR_TOKEN:-}" ]]; then + echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USERNAME" --password-stdin +fi + +export BOT_IMAGE +export MINIAPP_IMAGE +export ENV_DIR +export SCHEDULER_POLL_INTERVAL_MS="${SCHEDULER_POLL_INTERVAL_MS:-60000}" +export SCHEDULER_DUE_SCAN_LIMIT="${SCHEDULER_DUE_SCAN_LIMIT:-25}" + +mkdir -p "$DEPLOY_ROOT" + +docker compose -f "$COMPOSE_FILE" pull bot miniapp scheduler migrate caddy + +docker compose -f "$COMPOSE_FILE" run --rm --no-deps migrate + +docker compose -f "$COMPOSE_FILE" up -d --remove-orphans bot miniapp scheduler caddy + +docker compose -f "$COMPOSE_FILE" ps