Module 7: Alternative Platforms (Render, Railway, Fly.io)

6. CI/CD Integration with Alternative Platforms

Description

In this capsule you'll integrate GitHub Actions with Render, Railway and Fly.io to automate deployments. All three platforms offer auto-deploy from Git, but in production you need more control: tests before the deploy, environment promotion (staging → production), automated rollback, and notifications. GitHub Actions (which you already know from prerequisite #16) is the piece that connects your CI pipeline with the deployment on the chosen platform.

Context: The previous capsules (02-04) deployed manually — dashboard or CLI. That's fine for the first time, but on a team and in production you need the process to be reproducible, auditable and automatic. A push to main should run tests, build the image, deploy to staging, verify health, and promote to production. This capsule builds that pipeline for each platform.


The Deployment Pipeline

Pipeline structure

Push to main
    ↓
GitHub Actions
    ├── 1. Checkout code
    ├── 2. Setup Python
    ├── 3. Install dependencies
    ├── 4. Run tests
    ├── 5. Build Docker image (optional, depends on the platform)
    ├── 6. Deploy to staging
    ├── 7. Health check on staging
    ├── 8. Deploy to production (if staging passes)
    └── 9. Notification (Slack, Discord, etc.)

Prerequisites

# You need:
# 1. A repository on GitHub
# 2. An app deployed on at least one platform (M7 caps 02-04)
# 3. Basic tests for your app
# 4. GitHub Actions enabled on your repo

# Verify structure
ls .github/workflows/
# If it doesn't exist, create it:
mkdir -p .github/workflows

Basic tests for your AI app

Before automating the deploy, you need tests that verify your app works:

# tests/test_app.py
import pytest
from fastapi.testclient import TestClient
from unittest.mock import patch, MagicMock

from main import app

client = TestClient(app)


def test_health_check():
    response = client.get("/health")
    assert response.status_code == 200
    data = response.json()
    assert data["status"] == "healthy"
    assert "version" in data


def test_ask_requires_api_key():
    with patch.dict("os.environ", {}, clear=True):
        response = client.post(
            "/ask",
            json={"question": "test", "max_tokens": 50},
        )
        assert response.status_code == 500


@patch("main.openai.OpenAI")
def test_ask_returns_answer(mock_openai):
    mock_response = MagicMock()
    mock_response.choices = [MagicMock()]
    mock_response.choices[0].message.content = "Test answer"
    mock_response.usage.total_tokens = 42
    mock_openai.return_value.chat.completions.create.return_value = mock_response

    with patch.dict("os.environ", {"OPENAI_API_KEY": "sk-test"}):
        response = client.post(
            "/ask",
            json={"question": "What is Python?", "max_tokens": 50},
        )
        assert response.status_code == 200
        data = response.json()
        assert "answer" in data
# tests/requirements-test.txt
pytest==8.3.0
httpx==0.27.0

GitHub Actions + Render

Option 1: Native auto-deploy (without GitHub Actions)

Render deploys automatically when you push to main. You don't need GitHub Actions for a basic deploy. But if you want tests before the deploy:

Option 2: Controlled deploy with GitHub Actions

# .github/workflows/deploy-render.yml
name: Deploy to Render

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  RENDER_SERVICE_ID: ${{ secrets.RENDER_SERVICE_ID }}

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Install dependencies
        run: |
          pip install -r app/requirements.txt
          pip install -r tests/requirements-test.txt

      - name: Run tests
        run: pytest tests/ -v

  deploy:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'

    steps:
      - name: Trigger Render Deploy
        run: |
          curl -X POST \
            "https://api.render.com/v1/services/${{ secrets.RENDER_SERVICE_ID }}/deploys" \
            -H "Authorization: Bearer ${{ secrets.RENDER_API_KEY }}" \
            -H "Content-Type: application/json" \
            -d '{"clearCache": "do_not_clear"}'

      - name: Wait for deploy
        run: sleep 120

      - name: Health check
        run: |
          STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
            ${{ secrets.RENDER_URL }}/health)
          if [ "$STATUS" != "200" ]; then
            echo "Health check failed with status $STATUS"
            exit 1
          fi
          echo "Health check passed"

