This guide covers every step from provisioning infrastructure from scratch to running day-to-day deployments, database migrations, rollbacks, and environment variable management.
- Prerequisites
- First-time Infrastructure Provisioning
- Environment Variable Management
- Normal Deployment Workflow
- Database Migration Process
- Rollback Procedure
- Validation Checklist
Install and configure the following tools before proceeding.
| Tool | Version | Install |
|---|---|---|
| AWS CLI | ≥ 2.x | brew install awscli / aws.amazon.com/cli |
| Terraform | ≥ 1.5.0 | brew install terraform |
| Docker | ≥ 24 | docs.docker.com |
| Rust / Cargo | stable | curl https://sh.rustup.rs -sSf | sh |
| psql (PostgreSQL client) | ≥ 14 | brew install libpq |
GitHub CLI (gh) |
≥ 2.x | brew install gh |
Configure an AWS profile with permission to assume the deployment IAM roles:
aws configure --profile predictiq
# Enter: Access Key ID, Secret Access Key, us-east-1, jsonThen verify access:
aws sts get-caller-identity --profile predictiqThe deployment workflow reads the following secrets. Set them under Settings → Secrets → Actions in the repository:
| Secret | Description |
|---|---|
AWS_ROLE_DEV |
IAM role ARN for the dev environment |
AWS_ROLE_STAGING |
IAM role ARN for the staging environment |
AWS_ROLE_PROD |
IAM role ARN for the production environment |
Run this section once per environment. Skip it for subsequent deployments.
The bootstrap script creates the S3 bucket and DynamoDB table that Terraform uses for remote state storage and locking.
cd infrastructure/terraform
# Bootstrap for production (repeat with dev/staging as needed)
./bootstrap.sh us-east-1 prodExpected output: bucket name, DynamoDB table name, and confirmation that versioning and encryption are enabled.
cd infrastructure/terraform
terraform init -backend-config=environments/production/backend.hclterraform plan -var-file="environments/production/terraform.tfvars"Review the plan output carefully. Confirm:
- A new VPC with public and private subnets
- An RDS PostgreSQL instance in the private subnet
- An ElastiCache Redis cluster in the private subnet
- An ECS cluster + Fargate service for the API
- An Application Load Balancer in the public subnet
- A CloudWatch monitoring module
terraform apply -var-file="environments/production/terraform.tfvars"Confirm with yes when prompted. The first apply takes approximately 15–25 minutes.
terraform outputNote the following values — you will need them for environment variable configuration:
| Output | Usage |
|---|---|
database_url |
DATABASE_URL for the API service |
redis_url |
REDIS_URL for the API service |
alb_dns_name |
Configure DNS CNAME to this value |
ecs_cluster_name |
Used for ECS deployment commands |
ecs_service_name |
Used for ECS deployment commands |
Connect to the RDS instance via the bastion or AWS RDS Proxy (see Database Migration Process).
DATABASE_URL="postgres://user:password@<rds-endpoint>:5432/predictiq" \
bash services/api/scripts/run_migrations.shStore secrets that the ECS task reads at runtime:
# Create the production secret bundle
aws secretsmanager create-secret \
--name predictiq/prod/api \
--secret-string '{
"DATABASE_URL": "postgres://...",
"REDIS_URL": "rediss://...",
"SENDGRID_API_KEY": "SG.xxx",
"HMAC_KEY": "<32-byte-hex>",
"UNSUBSCRIBE_SIGNING_SECRET": "<secret>",
"PREDICTIQ_CONTRACT_ID": "<stellar-contract-id>",
"API_KEYS": "<key1>,<key2>"
}' \
--profile predictiqThe API service is configured entirely via environment variables. Variables are stored in AWS Secrets Manager and injected into ECS task definitions at deploy time.
| Variable | Description | Example |
|---|---|---|
DATABASE_URL |
PostgreSQL connection string | postgres://user:pass@host:5432/predictiq |
REDIS_URL |
Redis connection string | rediss://host:6379 |
HMAC_KEY |
32-byte hex secret for request signing | deadbeef... |
API_KEYS |
Comma-separated admin API keys | key1,key2 |
SENDGRID_API_KEY |
SendGrid API key | SG.xxx |
FROM_EMAIL |
Sender address for transactional email | noreply@predictiq.com |
BASE_URL |
Public base URL of the API | https://api.predictiq.com |
UNSUBSCRIBE_SIGNING_SECRET |
HMAC secret for unsubscribe tokens | <random-32-chars> |
PREDICTIQ_CONTRACT_ID |
Stellar/Soroban contract address | C... |
BLOCKCHAIN_RPC_URL |
Stellar Horizon / Soroban RPC endpoint | https://soroban-testnet.stellar.org |
STELLAR_NETWORK_PASSPHRASE |
Stellar network passphrase | Test SDF Network ; September 2015 |
| Variable | Default | Description |
|---|---|---|
API_BIND_ADDR |
0.0.0.0:8080 |
Bind address |
CORS_ALLOWED_ORIGINS |
(empty — blocks cross-origin) | Comma-separated origins |
DB_POOL_MIN_CONNECTIONS |
2 |
Minimum DB pool connections |
DB_POOL_MAX_CONNECTIONS |
10 |
Maximum DB pool connections |
FEATURED_LIMIT |
10 |
Max featured markets returned |
NEWSLETTER_TOKEN_TTL_SECS |
86400 |
Confirmation token expiry |
OTLP_ENDPOINT |
(none) | OpenTelemetry collector endpoint |
TRACE_SAMPLE_RATE |
0.1 |
Fraction of requests traced (0–1) |
SENDGRID_WEBHOOK_SECRET |
(none) | SendGrid webhook signature secret |
ADMIN_WHITELIST_IPS |
(none — admin routes unrestricted) | Comma-separated CIDR allowlist |
aws secretsmanager update-secret \
--secret-id predictiq/prod/api \
--secret-string '{"SENDGRID_API_KEY": "SG.new-value", ...}' \
--profile predictiqAfter updating secrets, force a new ECS deployment to pick up the changes (see Step 4 below).
A normal deployment pushes a new container image and triggers a rolling ECS update.
All deployments start with merging a PR to main. GitHub Actions (.github/workflows/docker.yml) automatically builds and pushes a tagged image to GHCR:
ghcr.io/<org>/predictiq:<git-sha>
ghcr.io/<org>/predictiq:main
ghcr.io/<org>/predictiq:latest
Edit infrastructure/terraform/environments/production/terraform.tfvars:
api_image_uri = "ghcr.io/<org>/predictiq:<new-sha>"Commit and push. The Terraform deployment workflow applies automatically on push to main.
# Watch the rolling deployment
aws ecs describe-services \
--cluster predictiq-prod \
--services predictiq-api \
--profile predictiq \
--query 'services[0].deployments'
# Stream ECS task logs
aws logs tail /ecs/predictiq-prod/api --follow --profile predictiqIf you changed secrets but not the image, force a redeployment:
aws ecs update-service \
--cluster predictiq-prod \
--service predictiq-api \
--force-new-deployment \
--profile predictiqcurl -s https://api.predictiq.com/health | jq .Expected response:
{
"status": "ok",
"redis": { "circuit_breaker": "closed" },
"db": { "status": "ok" }
}Database migrations are SQL files in services/api/database/migrations/. Each migration has a corresponding rollback script in services/api/database/migrations/rollbacks/.
Before any deployment that includes database migrations, a snapshot of the RDS instance must be taken. This provides a rollback point if a migration causes data loss or corruption.
aws rds create-db-snapshot \
--db-instance-identifier <your-db-instance-id> \
--db-snapshot-identifier "pre-migration-$(date +%Y%m%d%H%M%S)" \
--region us-east-1Wait for the snapshot to complete before proceeding:
aws rds wait db-snapshot-completed \
--db-snapshot-identifier "pre-migration-<timestamp>" \
--region us-east-1If the wait command exits with a non-zero status, do not proceed with the deployment. Investigate the snapshot failure before continuing.
In the deployment pipeline, add a snapshot step before the migration step and gate migration execution on its success:
- name: Take pre-migration RDS snapshot
run: |
SNAPSHOT_ID="pre-migration-${GITHUB_SHA:0:8}-$(date +%Y%m%d%H%M%S)"
aws rds create-db-snapshot \
--db-instance-identifier "$RDS_INSTANCE_ID" \
--db-snapshot-identifier "$SNAPSHOT_ID" \
--region "$AWS_REGION"
echo "SNAPSHOT_ID=$SNAPSHOT_ID" >> "$GITHUB_ENV"
- name: Wait for snapshot to complete
run: |
aws rds wait db-snapshot-completed \
--db-snapshot-identifier "$SNAPSHOT_ID" \
--region "$AWS_REGION"
- name: Run migrations
run: ./scripts/run-migrations.sh
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}Migrations must be applied before deploying a new API version that requires them.
# Export the production database URL (get from Secrets Manager)
export DATABASE_URL=$(aws secretsmanager get-secret-value \
--secret-id predictiq/prod/api \
--profile predictiq \
--query 'SecretString' \
--output text | python3 -c "import json,sys; print(json.load(sys.stdin)['DATABASE_URL'])")
# Apply all pending migrations
bash services/api/scripts/run_migrations.shTo apply a single migration manually:
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 \
-f services/api/database/migrations/011_create_markets.sqlThe migration runner supports a dry-run mode that prints the SQL for all pending migrations without executing anything. Use this to audit pending changes before a production deployment.
MIGRATE_DRY_RUN=true ./your-api-binaryOutput example:
-- [DRY RUN] migration 008 (008_create_email_tracking)
CREATE TABLE IF NOT EXISTS email_tracking ( ... );
[DRY RUN] migration run complete — no changes applied (pending=1)
Dry-run mode is safe to run against production databases — it only reads the schema_migrations table to determine which migrations are pending.
psql "$DATABASE_URL" -c "SELECT version, applied_at FROM schema_migrations ORDER BY version;"- Create
services/api/database/migrations/NNN_description.sql - Create
services/api/database/migrations/rollbacks/NNN_description_down.sql(required — CI validates this) - Test the migration and rollback locally against a dev database
- Include both files in the PR
Pre-migration snapshots are kept for 30 days. Clean up old snapshots periodically:
aws rds describe-db-snapshots \
--snapshot-type manual \
--query "DBSnapshots[?SnapshotCreateTime<='$(date -d '30 days ago' --utc +%Y-%m-%dT%H:%M:%SZ)'].DBSnapshotIdentifier" \
--output text | xargs -I {} aws rds delete-db-snapshot --db-snapshot-identifier {}# Option A — revert the merge commit and push; CI redeploys the previous image
git revert <merge-commit-sha>
git push origin main
# Option B — force-redeploy a previous image without a code change
aws ecs update-service \
--cluster predictiq-prod \
--service predictiq-api \
--task-definition predictiq-api:<previous-revision> \
--force-new-deployment \
--profile predictiqEach migration has a paired rollback script. Apply in reverse order (highest version first):
# Roll back migration 012
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 \
-f services/api/database/migrations/rollbacks/012_add_performance_indexes_down.sql
# Remove the version record so the migration is treated as pending again
psql "$DATABASE_URL" -c "DELETE FROM schema_migrations WHERE version = '012';"If a migration caused data corruption, you can restore from the pre-migration snapshot:
-
Identify the snapshot taken before the failed deployment:
aws rds describe-db-snapshots \ --query "DBSnapshots[?starts_with(DBSnapshotIdentifier,'pre-migration')]|sort_by(@,&SnapshotCreateTime)[-5:]" \ --region us-east-1 -
Restore the snapshot to a new instance for validation:
aws rds restore-db-instance-from-db-snapshot \ --db-instance-identifier <restore-instance-id> \ --db-snapshot-identifier <snapshot-id> \ --region us-east-1
-
After validating the restore, update DNS/connection strings to point to the restored instance, or swap the identifier if using RDS renaming.
-
Delete the failed instance once traffic has been moved.
For more detail, see services/api/database/migrations/ROLLBACK.md.
See infrastructure/ROLLBACK.md for full procedures including Terraform state restoration.
Quick path — revert via git:
git revert <terraform-commit-sha>
git push origin main
# GitHub Actions applies the reverted Terraform plan automatically| Scope | Target time |
|---|---|
| Application (ECS rolling update) | < 5 minutes |
| Config-only (Secrets Manager + force redeploy) | < 3 minutes |
| Database migration rollback | 5–15 minutes (depends on data volume) |
| Full infrastructure rollback (Terraform) | 15–30 minutes |
Run these checks after every production deployment:
# 1. Health endpoint
curl -sf https://api.predictiq.com/health | jq .
# 2. Statistics (exercises DB + cache)
curl -sf https://api.predictiq.com/api/v1/statistics | jq .
# 3. Featured markets (exercises blockchain + cache)
curl -sf https://api.predictiq.com/api/v1/markets/featured | jq .
# 4. Blockchain health
curl -sf https://api.predictiq.com/api/v1/blockchain/health | jq .
# 5. CloudWatch alarms — confirm no alarms are in ALARM state
aws cloudwatch describe-alarms \
--state-value ALARM \
--profile predictiq \
--query 'MetricAlarms[].AlarmName'
# 6. ECS service stabilized
aws ecs describe-services \
--cluster predictiq-prod \
--services predictiq-api \
--profile predictiq \
--query 'services[0].{desired:desiredCount,running:runningCount,pending:pendingCount}'All six checks should pass within 5 minutes of the deployment completing.
services/api/src/migrations.rs— migration runner implementationservices/api/database/migrations/— SQL migration files- AWS docs: Creating a DB snapshot
- AWS docs: Restoring from a DB snapshot
Validate this guide: After any significant change to the deployment process, have a team member who was not involved in the change follow it end-to-end in the staging environment and record the outcome here.
Date Follower Environment Issues found PR (not yet validated)