Deploy to DigitalOcean via Tailscale #134
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
name: Deploy to DigitalOcean via Tailscale # Workflow name displayed in GitHub UI | |
# Deploys the latest stable tagged container images to production servers. | |
# Manual trigger with debug ssh option | |
on: | |
workflow_dispatch: # Manual trigger via GitHub UI | |
inputs: | |
enable_ssh_debugging: # Optional debugging parameter useful to debug the ssh connection to the server | |
description: 'Enable verbose SSH debugging' # Help text shown in UI | |
required: false # Not required to run the workflow | |
default: false # Disabled by default | |
type: boolean # Simple checkbox in the UI | |
permissions: | |
contents: read # Minimal permissions required for this workflow | |
jobs: | |
deploy: | |
name: Manual Deploy Over Tailscale # Display name for this job | |
runs-on: ubuntu-24.04 | |
environment: production # Use the production environment settings | |
steps: | |
# Step 1: Setup Tailscale on the GitHub Actions runner | |
- name: Set up Tailscale # Connect to the Tailscale network | |
uses: tailscale/github-action@v3 # Official Tailscale GitHub Action | |
with: | |
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} # OAuth client ID for Tailscale | |
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }} # OAuth client secret for Tailscale | |
tags: tag:github-actions # Tag to identify this connection in Tailscale | |
version: latest # Use the latest version of Tailscale | |
use-cache: 'true' # Cache Tailscale binary for faster startup | |
# Step 2: (Debug only) Verifies SSH ED25519 SSH key | |
- name: Debug ED25519 Key # SSH key debugging step | |
if: ${{ inputs.enable_ssh_debugging == true }} # Only run when debugging is enabled | |
run: | | |
mkdir -p ~/.ssh | |
# Create SSH directory if it doesn't exist | |
echo "${{ secrets.DO_SSH_KEY }}" > ~/.ssh/id_ed25519 | |
# Create private SSH key file | |
chmod 600 ~/.ssh/id_ed25519 | |
# Set secure permissions on the key | |
echo "${{ secrets.DO_HOST_KEY }}" >> ~/.ssh/known_hosts | |
# Add host key to known hosts | |
ssh-keygen -l -f ~/.ssh/id_ed25519 | |
# Show fingerprint of the key | |
ssh-keygen -y -f ~/.ssh/id_ed25519 | |
# Show public key derived from private key | |
ssh -vvv -o StrictHostKeyChecking=accept-new -o BatchMode=yes -i ~/.ssh/id_ed25519 ${{ secrets.DO_USERNAME }}@${{ secrets.DO_TAILSCALE_NAME }} 'echo "Connection successful"' | |
# Test connection with verbose output | |
# Step 3: Create .env File Locally | |
- name: Create .env File on Server # Create environment file for deployment | |
run: | | |
cat > envfile <<EOF | |
# Start heredoc to create env file | |
# -------- PostgreSQL Config | |
# Database username | |
POSTGRES_USER=${{ secrets.POSTGRES_USER }} | |
# Database password | |
POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }} | |
# Database name | |
POSTGRES_DB=${{ vars.POSTGRES_DB }} | |
# Container hostname for database | |
POSTGRES_HOST=${{ vars.POSTGRES_HOST }} | |
# Database port | |
POSTGRES_PORT=${{ vars.POSTGRES_PORT }} | |
# Database schema | |
POSTGRES_SCHEMA=${{ vars.POSTGRES_SCHEMA }} | |
POSTGRES_SSL_ROOT_CERT=${{ secrets.POSTGRES_SSL_ROOT_CERT }} | |
# Database connection options, hardcoded intentionally | |
POSTGRES_OPTIONS=sslmode=verify-full&sslrootcert=/app/root.crt | |
# Database connection string | |
DATABASE_URL=postgres://${{ secrets.POSTGRES_USER }}:${{ secrets.POSTGRES_PASSWORD }}@${{ vars.POSTGRES_HOST }}:${{ vars.POSTGRES_PORT }}/${{ vars.POSTGRES_DB }}?${{ vars.POSTGRES_OPTIONS }} | |
# -------- Backend Config | |
# Docker image for backend | |
BACKEND_IMAGE_NAME=${{ vars.BACKEND_IMAGE_NAME }} | |
# Container name for backend | |
BACKEND_CONTAINER_NAME=${{ vars.BACKEND_CONTAINER_NAME }} | |
# We are not building locally so setting to empty string to quiet warning | |
BACKEND_BUILD_CONTEXT="" | |
# Backend daemon port used by the Rust backend to listen to connection on | |
BACKEND_PORT=${{ vars.BACKEND_PORT }} | |
# Network interface for backend to listen for incoming connections on | |
BACKEND_INTERFACE=${{ vars.BACKEND_INTERFACE }} | |
# CORS allowed origins | |
BACKEND_ALLOWED_ORIGINS=${{ vars.BACKEND_ALLOWED_ORIGINS }} | |
# Logging filter level to apply | |
BACKEND_LOG_FILTER_LEVEL=${{ vars.BACKEND_LOG_FILTER_LEVEL }} | |
# Protocol for backend (http, https) | |
BACKEND_SERVICE_PROTOCOL=${{ vars.BACKEND_SERVICE_PROTOCOL }} | |
# Hostname or IP address to reach the backend on | |
BACKEND_SERVICE_HOST=${{ vars.BACKEND_SERVICE_HOST }} | |
# Backend service port used by the frontend / reverse proxy | |
BACKEND_SERVICE_PORT=${{ vars.BACKEND_SERVICE_PORT }} | |
# The URL path to proxy the backend API on (e.g. "api" would resolve to https://backend/api/) | |
BACKEND_SERVICE_API_PATH=${{ vars.BACKEND_SERVICE_API_PATH }} | |
# API version to use between frontend and backend | |
BACKEND_API_VERSION=${{ vars.BACKEND_API_VERSION }} | |
# Deployment environment used (development, staging, production) | |
RUST_ENV=${{ vars.RUST_ENV }} | |
# -------- TipTap Config | |
# TipTap account unique ID | |
TIPTAP_APP_ID=${{ vars.TIPTAP_APP_ID }} | |
# TipTap collaborative editor URL | |
TIPTAP_URL=${{ secrets.TIPTAP_URL }} | |
# TipTap authentication key | |
TIPTAP_AUTH_KEY=${{ secrets.TIPTAP_AUTH_KEY }} | |
# JWT signing key for TipTap | |
TIPTAP_JWT_SIGNING_KEY=${{ secrets.TIPTAP_JWT_SIGNING_KEY }} | |
# -------- Frontend Config | |
# Docker image for frontend | |
FRONTEND_IMAGE_NAME=${{ vars.FRONTEND_IMAGE_NAME }} | |
# Container name for frontend | |
FRONTEND_CONTAINER_NAME=${{ vars.FRONTEND_CONTAINER_NAME }} | |
# We are not building locally so setting to empty string to quiet warning | |
FRONTEND_BUILD_CONTEXT="" | |
# Frontend service interface to listen for client connections on | |
FRONTEND_SERVICE_INTERFACE=${{ vars.FRONTEND_SERVICE_INTERFACE }} | |
# Frontend service port | |
FRONTEND_SERVICE_PORT=${{ vars.FRONTEND_SERVICE_PORT }} | |
# -------- Nginx Reverse Proxy Config | |
SSL_DHPARAMS_PATH=${{ vars.SSL_DHPARAMS_PATH }} | |
# -------- Platform Config | |
# Target platform for Docker containers | |
PLATFORM=${{ vars.PLATFORM }} | |
EOF | |
# Step 4: SSH and deploy to the digitalocean droplet over private Tailscale tailnet | |
- name: Deploy Over SSH to Server and Restart Service # Main deployment step | |
run: | | |
mkdir -p ~/.ssh | |
# Create SSH directory | |
echo "${{ secrets.DO_SSH_KEY }}" > ~/.ssh/id_ed25519 | |
# Save SSH private key | |
chmod 600 ~/.ssh/id_ed25519 | |
# Set secure permissions on key | |
echo "${{ secrets.DO_HOST_KEY }}" >> ~/.ssh/known_hosts | |
# Add static host key | |
ssh-keyscan -H ${{ secrets.DO_TAILSCALE_NAME }} >> ~/.ssh/known_hosts | |
# Add dynamic host key | |
# Copy .env to server | |
scp -o StrictHostKeyChecking=accept-new -i ~/.ssh/id_ed25519 envfile ${{ secrets.DO_USERNAME }}@${{ secrets.DO_TAILSCALE_NAME }}:/home/deploy/.env | |
# Upload env file | |
# SSH and deploy | |
ssh -o StrictHostKeyChecking=accept-new -o BatchMode=yes -i ~/.ssh/id_ed25519 ${{ secrets.DO_USERNAME }}@${{ secrets.DO_TAILSCALE_NAME }} ' | |
set -e | |
# Exit immediately if any command fails | |
# Function to log errors | |
log_error() { | |
echo "ERROR: $1" >&2 | |
} | |
# Function to log info | |
log_info() { | |
echo "INFO: $1" | |
} | |
log_info '📦 Starting deployment from branch: ${{ github.ref_name }}...' | |
cd /home/deploy | |
curl -O https://raw.githubusercontent.com/refactor-group/refactor-platform-rs/refs/heads/${{ github.ref_name }}/docker-compose.yaml | |
chmod 640 docker-compose.yaml | |
# Check if docker-compose.yaml exists before using it as reference | |
if [ ! -f docker-compose.yaml ]; then | |
log_error "docker-compose.yaml not found - cannot set permissions" | |
exit 1 | |
fi | |
log_info "Creating nginx directories..." | |
if ! mkdir -p nginx/conf.d nginx/logs; then | |
log_error "Failed to create nginx directories" | |
exit 1 | |
fi | |
log_info "Setting permissions..." | |
# Set directory permissions | |
find nginx -type d -exec chmod 770 {} \; | |
# Set file permissions | |
find nginx -type f -exec chmod 660 {} \; | |
log_info "Downloading nginx configuration files..." | |
# Download main nginx config | |
if ! curl -fsSL "https://raw.githubusercontent.com/refactor-group/refactor-platform-rs/refs/heads/${{ github.ref_name }}/nginx/nginx.conf" -o nginx/nginx.conf; then | |
log_error "Failed to download nginx.conf" | |
exit 1 | |
fi | |
# Verify the main nginx config file was downloaded and is not empty | |
if [ ! -s nginx/nginx.conf ]; then | |
log_error "Downloaded nginx.conf is empty or doesn't exist" | |
exit 1 | |
fi | |
# Download refactor platform nginx reverse proxy config | |
if ! curl -fsSL "https://raw.githubusercontent.com/refactor-group/refactor-platform-rs/refs/heads/${{ github.ref_name }}/nginx/conf.d/refactor-platform.conf" -o nginx/conf.d/refactor-platform.conf; then | |
log_error "Failed to download refactor-platform.conf" | |
exit 1 | |
fi | |
# Verify the platform config file was downloaded and is not empty | |
if [ ! -s nginx/conf.d/refactor-platform.conf ]; then | |
log_error "Downloaded refactor-platform.conf is empty or doesn't exist" | |
exit 1 | |
fi | |
log_info "Nginx setup completed successfully!" | |
log_info "🔧 Matching .env permissions to docker-compose.yaml..." | |
# Update env file permissions | |
# Copy permissions from compose file | |
chmod --reference=docker-compose.yaml .env | |
# Copy ownership from compose file | |
chown --reference=docker-compose.yaml .env | |
log_info "📋 Showing masked .env:" | |
# Display environment with sensitive data masked | |
sed "s/POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=***/g; s/TIPTAP_AUTH_KEY=.*/TIPTAP_AUTH_KEY=***/g; s/TIPTAP_JWT_SIGNING_KEY=.*/TIPTAP_JWT_SIGNING_KEY=***/g" .env | |
log_info "📥 Logging into GHCR..." | |
# Login to GitHub Container Registry | |
echo ${{ secrets.GHCR_PAT }} | docker login ghcr.io -u ${{ secrets.GHCR_USERNAME }} --password-stdin | |
# Docker login | |
log_info "📥 Pulling images..." | |
# Pull Docker images | |
if [ -n "${{ vars.BACKEND_IMAGE_NAME }}" ]; then | |
# Check if backend image is set | |
log_info "Pulling backend image: ${{ vars.BACKEND_IMAGE_NAME }}" | |
# Announce image pull | |
docker pull ${{ vars.BACKEND_IMAGE_NAME }} | |
# Pull backend image | |
fi | |
if [ -n "${{ vars.FRONTEND_IMAGE_NAME }}" ]; then | |
# Check if frontend image is set | |
log_info "Pulling frontend image: ${{ vars.FRONTEND_IMAGE_NAME }}" | |
# Announce image pull | |
docker pull ${{ vars.FRONTEND_IMAGE_NAME }} | |
# Pull frontend image | |
fi | |
log_info "🔍 Validating config..." | |
# Verify docker-compose configuration | |
docker compose config --quiet | |
# Check config without output unless error | |
log_info "🛑 Stopping Refactor Platform service..." | |
sudo systemctl stop refactor-platform.service | |
log_info "🚀 Starting Refactor Platform service..." | |
sudo systemctl start refactor-platform.service | |
log_info "⏳ Waiting for startup..." | |
# Wait for containers to reach steady state | |
sleep 3 | |
log_info "🩺 Checking service and status..." | |
systemctl status refactor-platform.service | |
# Check container status | |
docker ps -a | |
# List all containers | |
log_info "🩺 Verifying app status..." | |
# Verify application health | |
log_info "🩺 Checking rust-app service status..." | |
if docker ps | grep -q rust-app; then | |
log_info "✅ Deployment succeeded! rust-app is running." | |
else | |
log_error "⚠️ Missing container for rust-app. Logs follow:" | |
# Show backend logs | |
docker logs rust-app --tail 30 2>/dev/null || echo "❌ Backend logs unavailable" | |
fi | |
log_info "🎉 Deployment complete." | |
# Final deployment message | |
' |