Configure secrets in GitHub

GitHub → your repo → Settings → Secrets and variables → Actions

Add:
- RENDER_API_KEY: (Dashboard → Account Settings → API Keys)
- RENDER_SERVICE_ID: (Dashboard → your service → the URL contains the ID: srv-xxx)
- RENDER_URL: https://your-service.onrender.com

Disable Render's auto-deploy

If you use GitHub Actions to control the deploy, turn off Render's auto-deploy:

Dashboard → your service → Settings → Build & Deploy
Auto-Deploy: OFF

Now only GitHub Actions triggers deploys, after passing tests.


GitHub Actions + Railway

Deploy with the Railway CLI in GitHub Actions

# .github/workflows/deploy-railway.yml
name: Deploy to Railway

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Install dependencies
        run: |
          pip install -r app/requirements.txt
          pip install -r tests/requirements-test.txt

      - name: Run tests
        run: pytest tests/ -v

  deploy-staging:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'

    steps:
      - uses: actions/checkout@v4

      - name: Install Railway CLI
        run: npm install -g @railway/cli

      - name: Deploy to staging
        env:
          RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
        run: railway up --environment staging --detach

      - name: Wait for staging deploy
        run: sleep 60

      - name: Health check staging
        run: |
          STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
            ${{ secrets.RAILWAY_STAGING_URL }}/health)
          if [ "$STATUS" != "200" ]; then
            echo "Staging health check failed"
            exit 1
          fi

  deploy-production:
    needs: deploy-staging
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Install Railway CLI
        run: npm install -g @railway/cli

      - name: Deploy to production
        env:
          RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
        run: railway up --environment production --detach

      - name: Wait for production deploy
        run: sleep 60

      - name: Health check production
        run: |
          STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
            ${{ secrets.RAILWAY_PRODUCTION_URL }}/health)
          if [ "$STATUS" != "200" ]; then
            echo "Production health check failed!"
            exit 1
          fi
          echo "Production deploy successful"

Get the Railway Token

# Generate a service token (doesn't use your personal session)
# Dashboard → your project → Settings → Tokens → Create Token

# Or from the CLI:
railway tokens create
# Token: rlwy_xxx

# Add to GitHub Secrets:
# RAILWAY_TOKEN = rlwy_xxx
# RAILWAY_STAGING_URL = https://...staging.up.railway.app
# RAILWAY_PRODUCTION_URL = https://...production.up.railway.app

Configure environments in Railway

# Railway supports multiple environments natively
# Dashboard → Project → Settings → Environments

# Create a staging environment:
# + New Environment → staging

# Each environment has:
# - Its own variables
# - Its own URL
# - Its own database instance (optional)

GitHub Actions + Fly.io

Deploy with flyctl in GitHub Actions

# .github/workflows/deploy-flyio.yml
name: Deploy to Fly.io

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Install dependencies
        run: |
          pip install -r app/requirements.txt
          pip install -r tests/requirements-test.txt

      - name: Run tests
        run: pytest tests/ -v

  deploy-staging:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'

    steps:
      - uses: actions/checkout@v4

      - name: Setup Fly.io CLI
        uses: superfly/flyctl-actions/setup-flyctl@master

      - name: Deploy to staging
        env:
          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
        run: flyctl deploy --app docusearch-ai-staging --remote-only

      - name: Health check staging
        run: |
          sleep 30
          STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
            https://docusearch-ai-staging.fly.dev/health)
          if [ "$STATUS" != "200" ]; then
            echo "Staging health check failed"
            exit 1
          fi

  deploy-production:
    needs: deploy-staging
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup Fly.io CLI
        uses: superfly/flyctl-actions/setup-flyctl@master

      - name: Deploy to production
        env:
          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
        run: flyctl deploy --app docusearch-ai --remote-only

      - name: Health check production
        run: |
          sleep 30
          STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
            https://docusearch-ai.fly.dev/health)
          if [ "$STATUS" != "200" ]; then
            echo "Production health check failed!"
            flyctl releases --app docusearch-ai
            exit 1
          fi
          echo "Production deploy successful"

      - name: Show deployment info
        env:
          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
        run: |
          flyctl status --app docusearch-ai
          flyctl releases --app docusearch-ai --image

