Deploy to DigitalOcean via Tailscale #167
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 | |
# Test SSH connection first | |
echo "Testing SSH connection to ${{ secrets.DO_TAILSCALE_NAME }}..." | |
if ! ssh -o StrictHostKeyChecking=accept-new -o BatchMode=yes -o ConnectTimeout=10 -i ~/.ssh/id_ed25519 ${{ secrets.DO_USERNAME }}@${{ secrets.DO_TAILSCALE_NAME }} 'echo "SSH connection successful"'; then | |
echo "ERROR: SSH connection failed to ${{ secrets.DO_TAILSCALE_NAME }}" | |
echo "Checking SSH configuration..." | |
ssh -vvv -o StrictHostKeyChecking=accept-new -o BatchMode=yes -o ConnectTimeout=10 -i ~/.ssh/id_ed25519 ${{ secrets.DO_USERNAME }}@${{ secrets.DO_TAILSCALE_NAME }} 'echo "test"' || true | |
exit 1 | |
fi | |
# Copy .env to server | |
echo "Copying .env file to server..." | |
if ! scp -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 -i ~/.ssh/id_ed25519 envfile ${{ secrets.DO_USERNAME }}@${{ secrets.DO_TAILSCALE_NAME }}:/home/deploy/.env; then | |
echo "ERROR: Failed to copy .env file" | |
exit 1 | |
fi | |
# Upload env file | |
# SSH and deploy | |
echo "Starting main deployment script..." | |
ssh -o StrictHostKeyChecking=accept-new -o BatchMode=yes -o ConnectTimeout=30 -i ~/.ssh/id_ed25519 ${{ secrets.DO_USERNAME }}@${{ secrets.DO_TAILSCALE_NAME }} 'bash -s' << 'DEPLOY_SCRIPT_EOF' | |
set -e | |
# Exit immediately if any command fails | |
# Cleanup function to ensure service gets restarted even on failure | |
cleanup() { | |
echo "INFO: 🔄 Ensuring Refactor Platform service is running..." | |
if systemctl list-unit-files | grep -q "refactor-platform.service"; then | |
sudo systemctl start refactor-platform.service 2>/dev/null || echo "WARNING: Failed to restart service" | |
else | |
echo "WARNING: refactor-platform.service not found during cleanup" | |
fi | |
} | |
# Verify SSH execution is working correctly | |
if [[ "$(hostname)" == *"runner"* ]] || [[ "$(pwd)" == *"runner"* ]]; then | |
echo "FATAL ERROR: Script is running on GitHub Actions runner instead of target server!" | |
echo "SSH connection failed - aborting deployment" | |
exit 1 | |
fi | |
echo "INFO: Connected to deployment target successfully" | |
echo 'INFO: 📦 Starting deployment from branch: ${{ github.ref_name }}...' | |
# Ensure we're in the correct directory | |
cd /home/deploy || { | |
echo "FATAL ERROR: Cannot change to /home/deploy directory" | |
echo "Current directory: $(pwd)" | |
echo "Directory contents:" | |
ls -la | |
exit 1 | |
} | |
curl -O https://raw.githubusercontent.com/refactor-group/refactor-platform-rs/refs/heads/${{ github.ref_name }}/docker-compose.yaml | |
if [ -f docker-compose.yaml ]; then | |
chmod 640 docker-compose.yaml | |
else | |
echo "ERROR: Failed to download docker-compose.yaml" | |
exit 1 | |
fi | |
# Check if docker-compose.yaml exists before using it as reference | |
if [ ! -f docker-compose.yaml ]; then | |
echo "ERROR: docker-compose.yaml not found - cannot set permissions" | |
exit 1 | |
fi | |
echo "INFO: 🛑 Stopping Refactor Platform service before nginx setup..." | |
sudo systemctl stop refactor-platform.service | |
# Set trap early to ensure service restart on any failure during nginx setup | |
trap cleanup EXIT | |
echo "INFO: Creating nginx directories..." | |
if ! mkdir -p nginx/conf.d nginx/logs nginx/scripts nginx/html/.well-known/acme-challenge; then | |
echo "ERROR: Failed to create nginx directories" | |
exit 1 | |
fi | |
echo "INFO: Setting initial directory permissions..." | |
# Ensure current user owns the nginx directory tree | |
chown -R $(whoami):$(whoami) nginx/ 2>/dev/null || true | |
# Set restrictive directory permissions initially | |
find nginx -type d -exec chmod 755 {} \; | |
echo "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 | |
echo "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 | |
echo "ERROR: Downloaded nginx.conf is empty or could not be created" | |
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 | |
echo "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 | |
echo "ERROR: Downloaded refactor-platform.conf is empty or could not be created" | |
exit 1 | |
fi | |
# Download refactor platform nginx let's encrypt ssl cert renewal script | |
# Handle potential directory issues with robust retry logic | |
echo "INFO: Preparing to download renew-certs.sh..." | |
# Ensure scripts directory exists (recreate if needed) | |
if [ ! -d nginx/scripts ]; then | |
echo "INFO: Scripts directory missing, recreating..." | |
mkdir -p nginx/scripts | |
fi | |
# Remove existing file first to avoid permission issues | |
rm -f nginx/scripts/renew-certs.sh 2>/dev/null || true | |
# Download with exponential backoff retry logic | |
for attempt in {1..3}; do | |
if curl -fsSL --retry 2 "https://raw.githubusercontent.com/refactor-group/refactor-platform-rs/refs/heads/${{ github.ref_name }}/nginx/scripts/renew-certs.sh" -o nginx/scripts/renew-certs.sh; then | |
echo "INFO: Successfully downloaded renew-certs.sh on attempt $attempt" | |
break | |
elif [ $attempt -eq 3 ]; then | |
echo "ERROR: Failed to download renew-certs.sh after 3 attempts" | |
exit 1 | |
else | |
echo "INFO: Download attempt $attempt failed, recreating directory and retrying in $((attempt * 2)) seconds..." | |
mkdir -p nginx/scripts | |
sleep $((attempt * 2)) | |
fi | |
done | |
# Verify the renew certs script file was downloaded and is not empty | |
if [ ! -s nginx/scripts/renew-certs.sh ]; then | |
echo "ERROR: Downloaded renew-certs.sh script is empty or could not be created" | |
exit 1 | |
fi | |
if [ -f nginx/scripts/renew-certs.sh ]; then | |
chmod 770 nginx/scripts/renew-certs.sh | |
else | |
echo "WARNING: renew-certs.sh not found for initial chmod" | |
fi | |
echo "INFO: Setting final directory permissions..." | |
# Set final directory permissions to allow nginx to serve ACME challenges | |
if [ -d nginx ]; then | |
find nginx -type d -exec chmod 755 {} \; | |
# Set file permissions for downloaded files | |
find nginx -type f -exec chmod 644 {} \; 2>/dev/null || true | |
# Make the renewal script executable | |
if [ -f nginx/scripts/renew-certs.sh ]; then | |
chmod 755 nginx/scripts/renew-certs.sh | |
else | |
echo "WARNING: renew-certs.sh not found for final chmod" | |
fi | |
else | |
echo "WARNING: nginx directory not found for final permissions" | |
fi | |
echo "INFO: Nginx setup completed successfully!" | |
echo "INFO: 🔧 Matching .env permissions to docker-compose.yaml..." | |
# Update env file permissions | |
# Copy permissions from compose file | |
if [ -f docker-compose.yaml ] && [ -f .env ]; then | |
chmod --reference=docker-compose.yaml .env | |
# Copy ownership from compose file | |
chown --reference=docker-compose.yaml .env | |
elif [ -f .env ]; then | |
echo "WARNING: docker-compose.yaml not found, setting default .env permissions" | |
chmod 640 .env | |
else | |
echo "WARNING: .env file not found, skipping permission setting" | |
fi | |
echo "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 | |
echo "INFO: 📥 Pulling images..." | |
# Pull Docker images | |
if [ -n "${{ vars.BACKEND_IMAGE_NAME }}" ]; then | |
# Check if backend image is set | |
echo "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 | |
echo "INFO: Pulling frontend image: ${{ vars.FRONTEND_IMAGE_NAME }}" | |
# Announce image pull | |
docker pull ${{ vars.FRONTEND_IMAGE_NAME }} | |
# Pull frontend image | |
fi | |
echo "INFO: 🔍 Validating config..." | |
# Verify docker-compose configuration | |
if [ -f docker-compose.yaml ]; then | |
docker compose config --quiet | |
else | |
echo "ERROR: docker-compose.yaml not found - cannot validate configuration" | |
exit 1 | |
fi | |
echo "INFO: 🚀 Starting Refactor Platform service..." | |
# Verify systemd service exists before attempting to start | |
if systemctl list-unit-files | grep -q "refactor-platform.service"; then | |
sudo systemctl start refactor-platform.service | |
else | |
echo "ERROR: refactor-platform.service not found" | |
echo "Available services matching 'refactor':" | |
systemctl list-unit-files | grep -i refactor || echo "No refactor services found" | |
exit 1 | |
fi | |
echo "INFO: ⏳ Waiting for startup..." | |
# Wait for containers to reach steady state | |
sleep 3 | |
echo "INFO: 🩺 Checking service and status..." | |
systemctl status refactor-platform.service | |
# Check container status | |
docker ps -a | |
# List all containers | |
echo "INFO: 🩺 Verifying app status..." | |
# Verify application health | |
echo "INFO: 🩺 Checking rust-app service status..." | |
if docker ps | grep -q rust-app; then | |
echo "INFO: ✅ Deployment succeeded! rust-app is running." | |
else | |
echo "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 | |
echo "INFO: 🎉 Deployment complete." | |
# Final deployment message | |
DEPLOY_SCRIPT_EOF |