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
| Aspect | Render | Railway | Fly.io |
|---|---|---|---|
| Native auto-deploy | ✅ Git push | ✅ Git push | ❌ (needs Actions) |
| GitHub Actions setup | REST API (curl) | CLI (railway up) | CLI (flyctl deploy) |
| Official action | ❌ | ❌ | ✅ superfly/flyctl-actions |
| Multi-environment | Manual (separate services) | ✅ Native (environments) | Manual (separate apps) |
| Rollback | Dashboard (redeploy a previous one) | Dashboard + CLI | flyctl releases rollback |
| Deploy hooks | ✅ Deploy hooks URL | ✅ Webhooks | ❌ (use Actions) |
| Secret management | Dashboard/API | CLI + Dashboard | CLI + 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) usingRAILWAY_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
- GitHub Actions Documentation — Official documentation
- Render Deploy Hooks — Trigger deploys from CI
- Railway CI/CD — CI/CD integration guide
- Fly.io GitHub Actions — Official CI/CD guide
- GitHub Environments — Staging/production environments
- Deployment Best Practices — Martin Fowler — Deployment pipeline principles