Skip to Content
InfraGitHubCreate DO app

Create DigitalOcean App Workflow

This GitHub Action workflow automatically deploys preview environments to DigitalOcean App Platform when a pull request is created.

Trigger Conditions

  • Runs on pull requests to the master branch
  • Skips if PR title contains “DO NOT PREVIEW”

Workflow Steps

1. Environment Setup

  • Checks out code
  • Installs required tools: jq, doctl, sshpass
  • Authenticates with DigitalOcean API

2. Database Preparation

  • Sanitizes branch name for use in database/app names (max 18 characters)
  • Dumps production database from managed database
  • Creates new database on droplet with branch-specific name
  • Imports database dump to new database

3. App Service Deployment

  • Generates app spec with all environment variables
  • Creates or updates app service with branch-specific name
  • Waits for app to be live and retrieves URL

4. Admin Service Deployment

  • Generates admin spec using app URL for API connection
  • Creates or updates admin service
  • Waits for admin to be live

5. Final Configuration

  • Updates app service with live URLs for APP_URL and ADMIN_URL
  • Sends deployment notification email with both URLs

Complete Workflow

name: Deploy to DigitalOcean App Platform on: pull_request: branches: - master jobs: deploy: if: ${{ ! contains(github.event.pull_request.title, 'DO NOT PRIVIEW') }} runs-on: ubuntu-latest steps: - name: Checkout Code uses: actions/checkout@v3 - name: Install jq run: sudo apt-get update && sudo apt-get install -y jq - name: Install doctl uses: digitalocean/action-doctl@v2 with: token: ${{ secrets.DO_API_TOKEN }} - name: Authenticate doctl run: doctl auth init -t ${{ secrets.DO_API_TOKEN }} - name: Sanitize Branch Name id: sanitize_branch run: | ORIGINAL_BRANCH="${{ github.head_ref }}" SANITIZED_BRANCH=$(echo "${ORIGINAL_BRANCH}" | sed 's/[^a-zA-Z0-9]/-/g') SANITIZED_BRANCH=${SANITIZED_BRANCH:0:18} echo "SANITIZED_BRANCH=${SANITIZED_BRANCH}" >> $GITHUB_OUTPUT - name: Install sshpass run: | sudo apt-get update sudo apt-get install -y sshpass - name: Dump IBA Database from Managed Database env: SANITIZED_BRANCH: ${{ steps.sanitize_branch.outputs.SANITIZED_BRANCH }} MANAGED_DB_HOST: ${{ secrets.MANAGED_DB_HOST }} MANAGED_DB_PORT: ${{ secrets.MANAGED_DB_PORT }} MANAGED_DB_USER: ${{ secrets.MANAGED_DB_USER }} MANAGED_DB_PASSWORD: ${{ secrets.MANAGED_DB_PASSWORD }} DO_DROPLET_IP: ${{ secrets.DO_DROPLET_IP }} DO_DROPLET_PASSWORD: ${{ secrets.DO_DROPLET_PASSWORD }} run: | sshpass -p "${DO_DROPLET_PASSWORD}" ssh -o StrictHostKeyChecking=no root@${DO_DROPLET_IP} << EOF set -ex echo "Starting mysqldump at: \$(date)" rm -rf /tmp/iba_dump.sql mysqldump \ --set-gtid-purged=OFF \ --host="${MANAGED_DB_HOST}" \ --port="${MANAGED_DB_PORT}" \ --user="${MANAGED_DB_USER}" \ --password="${MANAGED_DB_PASSWORD}" \ --ssl-mode=REQUIRED \ --routines \ --events \ --triggers \ IBA > /tmp/iba_dump.sql echo "Mysqldump finished at: \$(date)" EOF - name: Drop Database ON Droplet env: SANITIZED_BRANCH: ${{ steps.sanitize_branch.outputs.SANITIZED_BRANCH }} MANAGED_DB_HOST: ${{ secrets.MANAGED_DB_HOST }} MANAGED_DB_PORT: ${{ secrets.MANAGED_DB_PORT }} MANAGED_DB_USER: ${{ secrets.MANAGED_DB_USER }} MANAGED_DB_PASSWORD: ${{ secrets.MANAGED_DB_PASSWORD }} DB_PASSWORD: ${{ secrets.DB_PASSWORD }} DO_DROPLET_IP: ${{ secrets.DO_DROPLET_IP }} DO_DROPLET_PASSWORD: ${{ secrets.DO_DROPLET_PASSWORD }} run: | NEW_DB="${SANITIZED_BRANCH}_iba" echo "New database name: ${NEW_DB}" sshpass -p "${DO_DROPLET_PASSWORD}" ssh -o StrictHostKeyChecking=no root@${DO_DROPLET_IP} << EOF set -ex echo "Dropping existing database \${NEW_DB} (if exists) and creating new database..." docker exec -i mysql-container mysql -uroot -p"${DB_PASSWORD}" -e "DROP DATABASE IF EXISTS \`${NEW_DB}\`" EOF - name: Create Database ON Droplet env: SANITIZED_BRANCH: ${{ steps.sanitize_branch.outputs.SANITIZED_BRANCH }} MANAGED_DB_HOST: ${{ secrets.MANAGED_DB_HOST }} MANAGED_DB_PORT: ${{ secrets.MANAGED_DB_PORT }} MANAGED_DB_USER: ${{ secrets.MANAGED_DB_USER }} MANAGED_DB_PASSWORD: ${{ secrets.MANAGED_DB_PASSWORD }} DB_PASSWORD: ${{ secrets.DB_PASSWORD }} DO_DROPLET_IP: ${{ secrets.DO_DROPLET_IP }} DO_DROPLET_PASSWORD: ${{ secrets.DO_DROPLET_PASSWORD }} run: | NEW_DB="${SANITIZED_BRANCH}_iba" echo "New database name: ${NEW_DB}" sshpass -p "${DO_DROPLET_PASSWORD}" ssh -o StrictHostKeyChecking=no root@${DO_DROPLET_IP} << EOF set -ex docker exec -i mysql-container mysql -uroot -p"${DB_PASSWORD}" -e "CREATE DATABASE \`${NEW_DB}\`" echo "Created existing database \${NEW_DB} (if exists) and creating new database..." EOF - name: Import Database ON Droplet env: SANITIZED_BRANCH: ${{ steps.sanitize_branch.outputs.SANITIZED_BRANCH }} MANAGED_DB_HOST: ${{ secrets.MANAGED_DB_HOST }} MANAGED_DB_PORT: ${{ secrets.MANAGED_DB_PORT }} MANAGED_DB_USER: ${{ secrets.MANAGED_DB_USER }} MANAGED_DB_PASSWORD: ${{ secrets.MANAGED_DB_PASSWORD }} DB_PASSWORD: ${{ secrets.DB_PASSWORD }} DO_DROPLET_IP: ${{ secrets.DO_DROPLET_IP }} DO_DROPLET_PASSWORD: ${{ secrets.DO_DROPLET_PASSWORD }} run: | NEW_DB="${SANITIZED_BRANCH}_iba" echo "New database name: ${NEW_DB}" sshpass -p "${DO_DROPLET_PASSWORD}" ssh -o StrictHostKeyChecking=no root@${DO_DROPLET_IP} << EOF set -ex docker exec -i mysql-container mysql -uroot -p"${DB_PASSWORD}" "${NEW_DB}" < /tmp/iba_dump.sql EOF - name: Generate App Spec env: REPO_CLONE_URL: "https://${{ secrets.GIT_DO_USERNAME }}:${{ secrets.GIT_DO_TOKEN }}@github.com/${{ github.repository }}.git" BRANCH: ${{ github.head_ref }} SANITIZED_BRANCH: ${{ steps.sanitize_branch.outputs.SANITIZED_BRANCH }} CRYPTO_KEY: ${{ secrets.CRYPTO_KEY }} ADMIN_URL: ${{ secrets.ADMIN_URL }} APP_URL: ${{ secrets.APP_URL }} IS_PRODUCTION: ${{ secrets.IS_PRODUCTION }} NODE_ENV: ${{ secrets.NODE_ENV }} USE_NPM_INSTALL: ${{ secrets.USE_NPM_INSTALL }} NODE_MODULES_CACHE: ${{ secrets.NODE_MODULES_CACHE }} GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} GOOGLE_TRASLATE_TEXT_KEY: ${{ secrets.GOOGLE_TRASLATE_TEXT_KEY }} JIRA_ACCOUNT_EMAIL: ${{ secrets.JIRA_ACCOUNT_EMAIL }} JIRA_ACCOUNT_TOKEN: ${{ secrets.JIRA_ACCOUNT_TOKEN }} JIRA_ISSUE_TYPE_ID: ${{ secrets.JIRA_ISSUE_TYPE_ID }} JIRA_MEMBER_ALEX: ${{ secrets.JIRA_MEMBER_ALEX }} JIRA_MEMBER_VIVEK: ${{ secrets.JIRA_MEMBER_VIVEK }} JIRA_PROJECT_ID: ${{ secrets.JIRA_PROJECT_ID }} JWT_ALGO: ${{ secrets.JWT_ALGO }} JWT_SECRET: ${{ secrets.JWT_SECRET }} LOGS_DB_HOST: ${{ secrets.LOGS_DB_HOST }} LOGS_DB_NAME: ${{ secrets.LOGS_DB_NAME }} LOGS_DB_PASSWORD: ${{ secrets.LOGS_DB_PASSWORD }} LOGS_DB_PORT: ${{ secrets.LOGS_DB_PORT }} LOGS_DB_USER: ${{ secrets.LOGS_DB_USER }} DB_HOST: ${{ secrets.DB_HOST }} DB_USER: ${{ secrets.DB_USER }} DB_PASSWORD: ${{ secrets.DB_PASSWORD }} DB_NAME: "${{ steps.sanitize_branch.outputs.SANITIZED_BRANCH }}_iba" DB_PORT: ${{ secrets.DB_PORT }} MANDRILL_API_KEY: ${{ secrets.MANDRILL_API_KEY }} FROM_EMAIL: ${{ secrets.FROM_EMAIL }} EMAIL_ALERT: ${{ secrets.EMAIL_ALERT }} POSTMAN_TOKEN: ${{ secrets.POSTMAN_TOKEN }} RECAPTCHA_SECRETE_KEY: ${{ secrets.RECAPTCHA_SECRETE_KEY }} RECAPTCHA_SITE_KEY: ${{ secrets.RECAPTCHA_SITE_KEY }} S3_BUCKET_ACCESS_KEY_ID: ${{ secrets.S3_BUCKET_ACCESS_KEY_ID }} S3_BUCKET_SECRET_ACCESS_KEY: ${{ secrets.S3_BUCKET_SECRET_ACCESS_KEY }} S3_BUCKET_NAME: ${{ secrets.S3_BUCKET_NAME }} S3_BASE_URL: ${{ secrets.S3_BASE_URL }} S3_REGION: ${{ secrets.S3_REGION }} STRIPE_PK: ${{ secrets.STRIPE_PK }} STRIPE_SK: ${{ secrets.STRIPE_SK }} TWILIO_ACCOUNTSID: ${{ secrets.TWILIO_ACCOUNTSID }} TWILIO_AUTHTOKEN: ${{ secrets.TWILIO_AUTHTOKEN }} FACEBOOK_CLIENT_ID: ${{ secrets.FACEBOOK_CLIENT_ID }} FACEBOOK_CLIENT_SECRET: ${{ secrets.FACEBOOK_CLIENT_SECRET }} GOOGLE_TRASLATE_CLIENT_EMAIL: ${{ secrets.GOOGLE_TRASLATE_CLIENT_EMAIL }} GOOGLE_TRASLATE_PRIVATEKEY: ${{ secrets.GOOGLE_TRASLATE_PRIVATEKEY }} GA_PROPERTY_ID: ${{ secrets.GA_PROPERTY_ID }} GOOGLE_APPLICATION_CLIENT_EMAIL: ${{ secrets.GOOGLE_APPLICATION_CLIENT_EMAIL }} GOOGLE_APPLICATION_PRIVATEKEY: ${{ secrets.GOOGLE_APPLICATION_PRIVATEKEY }} GOOGLE_APPLICATION_WALLET_CLIENT_EMAIL: ${{ secrets.GOOGLE_APPLICATION_WALLET_CLIENT_EMAIL }} GOOGLE_APPLICATION_WALLET_PRIVATEKEY: ${{ secrets.GOOGLE_APPLICATION_WALLET_PRIVATEKEY }} OPENAI_KEY: ${{ secrets.OPENAI_KEY }} TWILIO_PHONE_US: ${{ secrets.TWILIO_PHONE_US }} TWILIO_PHONE_UK: ${{ secrets.TWILIO_PHONE_UK }} REDIS_HOST: ${{ secrets.REDIS_HOST }} REDIS_PORT: ${{ secrets.REDIS_PORT }} REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD }} CACHE_ENABLED: ${{ secrets.CACHE_ENABLED }} run: | cat > spec.json <<EOF { "name": "${SANITIZED_BRANCH}-app-preview", "region": "nyc", "services": [ { "name": "${SANITIZED_BRANCH}-app-preview", "git": { "repo_clone_url": "$REPO_CLONE_URL", "branch": "$BRANCH" }, "build_command": "echo build", "run_command": "npm start", "source_dir": "app", "instance_size_slug": "apps-s-1vcpu-1gb", "http_port": 8080, "health_check": { "http_path": "/api" }, "instance_count": 1, "environment_slug": "node-js", "envs": [ { "key": "CRYPTO_KEY", "value": "$CRYPTO_KEY", "scope": "RUN_AND_BUILD_TIME" }, { "key": "ADMIN_URL", "value": "$ADMIN_URL", "scope": "RUN_AND_BUILD_TIME" }, { "key": "APP_URL", "value": "$APP_URL", "scope": "RUN_AND_BUILD_TIME" }, { "key": "IS_PRODUCTION", "value": "$IS_PRODUCTION", "scope": "RUN_AND_BUILD_TIME" }, { "key": "NODE_ENV", "value": "$NODE_ENV", "scope": "RUN_AND_BUILD_TIME" }, { "key": "USE_NPM_INSTALL", "value": "$USE_NPM_INSTALL", "scope": "RUN_AND_BUILD_TIME" }, { "key": "NODE_MODULES_CACHE", "value": "$NODE_MODULES_CACHE", "scope": "RUN_AND_BUILD_TIME" }, { "key": "GOOGLE_CLIENT_ID", "value": "$GOOGLE_CLIENT_ID", "scope": "RUN_AND_BUILD_TIME" }, { "key": "GOOGLE_CLIENT_SECRET", "value": "$GOOGLE_CLIENT_SECRET", "scope": "RUN_AND_BUILD_TIME" }, { "key": "GOOGLE_TRASLATE_TEXT_KEY", "value": "$GOOGLE_TRASLATE_TEXT_KEY", "scope": "RUN_AND_BUILD_TIME" }, { "key": "JIRA_ACCOUNT_EMAIL", "value": "$JIRA_ACCOUNT_EMAIL", "scope": "RUN_AND_BUILD_TIME" }, { "key": "JIRA_ACCOUNT_TOKEN", "value": "$JIRA_ACCOUNT_TOKEN", "scope": "RUN_AND_BUILD_TIME" }, { "key": "JIRA_ISSUE_TYPE_ID", "value": "$JIRA_ISSUE_TYPE_ID", "scope": "RUN_AND_BUILD_TIME" }, { "key": "JIRA_MEMBER_ALEX", "value": "$JIRA_MEMBER_ALEX", "scope": "RUN_AND_BUILD_TIME" }, { "key": "JIRA_MEMBER_VIVEK", "value": "$JIRA_MEMBER_VIVEK", "scope": "RUN_AND_BUILD_TIME" }, { "key": "JIRA_PROJECT_ID", "value": "$JIRA_PROJECT_ID", "scope": "RUN_AND_BUILD_TIME" }, { "key": "JWT_ALGO", "value": "$JWT_ALGO", "scope": "RUN_AND_BUILD_TIME" }, { "key": "JWT_SECRET", "value": "$JWT_SECRET", "scope": "RUN_AND_BUILD_TIME" }, { "key": "LOGS_DB_HOST", "value": "$LOGS_DB_HOST", "scope": "RUN_AND_BUILD_TIME" }, { "key": "LOGS_DB_NAME", "value": "$LOGS_DB_NAME", "scope": "RUN_AND_BUILD_TIME" }, { "key": "LOGS_DB_PASSWORD", "value": "$LOGS_DB_PASSWORD", "scope": "RUN_AND_BUILD_TIME" }, { "key": "LOGS_DB_PORT", "value": "$LOGS_DB_PORT", "scope": "RUN_AND_BUILD_TIME" }, { "key": "LOGS_DB_USER", "value": "$LOGS_DB_USER", "scope": "RUN_AND_BUILD_TIME" }, { "key": "DB_HOST", "value": "$DB_HOST", "scope": "RUN_AND_BUILD_TIME" }, { "key": "DB_USER", "value": "$DB_USER", "scope": "RUN_AND_BUILD_TIME" }, { "key": "DB_PASSWORD", "value": "$DB_PASSWORD", "scope": "RUN_AND_BUILD_TIME" }, { "key": "DB_NAME", "value": "$DB_NAME", "scope": "RUN_AND_BUILD_TIME" }, { "key": "DB_PORT", "value": "$DB_PORT", "scope": "RUN_AND_BUILD_TIME" }, { "key": "MANDRILL_API_KEY", "value": "$MANDRILL_API_KEY", "scope": "RUN_AND_BUILD_TIME" }, { "key": "FROM_EMAIL", "value": "$FROM_EMAIL", "scope": "RUN_AND_BUILD_TIME" }, { "key": "EMAIL_ALERT", "value": "$EMAIL_ALERT", "scope": "RUN_AND_BUILD_TIME" }, { "key": "POSTMAN_TOKEN", "value": "$POSTMAN_TOKEN", "scope": "RUN_AND_BUILD_TIME" }, { "key": "RECAPTCHA_SECRETE_KEY", "value": "$RECAPTCHA_SECRETE_KEY", "scope": "RUN_AND_BUILD_TIME" }, { "key": "RECAPTCHA_SITE_KEY", "value": "$RECAPTCHA_SITE_KEY", "scope": "RUN_AND_BUILD_TIME" }, { "key": "S3_BUCKET_ACCESS_KEY_ID", "value": "$S3_BUCKET_ACCESS_KEY_ID", "scope": "RUN_AND_BUILD_TIME" }, { "key": "S3_BUCKET_SECRET_ACCESS_KEY", "value": "$S3_BUCKET_SECRET_ACCESS_KEY", "scope": "RUN_AND_BUILD_TIME" }, { "key": "S3_BUCKET_NAME", "value": "$S3_BUCKET_NAME", "scope": "RUN_AND_BUILD_TIME" }, { "key": "S3_BASE_URL", "value": "$S3_BASE_URL", "scope": "RUN_AND_BUILD_TIME" }, { "key": "S3_REGION", "value": "$S3_REGION", "scope": "RUN_AND_BUILD_TIME" }, { "key": "STRIPE_PK", "value": "$STRIPE_PK", "scope": "RUN_AND_BUILD_TIME" }, { "key": "STRIPE_SK", "value": "$STRIPE_SK", "scope": "RUN_AND_BUILD_TIME" }, { "key": "TWILIO_ACCOUNTSID", "value": "$TWILIO_ACCOUNTSID", "scope": "RUN_AND_BUILD_TIME" }, { "key": "TWILIO_AUTHTOKEN", "value": "$TWILIO_AUTHTOKEN", "scope": "RUN_AND_BUILD_TIME" }, { "key": "FACEBOOK_CLIENT_ID", "value": "$FACEBOOK_CLIENT_ID", "scope": "RUN_AND_BUILD_TIME" }, { "key": "FACEBOOK_CLIENT_SECRET", "value": "$FACEBOOK_CLIENT_SECRET", "scope": "RUN_AND_BUILD_TIME" }, { "key": "GOOGLE_TRASLATE_CLIENT_EMAIL", "value": "$GOOGLE_TRASLATE_CLIENT_EMAIL", "scope": "RUN_AND_BUILD_TIME" }, { "key": "GOOGLE_TRASLATE_PRIVATEKEY", "value": "$GOOGLE_TRASLATE_PRIVATEKEY", "scope": "RUN_AND_BUILD_TIME" }, { "key": "GA_PROPERTY_ID", "value": "$GA_PROPERTY_ID", "scope": "RUN_AND_BUILD_TIME" }, { "key": "GOOGLE_APPLICATION_CLIENT_EMAIL", "value": "$GOOGLE_APPLICATION_CLIENT_EMAIL", "scope": "RUN_AND_BUILD_TIME" }, { "key": "GOOGLE_APPLICATION_PRIVATEKEY", "value": "$GOOGLE_APPLICATION_PRIVATEKEY", "scope": "RUN_AND_BUILD_TIME" }, { "key": "GOOGLE_APPLICATION_WALLET_CLIENT_EMAIL", "value": "$GOOGLE_APPLICATION_WALLET_CLIENT_EMAIL", "scope": "RUN_AND_BUILD_TIME" }, { "key": "GOOGLE_APPLICATION_WALLET_PRIVATEKEY", "value": "$GOOGLE_APPLICATION_WALLET_PRIVATEKEY", "scope": "RUN_AND_BUILD_TIME" }, { "key": "OPENAI_KEY", "value": "$OPENAI_KEY", "scope": "RUN_AND_BUILD_TIME" }, { "key": "TWILIO_PHONE_US", "value": "$TWILIO_PHONE_US", "scope": "RUN_AND_BUILD_TIME" }, { "key": "TWILIO_PHONE_UK", "value": "$TWILIO_PHONE_UK", "scope": "RUN_AND_BUILD_TIME" }, { "key": "REDIS_HOST", "value": "$REDIS_HOST", "scope": "RUN_AND_BUILD_TIME" }, { "key": "REDIS_PORT", "value": "$REDIS_PORT", "scope": "RUN_AND_BUILD_TIME" }, { "key": "REDIS_PASSWORD", "value": "$REDIS_PASSWORD", "scope": "RUN_AND_BUILD_TIME" }, { "key": "CACHE_ENABLED", "value": "$CACHE_ENABLED", "scope": "RUN_AND_BUILD_TIME" } ], "routes": [ { "path": "/", "preserve_path_prefix": false } ] } ], "static_sites": [], "databases": [], "envs": [] } EOF - name: Get App ID id: get_app run: | APP_NAME="${{ steps.sanitize_branch.outputs.SANITIZED_BRANCH }}-app-preview" APP_ID=$(doctl apps list --format Spec.Name,ID --no-header | awk -v app="$APP_NAME" '$1 == app {print $2}') echo "APP_ID=${APP_ID}" >> $GITHUB_OUTPUT - name: Update App if: steps.get_app.outputs.APP_ID != '' run: doctl apps update ${{ steps.get_app.outputs.APP_ID }} --spec spec.json - name: Create App if: steps.get_app.outputs.APP_ID == '' run: doctl apps create --spec spec.json - name: Retrieve App ID Again id: get_new_app run: | APP_NAME="${{ steps.sanitize_branch.outputs.SANITIZED_BRANCH }}-app-preview" APP_ID=$(doctl apps list --format Spec.Name,ID --no-header | awk -v app="$APP_NAME" '$1 == app {print $2}') echo "APP_ID=${APP_ID}" >> $GITHUB_OUTPUT - name: Poll for APP and Get LIVE_URL if: steps.get_new_app.outputs.APP_ID != '' id: app_poll run: | #!/bin/bash set -e set -o pipefail APP_ID="${{ steps.get_new_app.outputs.APP_ID }}" MAX_ATTEMPTS=60 SLEEP_SECONDS=10 ATTEMPT=1 LIVE_URL="" echo "Starting to poll for Default Ingress URL for App ID: $APP_ID" while [ $ATTEMPT -le $MAX_ATTEMPTS ]; do echo "Attempt $ATTEMPT of $MAX_ATTEMPTS: Checking if app is live..." LIVE_URL=$(doctl apps get "$APP_ID" --format "Default Ingress" --no-header | xargs) if [[ -n "$LIVE_URL" ]]; then echo "Default Ingress URL found: $LIVE_URL" echo "LIVE_URL=$LIVE_URL" >> $GITHUB_OUTPUT break else echo "Default Ingress URL not found yet. Sleeping for $SLEEP_SECONDS seconds before retrying..." sleep $SLEEP_SECONDS fi ATTEMPT=$((ATTEMPT + 1)) done if [[ -z "$LIVE_URL" ]]; then echo "Error: Default Ingress URL was not found after $MAX_ATTEMPTS attempts." exit 1 fi - name: Generate Admin Spec env: REPO_CLONE_URL: "https://${{ secrets.GIT_DO_USERNAME }}:${{ secrets.GIT_DO_TOKEN }}@github.com/${{ github.repository }}.git" BRANCH: ${{ github.head_ref }} SANITIZED_BRANCH: ${{ steps.sanitize_branch.outputs.SANITIZED_BRANCH }} NEXT_PUBLIC_API_URL: ${{ steps.app_poll.outputs.LIVE_URL }} NEXT_JWT_SECRET: ${{ secrets.JWT_SECRET }} NEXT_PUBLIC_ENCRYPT_SECRET: ${{ secrets.CRYPTO_KEY }} NODE_ENV: ${{ secrets.NODE_ENV }} USE_NPM_INSTALL: ${{ secrets.USE_NPM_INSTALL }} NODE_MODULES_CACHE: ${{ secrets.NODE_MODULES_CACHE }} run: | cat > spec-admin.json <<EOF { "name": "${SANITIZED_BRANCH}-admin-preview", "region": "nyc", "services": [ { "name": "${SANITIZED_BRANCH}-admin-preview", "git": { "repo_clone_url": "$REPO_CLONE_URL", "branch": "$BRANCH" }, "build_command": "npm run build", "run_command": "npm start", "source_dir": "admin", "instance_size_slug": "apps-s-1vcpu-0.5gb", "http_port": 8080, "health_check": { "http_path": "/api/health" }, "instance_count": 1, "environment_slug": "node-js", "envs": [ { "key": "NEXT_PUBLIC_API_URL", "value": "$NEXT_PUBLIC_API_URL", "scope": "RUN_AND_BUILD_TIME" }, { "key": "NEXT_PUBLIC_ENCRYPT_SECRET", "value": "$NEXT_PUBLIC_ENCRYPT_SECRET", "scope": "RUN_AND_BUILD_TIME" }, { "key": "NEXT_JWT_SECRET", "value": "$NEXT_JWT_SECRET", "scope": "RUN_AND_BUILD_TIME" }, { "key": "NODE_ENV", "value": "$NODE_ENV", "scope": "RUN_AND_BUILD_TIME" }, { "key": "USE_NPM_INSTALL", "value": "$USE_NPM_INSTALL", "scope": "RUN_AND_BUILD_TIME" }, { "key": "NODE_MODULES_CACHE", "value": "$NODE_MODULES_CACHE", "scope": "RUN_AND_BUILD_TIME" }, ], "routes": [ { "path": "/", "preserve_path_prefix": false } ] } ], "static_sites": [], "databases": [], "envs": [] } EOF - name: Get Admin ID id: get_admin run: | ADMIN_NAME="${{ steps.sanitize_branch.outputs.SANITIZED_BRANCH }}-admin-preview" ADMIN_ID=$(doctl apps list --format Spec.Name,ID --no-header | awk -v app="$ADMIN_NAME" '$1 == app {print $2}') echo "ADMIN_ID=${ADMIN_ID}" >> $GITHUB_OUTPUT - name: Update Admin if: steps.get_admin.outputs.ADMIN_ID != '' run: doctl apps update ${{ steps.get_admin.outputs.ADMIN_ID }} --spec spec-admin.json - name: Create Admin if: steps.get_admin.outputs.ADMIN_ID == '' run: doctl apps create --spec spec-admin.json - name: Retrieve Admin ID Again id: get_new_admin run: | ADMIN_NAME="${{ steps.sanitize_branch.outputs.SANITIZED_BRANCH }}-admin-preview" ADMIN_ID=$(doctl apps list --format Spec.Name,ID --no-header | awk -v app="$ADMIN_NAME" '$1 == app {print $2}') echo "ADMIN_ID=${ADMIN_ID}" >> $GITHUB_OUTPUT - name: Poll for ADMIN and Get LIVE_URL if: steps.get_new_admin.outputs.ADMIN_ID != '' id: admin_poll run: | #!/bin/bash set -e set -o pipefail APP_ID="${{ steps.get_new_admin.outputs.ADMIN_ID }}" MAX_ATTEMPTS=60 SLEEP_SECONDS=10 ATTEMPT=1 LIVE_URL="" echo "Starting to poll for Default Ingress URL for App ID: $APP_ID" while [ $ATTEMPT -le $MAX_ATTEMPTS ]; do echo "Attempt $ATTEMPT of $MAX_ATTEMPTS: Checking if app is live..." LIVE_URL=$(doctl apps get "$APP_ID" --format "Default Ingress" --no-header | xargs) if [[ -n "$LIVE_URL" ]]; then echo "Default Ingress URL found: $LIVE_URL" echo "LIVE_URL=$LIVE_URL" >> $GITHUB_OUTPUT break else echo "Default Ingress URL not found yet. Sleeping for $SLEEP_SECONDS seconds before retrying..." sleep $SLEEP_SECONDS fi ATTEMPT=$((ATTEMPT + 1)) done if [[ -z "$LIVE_URL" ]]; then echo "Error: Default Ingress URL was not found after $MAX_ATTEMPTS attempts." exit 1 fi - name: Update spec.json with LIVE_URLS if: steps.app_poll.outputs.LIVE_URL != '' run: | echo "${{ steps.app_poll.outputs.LIVE_URL }}" echo "${{ steps.admin_poll.outputs.LIVE_URL }}" SPEC_FILE="spec.json" if [[ ! -f "$SPEC_FILE" ]]; then echo "Error: $SPEC_FILE does not exist." exit 1 fi cp "$SPEC_FILE" "${SPEC_FILE}.bak" UPDATED_SPEC=$(jq --arg app_url "${{ steps.app_poll.outputs.LIVE_URL }}" --arg admin_url "${{ steps.admin_poll.outputs.LIVE_URL }}" ' .services[].envs |= map( if .key == "APP_URL" then .value = $app_url elif .key == "ADMIN_URL" then .value = $admin_url else . end ) ' "$SPEC_FILE") if [[ $? -ne 0 ]]; then echo "Error: Failed to update $SPEC_FILE with the new APP_URL and ADMIN_URL." exit 1 fi echo "$UPDATED_SPEC" > "$SPEC_FILE" echo "spec.json has been updated with APP_URL: ${{ steps.app_poll.outputs.LIVE_URL }} and ADMIN_URL: ${{ steps.admin_poll.outputs.LIVE_URL }}" - name: Update App with Updated spec.json if: steps.get_new_app.outputs.APP_ID != '' run: | APP_ID="${{ steps.get_new_app.outputs.APP_ID }}" echo "Updating the app platform with the new spec.json..." doctl apps update "$APP_ID" --spec "spec.json" if [[ $? -eq 0 ]]; then echo "App platform updated successfully with the new APP_URL." else echo "Error: Failed to update the app platform." exit 1 fi - name: Send Deployment Notification Email uses: dawidd6/action-send-mail@v3 with: server_address: smtp.mandrillapp.com server_port: 587 username: ${{ secrets.FROM_EMAIL }} password: ${{ secrets.MANDRILL_API_KEY }} subject: "Deployment Successful" body: | Hello, Your deployment has been successfully completed. **App URL:** ${{ steps.app_poll.outputs.LIVE_URL }} **Admin URL:** ${{ steps.admin_poll.outputs.LIVE_URL }} Regards, Your Deployment Team to: ${{ secrets.EMAIL_ALERT }} from: ${{ secrets.FROM_EMAIL }}

Required Secrets

The workflow requires the following GitHub secrets to be configured:

  • DigitalOcean: DO_API_TOKEN, DO_DROPLET_IP, DO_DROPLET_PASSWORD
  • Database: MANAGED_DB_HOST, MANAGED_DB_PORT, MANAGED_DB_USER, MANAGED_DB_PASSWORD, DB_* credentials
  • Git: GIT_DO_USERNAME, GIT_DO_TOKEN
  • Application Environment Variables: All app-specific secrets (JWT, Stripe, Twilio, Google, etc.)
  • Email: MANDRILL_API_KEY, FROM_EMAIL, EMAIL_ALERT
Last updated on