From 176c24e8f3e88bb0a3a80cd0282f5bda9b41ce89 Mon Sep 17 00:00:00 2001 From: Carlos Date: Sat, 31 Jan 2026 10:57:46 -0800 Subject: [PATCH 1/2] feat: add deployment automation for production - Remove obsolete VITE_* build-args from docker.yml (frontend now uses relative URLs) - Add deploy.yml workflow for manual production deployments via SSH - Add deployment/ directory with production docker-compose.yml and deploy.sh script - Add comprehensive README with VPS setup instructions The deployment system: - Pulls images from GHCR by tag (commit SHA or "latest") - Runs health checks before marking deployment successful - Automatically rolls back on failure - Records deployment history for audit trail Requires GitHub environment "production" with SSH credentials. Co-Authored-By: Claude Opus 4.5 --- .github/workflows/deploy.yml | 41 ++++++++ .github/workflows/docker.yml | 4 - deployment/README.md | 173 ++++++++++++++++++++++++++++++++++ deployment/deploy.sh | 118 +++++++++++++++++++++++ deployment/docker-compose.yml | 70 ++++++++++++++ 5 files changed, 402 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/deploy.yml create mode 100644 deployment/README.md create mode 100644 deployment/deploy.sh create mode 100644 deployment/docker-compose.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..c49d6745 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,41 @@ +name: Deploy to Production + +on: + workflow_dispatch: + inputs: + image_tag: + description: 'Docker image tag to deploy (commit SHA or "latest")' + required: true + default: 'latest' + +concurrency: + group: deploy-production + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + environment: production + + steps: + - name: Deploy to VPS + uses: appleboy/ssh-action@v1.2.0 + env: + IMAGE_TAG: ${{ github.event.inputs.image_tag }} + with: + host: ${{ vars.SSH_HOST }} + username: ${{ vars.SSH_USER }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + port: ${{ vars.SSH_PORT }} + envs: IMAGE_TAG + command_timeout: 10m + script: | + /opt/ccsync/scripts/deploy.sh "$IMAGE_TAG" + + - name: Deployment summary + run: | + echo "## Deployment Complete" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "- **Image tag:** ${{ github.event.inputs.image_tag }}" >> $GITHUB_STEP_SUMMARY + echo "- **Environment:** production" >> $GITHUB_STEP_SUMMARY + echo "- **Server:** https://taskwarrior-server.ccextractor.org" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 51cde6cb..50b40fae 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -37,10 +37,6 @@ jobs: ghcr.io/ccextractor/frontend:${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=max - build-args: | - VITE_BACKEND_URL=http://localhost:8000 - VITE_FRONTEND_URL=http://localhost:80 - VITE_CONTAINER_ORIGIN=http://localhost:8080 build-and-push-backend: runs-on: ubuntu-latest diff --git a/deployment/README.md b/deployment/README.md new file mode 100644 index 00000000..33a0e1ff --- /dev/null +++ b/deployment/README.md @@ -0,0 +1,173 @@ +# CCSync Production Deployment + +This directory contains the production deployment configuration for CCSync. + +## Overview + +The deployment system uses: +- **GitHub Actions** to build and push Docker images to GHCR +- **SSH-based deployment** triggered manually via GitHub Actions +- **Automatic rollback** if health checks fail + +## VPS Directory Structure + +``` +/opt/ccsync/ +├── docker-compose.yml # Copy from this directory +├── .env # Contains IMAGE_TAG= +├── secrets/ +│ └── backend.env # OAuth secrets, session key (chmod 600) +├── data/ +│ ├── backend/ # Backend persistent data +│ └── syncserver/ # Taskchampion sync server data +├── scripts/ +│ └── deploy.sh # Copy from this directory +└── deployments/ # Deployment history + └── current -> ... # Symlink to current deployment +``` + +## Initial VPS Setup + +### 1. Create deploy user + +```bash +sudo useradd -m -s /bin/bash deploy +sudo usermod -aG docker deploy +``` + +### 2. Create directory structure + +```bash +sudo mkdir -p /opt/ccsync/{scripts,secrets,data/backend,data/syncserver,deployments} +sudo chown -R deploy:deploy /opt/ccsync +sudo chmod 750 /opt/ccsync +sudo chmod 700 /opt/ccsync/secrets +``` + +### 3. Copy deployment files + +```bash +# Copy docker-compose.yml +sudo -u deploy cp deployment/docker-compose.yml /opt/ccsync/ + +# Copy and make deploy script executable +sudo -u deploy cp deployment/deploy.sh /opt/ccsync/scripts/ +sudo chmod +x /opt/ccsync/scripts/deploy.sh +``` + +### 4. Create secrets file + +```bash +sudo -u deploy nano /opt/ccsync/secrets/backend.env +sudo chmod 600 /opt/ccsync/secrets/backend.env +``` + +Required variables in `backend.env`: +```bash +# Google OAuth (from Google Cloud Console) +CLIENT_ID=your-client-id.apps.googleusercontent.com +CLIENT_SEC=your-client-secret + +# Session security (generate with: openssl rand -hex 32) +SESSION_KEY=your-64-character-hex-string + +# Environment +ENV=production +PORT=8000 + +# URLs +ALLOWED_ORIGIN=https://taskwarrior-server.ccextractor.org +FRONTEND_ORIGIN_DEV=https://taskwarrior-server.ccextractor.org +REDIRECT_URL_DEV=https://taskwarrior-server.ccextractor.org/auth/callback +CONTAINER_ORIGIN=https://taskwarrior-server.ccextractor.org:8080 +``` + +### 5. Generate SSH key for GitHub Actions + +```bash +# As deploy user +sudo -u deploy ssh-keygen -t ed25519 -C "github-deploy@ccsync" -f /home/deploy/.ssh/github_deploy -N "" + +# Add to authorized_keys +sudo -u deploy bash -c 'cat /home/deploy/.ssh/github_deploy.pub >> /home/deploy/.ssh/authorized_keys' +sudo -u deploy chmod 600 /home/deploy/.ssh/authorized_keys + +# Display private key (add to GitHub Secrets as DEPLOY_SSH_KEY) +sudo cat /home/deploy/.ssh/github_deploy +``` + +## GitHub Repository Setup + +### 1. Create "production" environment + +In GitHub repo settings → Environments → Create "production": +- Add required reviewers (optional, for manual approval) +- Add deployment branch rule: `main` + +### 2. Add environment variables + +| Name | Value | +|------|-------| +| `SSH_HOST` | `152.228.128.92` | +| `SSH_USER` | `deploy` | +| `SSH_PORT` | `22` | + +### 3. Add environment secrets + +| Name | Description | +|------|-------------| +| `DEPLOY_SSH_KEY` | Private key from step 5 above | + +## Deployment + +### Automatic (after merge to main) + +1. Push/merge to `main` branch +2. GitHub Actions builds and pushes images to GHCR +3. Go to Actions → "Deploy to Production" → Run workflow +4. Enter the image tag (commit SHA) or "latest" +5. If environment protection is enabled, approve the deployment + +### Manual deployment on VPS + +```bash +# SSH to VPS +ssh deploy@152.228.128.92 + +# Deploy specific tag +/opt/ccsync/scripts/deploy.sh abc1234 + +# Deploy latest +/opt/ccsync/scripts/deploy.sh latest +``` + +## Rollback + +### Automatic + +The deploy script automatically rolls back if: +- Docker image pull fails +- Container startup fails +- Health check fails within 120 seconds + +### Manual + +```bash +# Check deployment history +ls -la /opt/ccsync/deployments/ + +# Get previous tag from a deployment record +cat /opt/ccsync/deployments//info.txt + +# Roll back to previous tag +/opt/ccsync/scripts/deploy.sh +``` + +## Monitoring + +The existing health check script at `/opt/ccsync-monitor/health-check.sh` monitors: +- Docker container health status +- Backend `/health` endpoint +- Alerts to Zulip on failures + +After migration, update the script to use `/opt/ccsync` instead of `~/ccsync`. diff --git a/deployment/deploy.sh b/deployment/deploy.sh new file mode 100644 index 00000000..07ddfd5b --- /dev/null +++ b/deployment/deploy.sh @@ -0,0 +1,118 @@ +#!/bin/bash +# CCSync Deployment Script +# Location on VPS: /opt/ccsync/scripts/deploy.sh +# +# Usage: ./deploy.sh +# Example: ./deploy.sh abc1234 +# +# This script: +# 1. Pulls new Docker images from GHCR +# 2. Starts the services with the new images +# 3. Verifies health checks pass +# 4. Rolls back automatically on failure + +set -euo pipefail + +# Configuration +DEPLOY_DIR="/opt/ccsync" +IMAGE_TAG="${1:?Usage: deploy.sh }" +HEALTH_URL="http://127.0.0.1:8000/health" +HEALTH_TIMEOUT=120 +ROLLBACK_ON_FAILURE=true + +cd "$DEPLOY_DIR" + +# --- Logging --- +log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; } +log_error() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: $*" >&2; } + +# --- Locking --- +LOCK_FILE="/var/lock/ccsync-deploy.lock" +exec 9>"$LOCK_FILE" +if ! flock -n 9; then + log_error "Another deployment is in progress" + exit 1 +fi + +# --- Get current tag for rollback --- +PREVIOUS_TAG="" +if [[ -f .env ]]; then + PREVIOUS_TAG=$(grep -oP 'IMAGE_TAG=\K.*' .env || true) +fi + +log "Starting deployment: $IMAGE_TAG (previous: ${PREVIOUS_TAG:-none})" + +# --- Pull new images --- +log "Pulling images for tag: $IMAGE_TAG" +export IMAGE_TAG +if ! docker compose pull frontend backend; then + log_error "Failed to pull images" + exit 1 +fi + +# --- Deploy --- +log "Starting services..." +if ! docker compose up -d --remove-orphans; then + log_error "Failed to start services" + if [[ -n "$PREVIOUS_TAG" && "$ROLLBACK_ON_FAILURE" == "true" ]]; then + log "Rolling back to $PREVIOUS_TAG" + export IMAGE_TAG="$PREVIOUS_TAG" + docker compose up -d + fi + exit 1 +fi + +# --- Health check --- +log "Waiting for health check (timeout: ${HEALTH_TIMEOUT}s)..." +HEALTHY=false +for i in $(seq 1 $((HEALTH_TIMEOUT / 5))); do + sleep 5 + if curl -sf "$HEALTH_URL" > /dev/null 2>&1; then + HEALTHY=true + break + fi + log "Health check attempt $i failed, retrying..." +done + +if [[ "$HEALTHY" != "true" ]]; then + log_error "Health check failed after ${HEALTH_TIMEOUT}s" + + # Show container status for debugging + log "Container status:" + docker compose ps + + if [[ -n "$PREVIOUS_TAG" && "$ROLLBACK_ON_FAILURE" == "true" ]]; then + log "Rolling back to $PREVIOUS_TAG" + export IMAGE_TAG="$PREVIOUS_TAG" + echo "IMAGE_TAG=$PREVIOUS_TAG" > .env + docker compose up -d + log "Rollback complete" + fi + exit 1 +fi + +log "Health check passed" + +# --- Persist the new tag --- +echo "IMAGE_TAG=$IMAGE_TAG" > .env + +# --- Record deployment --- +DEPLOY_RECORD="deployments/$(date '+%Y%m%d-%H%M%S')-$IMAGE_TAG" +mkdir -p "$DEPLOY_RECORD" +echo "tag=$IMAGE_TAG" > "$DEPLOY_RECORD/info.txt" +echo "deployed_at=$(date -Iseconds)" >> "$DEPLOY_RECORD/info.txt" +echo "previous_tag=${PREVIOUS_TAG:-none}" >> "$DEPLOY_RECORD/info.txt" + +# Update current symlink +ln -sfn "$(basename "$DEPLOY_RECORD")" deployments/current + +# --- Cleanup old images --- +log "Cleaning up old images..." +docker image prune -f --filter "until=168h" > /dev/null 2>&1 || true + +# --- Keep only last 10 deployment records --- +cd deployments +ls -1t | grep -v '^current$' | tail -n +11 | xargs -r rm -rf +cd .. + +log "Deployment of $IMAGE_TAG successful" diff --git a/deployment/docker-compose.yml b/deployment/docker-compose.yml new file mode 100644 index 00000000..fa215e38 --- /dev/null +++ b/deployment/docker-compose.yml @@ -0,0 +1,70 @@ +# Production Docker Compose for CCSync +# Location on VPS: /opt/ccsync/docker-compose.yml +# +# Usage: +# IMAGE_TAG=abc1234 docker compose up -d +# +# The IMAGE_TAG is set by the deployment script (deploy.sh) + +services: + frontend: + image: ghcr.io/ccextractor/frontend:${IMAGE_TAG:-latest} + restart: unless-stopped + networks: + - ccsync + ports: + - "127.0.0.1:3000:80" + depends_on: + backend: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:80"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + + backend: + image: ghcr.io/ccextractor/backend:${IMAGE_TAG:-latest} + restart: unless-stopped + networks: + - ccsync + ports: + - "127.0.0.1:8000:8000" + depends_on: + syncserver: + condition: service_healthy + env_file: + - ./secrets/backend.env + volumes: + - ./data/backend:/app/data + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + + syncserver: + image: ghcr.io/gothenburgbitfactory/taskchampion-sync-server:0.7.1 + restart: unless-stopped + networks: + - ccsync + ports: + - "127.0.0.1:8081:8080" + environment: + - RUST_LOG=info + - DATA_DIR=/data + - LISTEN=0.0.0.0:8080 + volumes: + - ./data/syncserver:/data + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:8080/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + +networks: + ccsync: + driver: bridge From 52d2f2541d64192ece24c89a72e0e092daa680aa Mon Sep 17 00:00:00 2001 From: Carlos Date: Sat, 31 Jan 2026 11:22:38 -0800 Subject: [PATCH 2/2] fix: remove hardcoded IP from deployment README Replace server IP with placeholder to avoid exposing infrastructure details in the repository. Co-Authored-By: Claude Opus 4.5 --- deployment/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deployment/README.md b/deployment/README.md index 33a0e1ff..0cbf7d6a 100644 --- a/deployment/README.md +++ b/deployment/README.md @@ -108,7 +108,7 @@ In GitHub repo settings → Environments → Create "production": | Name | Value | |------|-------| -| `SSH_HOST` | `152.228.128.92` | +| `SSH_HOST` | `` | | `SSH_USER` | `deploy` | | `SSH_PORT` | `22` | @@ -132,7 +132,7 @@ In GitHub repo settings → Environments → Create "production": ```bash # SSH to VPS -ssh deploy@152.228.128.92 +ssh deploy@ # Deploy specific tag /opt/ccsync/scripts/deploy.sh abc1234