Get the Fly.io API Token

# Create a token
flyctl tokens create deploy -x 999999h
# Token: FlyV1 xxx

# Add to GitHub Secrets:
# FLY_API_TOKEN = FlyV1 xxx

Fly.io: Staging and production apps

# Create a staging app (separate from production)
flyctl launch --name docusearch-ai-staging --region iad --no-deploy

# Copy secrets to staging
flyctl secrets set OPENAI_API_KEY=sk-staging-xxx --app docusearch-ai-staging

# Now you have:
# Production: docusearch-ai.fly.dev
# Staging: docusearch-ai-staging.fly.dev

Environment Promotion: Staging → Production

The promotion pattern

Feature branch → PR → Tests → Merge to main
    ↓
Deploy to STAGING (automatic on push to main)
    ↓
Health check + smoke tests on staging
    ↓
Deploy to PRODUCTION (automatic if staging passes)
    ↓
Health check on production
    ↓
Success/failure notification

Promotion workflow

The pattern is: test → staging → smoke tests → production → health check. Each job depends on the previous one. If staging fails, production doesn't run. The complete workflow is implemented in this capsule's exercises by combining the blocks for each platform shown above.


Comparison: CI/CD by Platform

AspectRenderRailwayFly.io
Native auto-deploy✅ Git push✅ Git push❌ (needs Actions)
GitHub Actions setupREST API (curl)CLI (railway up)CLI (flyctl deploy)
Official action✅ superfly/flyctl-actions
Multi-environmentManual (separate services)✅ Native (environments)Manual (separate apps)
RollbackDashboard (redeploy a previous one)Dashboard + CLIflyctl releases rollback
Deploy hooks✅ Deploy hooks URL✅ Webhooks❌ (use Actions)
Secret managementDashboard/APICLI + DashboardCLI + Dashboard

Troubleshooting

Problem 1: "GitHub Actions fails — railway/flyctl command not found"

Solution: You need to install the CLI on the GitHub Actions runner:

# Railway
- name: Install Railway
  run: npm install -g @railway/cli

# Fly.io (use the official action)
- uses: superfly/flyctl-actions/setup-flyctl@master

Problem 2: "Deploy works locally but fails in CI — authentication error"

Solution: In CI you can't use interactive login. You need tokens:

# Railway — use RAILWAY_TOKEN
env:
  RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}

# Fly.io — use FLY_API_TOKEN
env:
  FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}

# Render — use the API key in headers
-H "Authorization: Bearer ${{ secrets.RENDER_API_KEY }}"

Problem 3: "Health check passes in staging but fails in production"

Solution: Different environment variables between staging and production. Verify:

- name: Debug environment
  run: |
    echo "Checking staging..."
    curl -s ${{ secrets.STAGING_URL }}/health | jq .
    echo "Checking production..."
    curl -s ${{ secrets.PRODUCTION_URL }}/health | jq .

Common causes:

  • Production API key expired or incorrect
  • Production database not migrated
  • Different plan limits (Free vs Starter)

Problem 4: "The pipeline takes too long — 10+ minutes"

Solution: Optimize the pipeline:

# Cache Python dependencies
- uses: actions/setup-python@v5
  with:
    python-version: "3.11"
    cache: "pip"

# Reduce wait times (use polling instead of a fixed sleep)
- name: Health check with polling
  run: |
    for i in $(seq 1 20); do
      STATUS=$(curl -s -o /dev/null -w "%{http_code}" $URL/health 2>/dev/null)
      if [ "$STATUS" = "200" ]; then exit 0; fi
      sleep 10
    done
    exit 1

Problem 5: "I want automatic rollback if production fails"

Solution: Each platform has its mechanism:

# Fly.io — rollback to the previous release
flyctl releases rollback --app docusearch-ai

# Railway — redeploy from a previous commit
railway up --commit abc123

