A comprehensive GitHub Action to deploy Flask applications to Google App Engine with full support for:
- ✅ Google App Engine deployment with automatic scaling
- ✅ Firestore database setup and migrations
- ✅ Cloudflare DNS integration with SSL/TLS
- ✅ Traffic splitting for blue-green deployments
- ✅ Custom domains with automatic HTTPS
- ✅ Version management for seamless upgrades
- ✅ VPC connectivity for private resources
- ✅ Environment variables and secrets management
New to this action? Check out the Quick Start Guide to get deployed in 15 minutes!
- Automatic deployment to Google App Engine
- Support for multiple services and versions
- Configurable scaling (automatic, basic, or manual)
- Multiple Python runtime versions (3.11, 3.12)
- Instance class selection (F1, F2, F4, etc.)
- Custom entrypoint support (Gunicorn pre-configured)
- Firestore database initialization
- Database migration support
- Firestore index deployment
- Sample data seeding
- Both Native and Datastore modes
- Google Secret Manager integration
- Secure storage for API keys, passwords, and sensitive configuration
- Automatic secret access from Flask applications
- IAM-based access control
- Secret versioning and rotation support
- Cloudflare DNS integration
- Automatic SSL/TLS configuration
- Custom domain mapping
- HTTPS enforcement (all traffic)
- Multiple SSL modes (flexible, full, strict)
- Version-based deployments
- Traffic splitting for gradual rollouts
- Blue-green deployment support
- Zero-downtime deployments
- Version cleanup options
- VPC connector support
- Cloud Armor integration (optional)
- Service account authentication
- Secure-only traffic (HTTPS)
- Environment variable encryption
name: Deploy to App Engine
on:
push:
branches: [ main ]
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: thoughtparametersllc/flaskapp-deploy-action@v1
with:
project_id: my-gcp-project
service_account_key: ${{ secrets.GCP_SA_KEY }}name: Deploy with Firestore and Custom Domain
on:
push:
branches: [ main ]
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: thoughtparametersllc/flaskapp-deploy-action@v1
with:
# GCP Configuration
project_id: my-gcp-project
service_account_key: ${{ secrets.GCP_SA_KEY }}
region: us-central
# App Engine Configuration
service_name: api
runtime: python311
instance_class: F2
scaling_type: automatic
max_instances: 20
min_instances: 2
# Firestore Configuration
enable_firestore: true
run_migrations: true
migration_script: migrate.py
# Cloudflare & Custom Domain
cloudflare_api_token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
cloudflare_zone_id: ${{ secrets.CLOUDFLARE_ZONE_ID }}
custom_domain: api.example.com
enable_ssl: true
ssl_mode: full
# Environment Variables
env_variables: '{"FLASK_ENV":"production"}'| Input | Description | Required | Default |
|---|---|---|---|
project_id |
Google Cloud Project ID | Yes | - |
service_account_key |
GCP Service Account JSON key | Yes | - |
region |
GCP region for App Engine | No | us-central |
| Input | Description | Required | Default |
|---|---|---|---|
service_name |
App Engine service name | No | default |
runtime |
Python runtime (python311, python312) | No | python311 |
instance_class |
Instance class (F1, F2, F4, etc.) | No | F1 |
scaling_type |
Scaling type (automatic, basic, manual) | No | automatic |
max_instances |
Maximum number of instances | No | 10 |
min_instances |
Minimum number of instances | No | 1 |
| Input | Description | Required | Default |
|---|---|---|---|
app_yaml_path |
Path to custom app.yaml | No | (generated) |
requirements_file |
Path to requirements.txt | No | requirements.txt |
entrypoint |
Custom entrypoint command | No | (gunicorn) |
| Input | Description | Required | Default |
|---|---|---|---|
enable_firestore |
Enable Firestore setup | No | true |
firestore_mode |
Firestore mode (native or datastore) | No | native |
firestore_location |
Firestore location | No | us-central |
run_migrations |
Run migration script | No | false |
migration_script |
Path to migration script | No | migrate.py |
| Input | Description | Required | Default |
|---|---|---|---|
promote_version |
Promote version to receive all traffic | No | true |
traffic_split |
Traffic split config (e.g., "v1=0.9,v2=0.1") | No | - |
stop_previous_version |
Stop previous version after deploy | No | false |
| Input | Description | Required | Default |
|---|---|---|---|
cloudflare_api_token |
Cloudflare API token | No | - |
cloudflare_zone_id |
Cloudflare Zone ID | No | - |
custom_domain |
Custom domain to map | No | - |
enable_ssl |
Enable SSL/TLS configuration | No | true |
ssl_mode |
SSL mode (flexible, full, strict) | No | full |
| Input | Description | Required | Default |
|---|---|---|---|
env_variables |
Environment vars in JSON format | No | {} |
vpc_connector |
VPC connector name | No | - |
enable_cloud_armor |
Enable Cloud Armor | No | false |
| Output | Description |
|---|---|
service_url |
URL of the deployed service |
version_id |
Deployed version ID |
-
Create a GCP project or use an existing one
-
Enable required APIs:
gcloud services enable appengine.googleapis.com gcloud services enable cloudbuild.googleapis.com gcloud services enable firestore.googleapis.com gcloud services enable secretmanager.googleapis.com
-
Initialize App Engine (required once per project):
# Initialize App Engine in your preferred region gcloud app create --region=us-central # Available regions: us-central, us-east1, us-west2, europe-west, asia-northeast1, etc. # Full list: https://cloud.google.com/appengine/docs/locations
-
Create a service account with required permissions:
gcloud iam service-accounts create github-deploy \ --display-name="GitHub Actions Deploy" gcloud projects add-iam-policy-binding PROJECT_ID \ --member="serviceAccount:github-deploy@PROJECT_ID.iam.gserviceaccount.com" \ --role="roles/appengine.appAdmin" gcloud projects add-iam-policy-binding PROJECT_ID \ --member="serviceAccount:github-deploy@PROJECT_ID.iam.gserviceaccount.com" \ --role="roles/cloudbuild.builds.editor" gcloud projects add-iam-policy-binding PROJECT_ID \ --member="serviceAccount:github-deploy@PROJECT_ID.iam.gserviceaccount.com" \ --role="roles/datastore.owner" # Grant Secret Manager access (if using secrets) # Note: Use secret-level permissions for better security. This grants # read-only access at the project level for simplicity. gcloud projects add-iam-policy-binding PROJECT_ID \ --member="serviceAccount:github-deploy@PROJECT_ID.iam.gserviceaccount.com" \ --role="roles/secretmanager.secretAccessor"
-
Grant App Engine default service account access to secrets:
# This allows your deployed app to access Secret Manager gcloud projects add-iam-policy-binding PROJECT_ID \ --member="serviceAccount:PROJECT_ID@appspot.gserviceaccount.com" \ --role="roles/secretmanager.secretAccessor"
-
Create and download service account key:
gcloud iam service-accounts keys create key.json \ --iam-account=github-deploy@PROJECT_ID.iam.gserviceaccount.com
-
Add the key to GitHub Secrets as
GCP_SA_KEY
For custom domain with Cloudflare DNS:
-
Get your Cloudflare API Token:
- Go to Cloudflare Dashboard → My Profile → API Tokens
- Create token with
Zone.DNSandZone.SSL and Certificatespermissions
-
Get your Zone ID from the Cloudflare Dashboard
-
Add to GitHub Secrets:
CLOUDFLARE_API_TOKENCLOUDFLARE_ZONE_ID
Your Flask application should follow this structure:
your-app/
├── main.py # Main application file (entry point)
├── requirements.txt # Python dependencies
├── app.yaml # App Engine config (optional)
├── migrate.py # Migration script (optional)
├── firestore.indexes.json # Firestore indexes (optional)
├── .gcloudignore # Files to exclude from deployment
└── static/ # Static files (optional)
from flask import Flask
app = Flask(__name__)
@app.route('/')
def hello():
return 'Hello, World!'
@app.route('/health')
def health():
return {'status': 'healthy'}
if __name__ == '__main__':
app.run(host='127.0.0.1', port=8080)Flask>=3.0.0
gunicorn>=21.2.0
google-cloud-firestore>=2.14.0 # If using Firestore
runtime: python311
service: default
instance_class: F1
automatic_scaling:
min_instances: 1
max_instances: 10
entrypoint: gunicorn -b :$PORT main:appSee the complete example for a full working application.
- uses: thoughtparametersllc/flaskapp-deploy-action@v1
with:
project_id: my-project
service_account_key: ${{ secrets.GCP_SA_KEY }}- uses: thoughtparametersllc/flaskapp-deploy-action@v1
with:
project_id: my-project
service_account_key: ${{ secrets.GCP_SA_KEY }}
enable_firestore: true
run_migrations: true- uses: thoughtparametersllc/flaskapp-deploy-action@v1
with:
project_id: my-project
service_account_key: ${{ secrets.GCP_SA_KEY }}
custom_domain: api.example.com
cloudflare_api_token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
cloudflare_zone_id: ${{ secrets.CLOUDFLARE_ZONE_ID }}
ssl_mode: full# Step 1: Deploy new version without promoting
- uses: thoughtparametersllc/flaskapp-deploy-action@v1
with:
project_id: my-project
service_account_key: ${{ secrets.GCP_SA_KEY }}
promote_version: false
# Step 2: Gradually shift traffic
- uses: thoughtparametersllc/flaskapp-deploy-action@v1
with:
project_id: my-project
service_account_key: ${{ secrets.GCP_SA_KEY }}
traffic_split: v1=0.9,v2=0.1strategy:
matrix:
service: [api, admin, worker]
steps:
- uses: thoughtparametersllc/flaskapp-deploy-action@v1
with:
project_id: my-project
service_account_key: ${{ secrets.GCP_SA_KEY }}
service_name: ${{ matrix.service }}- Authentication: Authenticates with Google Cloud using service account
- Validation: Validates all input parameters
- App Configuration: Generates or uses provided app.yaml
- Database Setup: Initializes Firestore (if enabled)
- Migrations: Runs database migrations (if enabled)
- Deployment: Deploys to App Engine
- Traffic Management: Configures traffic splitting (if specified)
- Domain Mapping: Maps custom domain (if provided)
- DNS Configuration: Updates Cloudflare DNS (if configured)
- SSL Setup: Configures SSL/TLS settings
- Verification: Verifies deployment success
App Engine automatically uses Gunicorn as the WSGI server. The default configuration is:
gunicorn -b :$PORT main:app
You can customize this with the entrypoint input:
entrypoint: gunicorn -b :$PORT main:app --workers 4 --threads 8 --timeout 60Note: App Engine handles Gunicorn configuration professionally. The platform:
- Automatically binds to the correct port via
$PORT - Manages worker processes based on instance class
- Handles graceful shutdowns and restarts
- Provides health checking and monitoring
When using Cloudflare with a custom domain:
- Full Mode (Recommended): Encrypts traffic from visitors to Cloudflare and from Cloudflare to App Engine
- Strict Mode: Requires valid SSL certificate on App Engine (automatically provided by Google)
- Flexible Mode: Only encrypts traffic from visitors to Cloudflare (not recommended)
The action automatically:
- Creates CNAME record pointing to
ghs.googlehosted.com - Enables proxy (orange cloud) for CDN and DDoS protection
- Sets SSL/TLS mode
- Enables "Always Use HTTPS"
- Enables "Automatic HTTPS Rewrites"
App Engine supports running multiple versions simultaneously:
# Deploy new version without sending traffic
promote_version: false
# Gradually shift traffic
traffic_split: v1=0.9,v2=0.1 # 90% to v1, 10% to v2This enables:
- Canary deployments
- A/B testing
- Zero-downtime upgrades
- Easy rollback
Create a migrate.py script for database initialization:
from google.cloud import firestore
def run_migrations():
db = firestore.Client()
# Create collections
items_ref = db.collection('items')
# Add initial data
items_ref.add({
'name': 'Sample Item',
'created_at': firestore.SERVER_TIMESTAMP
})
print("Migrations completed!")
if __name__ == '__main__':
run_migrations()Enable migrations in the action:
run_migrations: true
migration_script: migrate.py# Stream logs
gcloud app logs tail -s SERVICE_NAME
# View recent logs
gcloud app logs read -s SERVICE_NAME --limit=50- App Engine Dashboard: https://console.cloud.google.com/appengine
- Logs Explorer: https://console.cloud.google.com/logs
- Firestore Console: https://console.cloud.google.com/firestore
Implement a /health endpoint in your Flask app:
@app.route('/health')
def health():
return {'status': 'healthy'}- Service Account: Use minimal required permissions
- Secrets: Store sensitive data in Secret Manager, not environment variables
- HTTPS Only: Always use
secure: alwaysin app.yaml handlers - VPC: Use VPC connector for private resource access
- Cloud Armor: Enable for DDoS protection and security rules
- Input Validation: Validate all user inputs
- Dependencies: Keep requirements.txt updated
- IAM: Follow principle of least privilege
Google Secret Manager provides a secure and convenient method for storing API keys, passwords, certificates, and other sensitive data.
# Enable Secret Manager API
gcloud services enable secretmanager.googleapis.com --project=PROJECT_ID
# Create secrets
echo -n "my-database-password" | gcloud secrets create db-password \
--data-file=- \
--replication-policy="automatic" \
--project=PROJECT_ID
echo -n "my-api-key" | gcloud secrets create external-api-key \
--data-file=- \
--replication-policy="automatic" \
--project=PROJECT_ID
# List all secrets
gcloud secrets list --project=PROJECT_ID
# View secret metadata
gcloud secrets describe db-password --project=PROJECT_ID# Grant the App Engine service account access to specific secrets
gcloud secrets add-iam-policy-binding db-password \
--member="serviceAccount:PROJECT_ID@appspot.gserviceaccount.com" \
--role="roles/secretmanager.secretAccessor" \
--project=PROJECT_ID
# Repeat for each secret your app needs to access
gcloud secrets add-iam-policy-binding external-api-key \
--member="serviceAccount:PROJECT_ID@appspot.gserviceaccount.com" \
--role="roles/secretmanager.secretAccessor" \
--project=PROJECT_IDAdd google-cloud-secret-manager to your requirements.txt:
Flask>=3.0.0
gunicorn>=21.2.0
google-cloud-secret-manager>=2.16.0
Then access secrets in your code:
import os
import requests
from google.cloud import secretmanager
def access_secret(secret_id, version_id="latest"):
"""
Access a secret from Google Secret Manager
Args:
secret_id: The ID of the secret to access
version_id: The version (default: "latest")
Returns:
The secret value as a string, or None if error occurs
"""
try:
client = secretmanager.SecretManagerServiceClient()
project_id = os.environ.get('GOOGLE_CLOUD_PROJECT') or os.environ.get('GCP_PROJECT')
# Fallback to metadata server if running in GCP
if not project_id:
try:
metadata_server = "http://metadata.google.internal/computeMetadata/v1/"
response = requests.get(
metadata_server + 'project/project-id',
headers={'Metadata-Flavor': 'Google'},
timeout=5
)
response.raise_for_status()
project_id = response.text
except requests.exceptions.RequestException:
return None
name = f"projects/{project_id}/secrets/{secret_id}/versions/{version_id}"
response = client.access_secret_version(request={"name": name})
return response.payload.data.decode('UTF-8')
except Exception as e:
# Use proper logging in production (app.logger.error)
import logging
logging.error(f"Error accessing secret: {e}")
return None
# Use in your Flask app
from flask import Flask
app = Flask(__name__)
# Lazy-load secrets on demand to avoid startup failures and support rotation
DATABASE_PASSWORD = None
API_KEY = None
def get_database_password():
"""
Lazily retrieve and cache the database password.
"""
global DATABASE_PASSWORD
if DATABASE_PASSWORD is None:
DATABASE_PASSWORD = access_secret('db-password')
return DATABASE_PASSWORD
def get_api_key():
"""
Lazily retrieve and cache the external API key.
"""
global API_KEY
if API_KEY is None:
API_KEY = access_secret('external-api-key')
return API_KEY
@app.route('/api/data')
def get_data():
api_key = get_api_key()
if not api_key:
return {'error': 'API key not configured'}, 500
# Use the API key to make external requests
headers = {'Authorization': f'Bearer {api_key}'}
# ... make your API call
return {'status': 'success'}Note: See the complete implementation for a production-ready example with full error handling.
- Never commit secrets to git - Use Secret Manager instead of environment variables or config files
- Never log secret values - Be careful not to log or return secrets in API responses
- Use specific permissions - Grant access only to the secrets each service needs
- Rotate secrets regularly - Update secret versions periodically
- Use secret versions - Keep old versions for rollback capability
- Audit access - Monitor who accesses secrets using Cloud Logging
# Add a new version to an existing secret
echo -n "new-password-value" | gcloud secrets versions add db-password \
--data-file=- \
--project=PROJECT_ID
# List all versions
gcloud secrets versions list db-password --project=PROJECT_ID
# Access a specific version
gcloud secrets versions access 2 --secret=db-password --project=PROJECT_ID
# Disable a secret version
gcloud secrets versions disable 1 --secret=db-password --project=PROJECT_ID
# Destroy a secret version (irreversible)
gcloud secrets versions destroy 1 --secret=db-password --project=PROJECT_IDSee the example application for a complete working implementation with Secret Manager.
App Engine offers a generous free tier:
- F1/F2 instances: 28 instance hours/day
- 1 GB egress/day
- Shared memcache
- Use F1 instances for development
- Set
min_instances: 0for low-traffic apps - Use Firestore Native mode (better free tier)
- Enable automatic scaling
- Clean up old versions regularly
For a typical small application:
- F1 instance (1 min instance): ~$40/month
- Firestore: Usually free tier
- Bandwidth: Usually free tier
- Total: ~$40-60/month
# Check service account permissions
gcloud projects get-iam-policy PROJECT_ID
# Verify APIs are enabled
gcloud services list --enabled# Check Firestore status
gcloud firestore databases describe --project=PROJECT_ID
# View Firestore indexes
gcloud firestore indexes list- Verify domain mapping:
gcloud app domain-mappings list - Check Cloudflare DNS records in dashboard
- Wait for DNS propagation (up to 48 hours)
- Verify SSL/TLS mode is compatible
# View recent logs
gcloud app logs tail -s SERVICE_NAME
# Check service status
gcloud app services describe SERVICE_NAME
# List versions
gcloud app versions list --service=SERVICE_NAMEError: Permission denied when accessing secrets
# Verify the App Engine service account has access
gcloud secrets get-iam-policy SECRET_NAME --project=PROJECT_ID
# Grant access if missing
gcloud secrets add-iam-policy-binding SECRET_NAME \
--member="serviceAccount:PROJECT_ID@appspot.gserviceaccount.com" \
--role="roles/secretmanager.secretAccessor" \
--project=PROJECT_IDError: Secret not found
# List all secrets to verify it exists
gcloud secrets list --project=PROJECT_ID
# Create the secret if it doesn't exist
echo -n "secret-value" | gcloud secrets create SECRET_NAME \
--data-file=- \
--project=PROJECT_IDError: Unable to access metadata server
- This error occurs when running locally without proper credentials
- Set
GOOGLE_CLOUD_PROJECTorGCP_PROJECTenvironment variable - Ensure
GOOGLE_APPLICATION_CREDENTIALSpoints to your service account key
See the examples directory for complete working examples:
- Basic Flask App - Complete App Engine application with Firestore
- Workflow Examples - Various deployment configurations
Contributions are welcome! Please see CONTRIBUTING.md for details.
This project is licensed under the terms specified in the LICENSE file.