Skip to content

Commit acf2062

Browse files
cfsmp3claude
andauthored
feat: add deployment automation for production (#441)
* 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 <noreply@anthropic.com> * fix: remove hardcoded IP from deployment README Replace server IP with placeholder <your-server-ip> to avoid exposing infrastructure details in the repository. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 8f30c83 commit acf2062

5 files changed

Lines changed: 402 additions & 4 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
name: Deploy to Production
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
image_tag:
7+
description: 'Docker image tag to deploy (commit SHA or "latest")'
8+
required: true
9+
default: 'latest'
10+
11+
concurrency:
12+
group: deploy-production
13+
cancel-in-progress: false
14+
15+
jobs:
16+
deploy:
17+
runs-on: ubuntu-latest
18+
environment: production
19+
20+
steps:
21+
- name: Deploy to VPS
22+
uses: appleboy/ssh-action@v1.2.0
23+
env:
24+
IMAGE_TAG: ${{ github.event.inputs.image_tag }}
25+
with:
26+
host: ${{ vars.SSH_HOST }}
27+
username: ${{ vars.SSH_USER }}
28+
key: ${{ secrets.DEPLOY_SSH_KEY }}
29+
port: ${{ vars.SSH_PORT }}
30+
envs: IMAGE_TAG
31+
command_timeout: 10m
32+
script: |
33+
/opt/ccsync/scripts/deploy.sh "$IMAGE_TAG"
34+
35+
- name: Deployment summary
36+
run: |
37+
echo "## Deployment Complete" >> $GITHUB_STEP_SUMMARY
38+
echo "" >> $GITHUB_STEP_SUMMARY
39+
echo "- **Image tag:** ${{ github.event.inputs.image_tag }}" >> $GITHUB_STEP_SUMMARY
40+
echo "- **Environment:** production" >> $GITHUB_STEP_SUMMARY
41+
echo "- **Server:** https://taskwarrior-server.ccextractor.org" >> $GITHUB_STEP_SUMMARY

.github/workflows/docker.yml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,6 @@ jobs:
3737
ghcr.io/ccextractor/frontend:${{ github.sha }}
3838
cache-from: type=gha
3939
cache-to: type=gha,mode=max
40-
build-args: |
41-
VITE_BACKEND_URL=http://localhost:8000
42-
VITE_FRONTEND_URL=http://localhost:80
43-
VITE_CONTAINER_ORIGIN=http://localhost:8080
4440

4541
build-and-push-backend:
4642
runs-on: ubuntu-latest

deployment/README.md

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
# CCSync Production Deployment
2+
3+
This directory contains the production deployment configuration for CCSync.
4+
5+
## Overview
6+
7+
The deployment system uses:
8+
- **GitHub Actions** to build and push Docker images to GHCR
9+
- **SSH-based deployment** triggered manually via GitHub Actions
10+
- **Automatic rollback** if health checks fail
11+
12+
## VPS Directory Structure
13+
14+
```
15+
/opt/ccsync/
16+
├── docker-compose.yml # Copy from this directory
17+
├── .env # Contains IMAGE_TAG=<sha>
18+
├── secrets/
19+
│ └── backend.env # OAuth secrets, session key (chmod 600)
20+
├── data/
21+
│ ├── backend/ # Backend persistent data
22+
│ └── syncserver/ # Taskchampion sync server data
23+
├── scripts/
24+
│ └── deploy.sh # Copy from this directory
25+
└── deployments/ # Deployment history
26+
└── current -> ... # Symlink to current deployment
27+
```
28+
29+
## Initial VPS Setup
30+
31+
### 1. Create deploy user
32+
33+
```bash
34+
sudo useradd -m -s /bin/bash deploy
35+
sudo usermod -aG docker deploy
36+
```
37+
38+
### 2. Create directory structure
39+
40+
```bash
41+
sudo mkdir -p /opt/ccsync/{scripts,secrets,data/backend,data/syncserver,deployments}
42+
sudo chown -R deploy:deploy /opt/ccsync
43+
sudo chmod 750 /opt/ccsync
44+
sudo chmod 700 /opt/ccsync/secrets
45+
```
46+
47+
### 3. Copy deployment files
48+
49+
```bash
50+
# Copy docker-compose.yml
51+
sudo -u deploy cp deployment/docker-compose.yml /opt/ccsync/
52+
53+
# Copy and make deploy script executable
54+
sudo -u deploy cp deployment/deploy.sh /opt/ccsync/scripts/
55+
sudo chmod +x /opt/ccsync/scripts/deploy.sh
56+
```
57+
58+
### 4. Create secrets file
59+
60+
```bash
61+
sudo -u deploy nano /opt/ccsync/secrets/backend.env
62+
sudo chmod 600 /opt/ccsync/secrets/backend.env
63+
```
64+
65+
Required variables in `backend.env`:
66+
```bash
67+
# Google OAuth (from Google Cloud Console)
68+
CLIENT_ID=your-client-id.apps.googleusercontent.com
69+
CLIENT_SEC=your-client-secret
70+
71+
# Session security (generate with: openssl rand -hex 32)
72+
SESSION_KEY=your-64-character-hex-string
73+
74+
# Environment
75+
ENV=production
76+
PORT=8000
77+
78+
# URLs
79+
ALLOWED_ORIGIN=https://taskwarrior-server.ccextractor.org
80+
FRONTEND_ORIGIN_DEV=https://taskwarrior-server.ccextractor.org
81+
REDIRECT_URL_DEV=https://taskwarrior-server.ccextractor.org/auth/callback
82+
CONTAINER_ORIGIN=https://taskwarrior-server.ccextractor.org:8080
83+
```
84+
85+
### 5. Generate SSH key for GitHub Actions
86+
87+
```bash
88+
# As deploy user
89+
sudo -u deploy ssh-keygen -t ed25519 -C "github-deploy@ccsync" -f /home/deploy/.ssh/github_deploy -N ""
90+
91+
# Add to authorized_keys
92+
sudo -u deploy bash -c 'cat /home/deploy/.ssh/github_deploy.pub >> /home/deploy/.ssh/authorized_keys'
93+
sudo -u deploy chmod 600 /home/deploy/.ssh/authorized_keys
94+
95+
# Display private key (add to GitHub Secrets as DEPLOY_SSH_KEY)
96+
sudo cat /home/deploy/.ssh/github_deploy
97+
```
98+
99+
## GitHub Repository Setup
100+
101+
### 1. Create "production" environment
102+
103+
In GitHub repo settings → Environments → Create "production":
104+
- Add required reviewers (optional, for manual approval)
105+
- Add deployment branch rule: `main`
106+
107+
### 2. Add environment variables
108+
109+
| Name | Value |
110+
|------|-------|
111+
| `SSH_HOST` | `<your-server-ip>` |
112+
| `SSH_USER` | `deploy` |
113+
| `SSH_PORT` | `22` |
114+
115+
### 3. Add environment secrets
116+
117+
| Name | Description |
118+
|------|-------------|
119+
| `DEPLOY_SSH_KEY` | Private key from step 5 above |
120+
121+
## Deployment
122+
123+
### Automatic (after merge to main)
124+
125+
1. Push/merge to `main` branch
126+
2. GitHub Actions builds and pushes images to GHCR
127+
3. Go to Actions → "Deploy to Production" → Run workflow
128+
4. Enter the image tag (commit SHA) or "latest"
129+
5. If environment protection is enabled, approve the deployment
130+
131+
### Manual deployment on VPS
132+
133+
```bash
134+
# SSH to VPS
135+
ssh deploy@<your-server-ip>
136+
137+
# Deploy specific tag
138+
/opt/ccsync/scripts/deploy.sh abc1234
139+
140+
# Deploy latest
141+
/opt/ccsync/scripts/deploy.sh latest
142+
```
143+
144+
## Rollback
145+
146+
### Automatic
147+
148+
The deploy script automatically rolls back if:
149+
- Docker image pull fails
150+
- Container startup fails
151+
- Health check fails within 120 seconds
152+
153+
### Manual
154+
155+
```bash
156+
# Check deployment history
157+
ls -la /opt/ccsync/deployments/
158+
159+
# Get previous tag from a deployment record
160+
cat /opt/ccsync/deployments/<deployment-dir>/info.txt
161+
162+
# Roll back to previous tag
163+
/opt/ccsync/scripts/deploy.sh <previous-tag>
164+
```
165+
166+
## Monitoring
167+
168+
The existing health check script at `/opt/ccsync-monitor/health-check.sh` monitors:
169+
- Docker container health status
170+
- Backend `/health` endpoint
171+
- Alerts to Zulip on failures
172+
173+
After migration, update the script to use `/opt/ccsync` instead of `~/ccsync`.

deployment/deploy.sh

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
#!/bin/bash
2+
# CCSync Deployment Script
3+
# Location on VPS: /opt/ccsync/scripts/deploy.sh
4+
#
5+
# Usage: ./deploy.sh <image_tag>
6+
# Example: ./deploy.sh abc1234
7+
#
8+
# This script:
9+
# 1. Pulls new Docker images from GHCR
10+
# 2. Starts the services with the new images
11+
# 3. Verifies health checks pass
12+
# 4. Rolls back automatically on failure
13+
14+
set -euo pipefail
15+
16+
# Configuration
17+
DEPLOY_DIR="/opt/ccsync"
18+
IMAGE_TAG="${1:?Usage: deploy.sh <image_tag>}"
19+
HEALTH_URL="http://127.0.0.1:8000/health"
20+
HEALTH_TIMEOUT=120
21+
ROLLBACK_ON_FAILURE=true
22+
23+
cd "$DEPLOY_DIR"
24+
25+
# --- Logging ---
26+
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; }
27+
log_error() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: $*" >&2; }
28+
29+
# --- Locking ---
30+
LOCK_FILE="/var/lock/ccsync-deploy.lock"
31+
exec 9>"$LOCK_FILE"
32+
if ! flock -n 9; then
33+
log_error "Another deployment is in progress"
34+
exit 1
35+
fi
36+
37+
# --- Get current tag for rollback ---
38+
PREVIOUS_TAG=""
39+
if [[ -f .env ]]; then
40+
PREVIOUS_TAG=$(grep -oP 'IMAGE_TAG=\K.*' .env || true)
41+
fi
42+
43+
log "Starting deployment: $IMAGE_TAG (previous: ${PREVIOUS_TAG:-none})"
44+
45+
# --- Pull new images ---
46+
log "Pulling images for tag: $IMAGE_TAG"
47+
export IMAGE_TAG
48+
if ! docker compose pull frontend backend; then
49+
log_error "Failed to pull images"
50+
exit 1
51+
fi
52+
53+
# --- Deploy ---
54+
log "Starting services..."
55+
if ! docker compose up -d --remove-orphans; then
56+
log_error "Failed to start services"
57+
if [[ -n "$PREVIOUS_TAG" && "$ROLLBACK_ON_FAILURE" == "true" ]]; then
58+
log "Rolling back to $PREVIOUS_TAG"
59+
export IMAGE_TAG="$PREVIOUS_TAG"
60+
docker compose up -d
61+
fi
62+
exit 1
63+
fi
64+
65+
# --- Health check ---
66+
log "Waiting for health check (timeout: ${HEALTH_TIMEOUT}s)..."
67+
HEALTHY=false
68+
for i in $(seq 1 $((HEALTH_TIMEOUT / 5))); do
69+
sleep 5
70+
if curl -sf "$HEALTH_URL" > /dev/null 2>&1; then
71+
HEALTHY=true
72+
break
73+
fi
74+
log "Health check attempt $i failed, retrying..."
75+
done
76+
77+
if [[ "$HEALTHY" != "true" ]]; then
78+
log_error "Health check failed after ${HEALTH_TIMEOUT}s"
79+
80+
# Show container status for debugging
81+
log "Container status:"
82+
docker compose ps
83+
84+
if [[ -n "$PREVIOUS_TAG" && "$ROLLBACK_ON_FAILURE" == "true" ]]; then
85+
log "Rolling back to $PREVIOUS_TAG"
86+
export IMAGE_TAG="$PREVIOUS_TAG"
87+
echo "IMAGE_TAG=$PREVIOUS_TAG" > .env
88+
docker compose up -d
89+
log "Rollback complete"
90+
fi
91+
exit 1
92+
fi
93+
94+
log "Health check passed"
95+
96+
# --- Persist the new tag ---
97+
echo "IMAGE_TAG=$IMAGE_TAG" > .env
98+
99+
# --- Record deployment ---
100+
DEPLOY_RECORD="deployments/$(date '+%Y%m%d-%H%M%S')-$IMAGE_TAG"
101+
mkdir -p "$DEPLOY_RECORD"
102+
echo "tag=$IMAGE_TAG" > "$DEPLOY_RECORD/info.txt"
103+
echo "deployed_at=$(date -Iseconds)" >> "$DEPLOY_RECORD/info.txt"
104+
echo "previous_tag=${PREVIOUS_TAG:-none}" >> "$DEPLOY_RECORD/info.txt"
105+
106+
# Update current symlink
107+
ln -sfn "$(basename "$DEPLOY_RECORD")" deployments/current
108+
109+
# --- Cleanup old images ---
110+
log "Cleaning up old images..."
111+
docker image prune -f --filter "until=168h" > /dev/null 2>&1 || true
112+
113+
# --- Keep only last 10 deployment records ---
114+
cd deployments
115+
ls -1t | grep -v '^current$' | tail -n +11 | xargs -r rm -rf
116+
cd ..
117+
118+
log "Deployment of $IMAGE_TAG successful"

0 commit comments

Comments
 (0)