# Render — from the dashboard, click "Manual Deploy" on a previous deploy
# Or via the API:
curl -X POST "https://api.render.com/v1/services/$SVC_ID/deploys" \
  -H "Authorization: Bearer $API_KEY" \
  -d '{"commitId": "abc123"}'

Hands-On Exercises

Exercise 1: Basic CI/CD pipeline

Create a GitHub Actions workflow that runs tests and deploys to your chosen platform when you push to main.

See solution
# .github/workflows/deploy.yml
name: Test and Deploy

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"
          cache: "pip"

      - name: Install dependencies
        working-directory: app
        run: |
          pip install -r requirements.txt
          pip install pytest httpx

      - name: Run tests
        working-directory: app
        run: pytest tests/ -v

  deploy:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'

    steps:
      - uses: actions/checkout@v4

      # === Choose ONE according to your platform ===

      # Render:
      - name: Deploy to Render
        if: false  # Change to true if you use Render
        run: |
          curl -X POST \
            "https://api.render.com/v1/services/${{ secrets.RENDER_SERVICE_ID }}/deploys" \
            -H "Authorization: Bearer ${{ secrets.RENDER_API_KEY }}" \
            -H "Content-Type: application/json"

      # Railway:
      - name: Deploy to Railway
        if: false  # Change to true if you use Railway
        env:
          RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
        run: |
          npm install -g @railway/cli
          railway up --detach

      # Fly.io:
      - name: Deploy to Fly.io
        if: false  # Change to true if you use Fly.io
        env:
          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
        run: |
          curl -L https://fly.io/install.sh | sh
          flyctl deploy --remote-only

      - name: Health check
        run: |
          sleep 60
          curl -f ${{ secrets.APP_URL }}/health
# Configure secrets in GitHub
# Settings → Secrets → Actions → New repository secret

# For Render:
# RENDER_API_KEY, RENDER_SERVICE_ID, APP_URL

# For Railway:
# RAILWAY_TOKEN, APP_URL

# For Fly.io:
# FLY_API_TOKEN, APP_URL

# Push and verify
git add .github/workflows/deploy.yml
git commit -m "Add CI/CD pipeline"
git push origin main

# Go to GitHub → Actions to monitor the pipeline

Exercise 2: Pipeline with staging → production

Extend the previous pipeline so it deploys first to staging, runs smoke tests, and then promotes to production. Use GitHub Environments to separate staging/production with their own variables.

See solution
# .github/workflows/deploy-staged.yml
name: Staged Deployment

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"
          cache: "pip"
      - run: pip install -r app/requirements.txt pytest httpx
      - run: pytest tests/ -v

  staging:
    needs: test
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to staging
        env:
          RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
        run: |
          npm install -g @railway/cli
          railway up --environment staging --detach
      - name: Verify staging
        run: |
          sleep 60
          curl -f ${{ vars.STAGING_URL }}/health

  production:
    needs: staging
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to production
        env:
          RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
        run: |
          npm install -g @railway/cli
          railway up --environment production --detach
      - name: Verify production
        run: |
          sleep 60
          curl -f ${{ vars.PRODUCTION_URL }}/health

Exercise 3: Configure automatic rollback

Add a step that performs an automatic rollback if the production health check fails after the deploy.

See solution
# Add after the production deploy:
  production:
    needs: smoke-test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Fly CLI
        uses: superfly/flyctl-actions/setup-flyctl@master

      - name: Get current release
        id: pre_deploy
        env:
          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
        run: |
          CURRENT=$(flyctl releases --app docusearch-ai --json | \
            python3 -c "import sys,json; print(json.load(sys.stdin)[0]['Version'])")
          echo "version=$CURRENT" >> $GITHUB_OUTPUT
          echo "Current release: v$CURRENT"

      - name: Deploy
        env:
          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
        run: flyctl deploy --app docusearch-ai --remote-only

      - name: Health check with rollback
        env:
          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
        run: |
          sleep 30

          HEALTHY=false
          for i in $(seq 1 10); do
            STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
              https://docusearch-ai.fly.dev/health 2>/dev/null)
            if [ "$STATUS" = "200" ]; then
              HEALTHY=true
              break
            fi
            echo "Health check attempt $i failed (status: $STATUS)"
            sleep 10
          done

          if [ "$HEALTHY" = "false" ]; then
            echo "::error::Production unhealthy — rolling back to v${{ steps.pre_deploy.outputs.version }}"
            flyctl releases rollback --app docusearch-ai \
              ${{ steps.pre_deploy.outputs.version }}
            sleep 30

            ROLLBACK_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
              https://docusearch-ai.fly.dev/health 2>/dev/null)
            echo "Rollback health check: $ROLLBACK_STATUS"
            exit 1
          fi

          echo "Production deploy successful"

