Skip to content

Deploy to DigitalOcean via Tailscale #133

Deploy to DigitalOcean via Tailscale

Deploy to DigitalOcean via Tailscale #133

Workflow file for this run

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
'