Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 0 additions & 4 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
173 changes: 173 additions & 0 deletions deployment/README.md
Original file line number Diff line number Diff line change
@@ -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=<sha>
├── 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` | `<your-server-ip>` |
| `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@<your-server-ip>

# 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/<deployment-dir>/info.txt

# Roll back to previous tag
/opt/ccsync/scripts/deploy.sh <previous-tag>
```

## 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`.
118 changes: 118 additions & 0 deletions deployment/deploy.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
#!/bin/bash
# CCSync Deployment Script
# Location on VPS: /opt/ccsync/scripts/deploy.sh
#
# Usage: ./deploy.sh <image_tag>
# 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 <image_tag>}"
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"
Loading
Loading