Exercise 4: Deploy notification by webhook

Add successful/failed deploy notifications using a webhook (Discord, Slack, or any URL).

See solution
# Notification job at the end of the pipeline
  notify:
    needs: [test, staging, production]
    runs-on: ubuntu-latest
    if: always()

    steps:
      - name: Build notification payload
        id: payload
        run: |
          if [ "${{ needs.production.result }}" = "success" ]; then
            STATUS="success"
            COLOR="3066993"
            MSG="Successful deploy in production"
          elif [ "${{ needs.staging.result }}" = "failure" ]; then
            STATUS="failed"
            COLOR="15158332"
            MSG="Deploy failed in staging"
          elif [ "${{ needs.test.result }}" = "failure" ]; then
            STATUS="failed"
            COLOR="15158332"
            MSG="Tests failed — deploy aborted"
          else
            STATUS="failed"
            COLOR="15158332"
            MSG="Deploy failed in production"
          fi
          echo "status=$STATUS" >> $GITHUB_OUTPUT
          echo "color=$COLOR" >> $GITHUB_OUTPUT
          echo "msg=$MSG" >> $GITHUB_OUTPUT

      - name: Send Discord notification
        if: secrets.DISCORD_WEBHOOK != ''
        run: |
          curl -X POST "${{ secrets.DISCORD_WEBHOOK }}" \
            -H "Content-Type: application/json" \
            -d '{
              "embeds": [{
                "title": "Deploy: ${{ steps.payload.outputs.status }}",
                "description": "${{ steps.payload.outputs.msg }}",
                "color": ${{ steps.payload.outputs.color }},
                "fields": [
                  {"name": "Repo", "value": "${{ github.repository }}", "inline": true},
                  {"name": "Branch", "value": "${{ github.ref_name }}", "inline": true},
                  {"name": "Commit", "value": "${{ github.sha }}", "inline": false}
                ]
              }]
            }'

      - name: Send Slack notification
        if: secrets.SLACK_WEBHOOK != ''
        run: |
          curl -X POST "${{ secrets.SLACK_WEBHOOK }}" \
            -H "Content-Type: application/json" \
            -d '{
              "text": "${{ steps.payload.outputs.msg }} — ${{ github.repository }}@${{ github.ref_name }}"
            }'

Summary

  • GitHub Actions connects your CI pipeline with the deployment on any platform.
  • Render has no CLI — the deploy is triggered via the REST API with curl.
  • Railway integrates with its CLI (railway up --environment xxx) using RAILWAY_TOKEN.
  • Fly.io has an official action (superfly/flyctl-actions) and a powerful CLI.
  • Environment promotion (staging → production) is the professional pattern: never deploy to production without verifying in staging.
  • Health checks after the deploy are mandatory — don't assume the deploy was successful just because there was no error.
  • Automatic rollback is possible on Fly.io (releases rollback), Railway (redeploy a commit), and Render (API deploy with commitId).
  • Tests before the deploy are the first line of defense — if the tests fail, the deploy doesn't run.

Additional Resources

  1. GitHub Actions Documentation — Official documentation
  2. Render Deploy Hooks — Trigger deploys from CI
  3. Railway CI/CD — CI/CD integration guide
  4. Fly.io GitHub Actions — Official CI/CD guide
  5. GitHub Environments — Staging/production environments
  6. Deployment Best Practices — Martin Fowler — Deployment pipeline principles