Infrastructure as Code
This guide covers the Terraform configuration used to deploy AppArt Agent on Google Cloud Platform.
Overview
flowchart TB
subgraph Terraform["Terraform Configuration"]
TF["main.tf<br/>~1700 lines"]
Vars["terraform.tfvars"]
end
subgraph Resources["GCP Resources Created"]
subgraph Compute["Compute"]
CR_FE["Cloud Run: Frontend"]
CR_BE["Cloud Run: Backend"]
CR_JOB["Cloud Run Job: Migrations"]
CR_DVF["Cloud Run Job: DVF Import"]
end
subgraph Network["Networking"]
VPC["VPC Network"]
Subnet["Subnet"]
Connector["VPC Connector"]
PSA["Private Service Access"]
end
subgraph Data["Data Services"]
SQL["Cloud SQL PostgreSQL"]
Redis["Memorystore Redis"]
GCS_D["GCS: Documents"]
GCS_P["GCS: Photos"]
end
subgraph Security["Security"]
SA_BE["SA: Backend"]
SA_FE["SA: Frontend"]
SA_DEP["SA: Deployer"]
Secrets["Secret Manager"]
end
subgraph Optional["Optional (with domain)"]
DNS["Cloud DNS"]
LB["Load Balancer"]
Certs["Certificate Manager"]
end
end
TF --> Resources
Vars --> TF
File Structure
infra/
└── terraform/
├── main.tf # All Terraform resources (~1500 lines)
└── terraform.tfvars.example # Example configuration file
Quick Start
cd infra/terraform
# Copy and configure variables
cp terraform.tfvars.example terraform.tfvars
# Edit terraform.tfvars with your project_id and settings
# Initialize Terraform
terraform init
# Preview changes
terraform plan
# Apply infrastructure
terraform apply
# View outputs
terraform output
Variables Reference
Required Variables
| Variable |
Type |
Description |
project_id |
string |
Your GCP project ID |
Deployment Configuration
| Variable |
Type |
Default |
Description |
region |
string |
europe-west1 |
GCP region for all resources |
environment |
string |
production |
staging or production |
Cloud Run Configuration
| Variable |
Type |
Default |
Description |
min_instances |
number |
0 |
Minimum instances (0=scale-to-zero, 1=always-on) |
backend_max_concurrency |
number |
20 |
Max concurrent requests per backend instance (Cloud Run default is 80; lower value triggers autoscaling sooner for DB-heavy endpoints) |
flowchart LR
subgraph ScaleZero["min_instances = 0"]
Z1["No traffic"] --> Z2["0 instances<br/>$0/month"]
Z3["Request arrives"] --> Z4["Cold start ~2-5s"]
Z4 --> Z5["1+ instances"]
end
subgraph AlwaysOn["min_instances = 1"]
A1["Always running"] --> A2["1+ instances<br/>~$50-100/month"]
A3["Request arrives"] --> A4["Instant response"]
end
Database Configuration
| Variable |
Type |
Default |
Description |
db_tier |
string |
db-g1-small |
Cloud SQL instance tier |
Available Tiers:
| Tier |
vCPU |
RAM |
Cost/month |
Best For |
db-f1-micro |
Shared |
614 MB |
~$10 |
Development |
db-g1-small |
Shared |
1.7 GB |
~$25 |
Staging |
db-custom-1-3840 |
1 |
3.75 GB |
~$40 |
Small production |
db-custom-2-4096 |
2 |
4 GB |
~$70 |
Production |
db-custom-4-8192 |
4 |
8 GB |
~$140 |
High traffic |
Redis Configuration
| Variable |
Type |
Default |
Description |
redis_tier |
string |
BASIC |
BASIC or STANDARD_HA |
redis_memory_size_gb |
number |
1 |
Memory size (1-300 GB) |
| Tier |
Availability |
Cost (1GB) |
Best For |
BASIC |
Single zone |
~$35/month |
Development, staging |
STANDARD_HA |
Multi-zone HA |
~$70/month |
Production |
Custom Domain Configuration
| Variable |
Type |
Default |
Description |
domain |
string |
"" |
Custom domain (e.g., appartagent.com) |
use_load_balancer |
bool |
true |
Use Cloud Load Balancer vs domain mappings |
create_dns_zone |
bool |
true |
Create Cloud DNS managed zone |
api_subdomain |
string |
api |
Subdomain for backend API |
DVF Dataset Configuration
| Variable |
Type |
Default |
Description |
dvf_source_url |
string |
"" |
DVF dataset URL or gs:// URI (optional, defaults to latest full dataset) |
flowchart TD
Domain{"domain set?"}
Domain -->|"No"| CloudRunURLs["Use Cloud Run URLs<br/>*.run.app"]
Domain -->|"Yes"| LBChoice{"use_load_balancer?"}
LBChoice -->|"true"| LBSetup["Load Balancer Setup"]
LBChoice -->|"false"| DomainMappings["Domain Mappings"]
subgraph LBSetup["Load Balancer (Recommended)"]
LB_IP["Static IP"]
LB_Cert["Certificate Manager"]
LB_NEG["Serverless NEGs"]
LB_URL["URL Map routing"]
end
subgraph DomainMappings["Domain Mappings (Simpler)"]
DM_FE["Frontend mapping"]
DM_BE["Backend mapping"]
DM_SSL["Auto SSL"]
end
Observability
| Variable |
Type |
Default |
Description |
logfire_token |
string |
"" |
Logfire write token (optional) |
Resources Created
Networking
flowchart TB
subgraph VPC["VPC: appart-agent-vpc"]
Subnet["Subnet: 10.0.0.0/24"]
Connector["VPC Connector: 10.8.0.0/28"]
end
subgraph Private["Private Service Access"]
PSA["IP Range: 10.x.x.x/16"]
end
subgraph Services["Private Services"]
SQL["Cloud SQL"]
Redis["Memorystore"]
end
Connector --> Subnet
Subnet --> PSA
PSA --> SQL
PSA --> Redis
| Resource |
Name |
Purpose |
| VPC Network |
appart-agent-vpc |
Private networking |
| Subnet |
appart-agent-subnet |
10.0.0.0/24 |
| VPC Connector |
appt-agent-connector |
Cloud Run → VPC access |
| Private IP Range |
Dynamic |
Cloud SQL & Redis private access |
Service Accounts & IAM
flowchart LR
subgraph Backend["appart-backend"]
BE_Roles["cloudsql.client<br/>secretmanager.secretAccessor<br/>storage.objectAdmin<br/>aiplatform.user<br/>redis.editor<br/>logging.logWriter"]
end
subgraph Frontend["appart-frontend"]
FE_Roles["secretmanager.secretAccessor<br/>logging.logWriter"]
end
subgraph Deployer["appart-deployer"]
DEP_Roles["run.admin<br/>iam.serviceAccountUser<br/>artifactregistry.writer<br/>secretmanager.secretAccessor<br/>storage.admin"]
end
subgraph CloudBuild["appart-cloudbuild"]
CB_Roles["run.admin<br/>iam.serviceAccountUser<br/>artifactregistry.writer<br/>secretmanager.secretAccessor"]
end
| Service Account |
Purpose |
Key Permissions |
appart-backend |
Backend Cloud Run service |
SQL, Storage, AI, Redis |
appart-frontend |
Frontend Cloud Run service |
Secrets, Logging |
appart-deployer |
GitHub Actions CI/CD |
Run admin, AR writer |
appart-cloudbuild |
Cloud Build (if used) |
Run admin, AR writer |
Secret Manager
| Secret |
Content |
Set By |
database-url |
PostgreSQL connection string |
Terraform (auto) |
db-password |
Database password |
Terraform (auto) |
jwt-secret |
Application secret key |
Terraform (auto) |
better-auth-secret |
Better Auth session signing key |
Manual or Terraform |
google-cloud-api-key |
Gemini API key |
Manual (optional) |
logfire-token |
Logfire write token |
Terraform or Manual |
Cloud Run Services
flowchart TB
subgraph Frontend["appart-frontend"]
FE_Image["Image: frontend:latest"]
FE_Port["Port: 3000"]
FE_Resources["CPU: 1, Memory: 512Mi"]
end
subgraph Backend["appart-backend"]
BE_Image["Image: backend:latest"]
BE_Port["Port: 8000"]
BE_Resources["CPU: 2, Memory: 2Gi"]
BE_VPC["VPC Connector"]
BE_SQL["Cloud SQL Socket"]
end
subgraph MigrationJob["db-migrate (Job)"]
MIG_CMD["alembic upgrade head"]
MIG_VPC["VPC Connector"]
end
subgraph DVFJob["dvf-import (Job)"]
DVF_CMD["download-dvf & import-dvf"]
DVF_VPC["VPC Connector"]
DVF_RES["CPU: 4, Memory: 16Gi"]
end
| Service |
CPU |
Memory |
Min Instances |
Max Instances |
Timeout |
appart-frontend |
1 |
512Mi |
var.min_instances |
10 |
- |
appart-backend |
2 |
2Gi |
var.min_instances |
10 |
- |
db-migrate (job) |
1 |
1Gi |
N/A |
1 |
10m |
dvf-import (job) |
8 |
32Gi |
N/A |
1 |
60m |
Storage Buckets
| Bucket |
Name Pattern |
Purpose |
CORS |
| Documents |
{project_id}-documents |
PDF uploads, document storage |
Enabled |
| Photos |
{project_id}-photos |
Photo uploads, redesigns |
Enabled |
Database
| Resource |
Configuration |
| Instance |
appart-agent-db |
| Version |
PostgreSQL 15 |
| Database |
appart_agent |
| User |
appart |
| Network |
Private IP only (no public access) |
| Backups |
Daily at 03:00, PITR in production |
Load Balancer (Optional)
When use_load_balancer = true and domain is set:
flowchart TB
subgraph External["External"]
Users["Users"]
end
subgraph LB["Global Load Balancer"]
IP["Static IP<br/>appart-agent-lb-ip"]
HTTPS["HTTPS Proxy"]
HTTP["HTTP Proxy<br/>(301 → HTTPS)"]
URLMap["URL Map"]
end
subgraph Certs["Certificate Manager"]
Cert["Managed Certificate"]
DNSAuth["DNS Authorizations"]
end
subgraph NEGs["Serverless NEGs"]
FE_NEG["Frontend NEG"]
BE_NEG["Backend NEG"]
end
subgraph CloudRun["Cloud Run"]
FE["Frontend"]
BE["Backend"]
end
Users --> IP
IP --> HTTPS
IP --> HTTP
HTTPS --> URLMap
URLMap -->|"domain, www"| FE_NEG
URLMap -->|"api.domain"| BE_NEG
FE_NEG --> FE
BE_NEG --> BE
Cert --> HTTPS
DNSAuth --> Cert
Outputs
After terraform apply, these outputs are available:
| Output |
Description |
frontend_url |
Cloud Run frontend URL |
backend_url |
Cloud Run backend URL |
frontend_custom_url |
Custom domain frontend URL (if configured) |
backend_custom_url |
Custom domain API URL (if configured) |
database_instance |
Cloud SQL instance name |
redis_host |
Redis IP address |
documents_bucket |
Documents GCS bucket name |
photos_bucket |
Photos GCS bucket name |
artifact_registry |
Docker registry URL |
backend_service_account |
Backend SA email |
deployer_service_account |
Deployer SA email |
dns_nameservers |
Cloud DNS nameservers (if using Cloud DNS) |
lb_ip |
Load balancer IP (if using LB) |
dvf_import_job |
DVF import job name |
State Management
Local State (Default)
State is stored locally in terraform.tfstate.
# State file location
infra/terraform/terraform.tfstate
Remote State (Recommended for Teams)
For team environments, configure GCS backend:
# Uncomment in main.tf
terraform {
backend "gcs" {
bucket = "your-project-tfstate"
prefix = "terraform/state"
}
}
Setup:
# Create state bucket
gsutil mb -l europe-west1 gs://your-project-tfstate
# Enable versioning
gsutil versioning set on gs://your-project-tfstate
# Initialize with remote backend
terraform init -migrate-state
Common Operations
Update Infrastructure
# Pull latest code
git pull
# Review changes
terraform plan
# Apply changes
terraform apply
Scale Resources
# Edit terraform.tfvars
min_instances = 1 # Enable always-on
db_tier = "db-custom-2-4096" # Upgrade database
# Apply
terraform apply
Add Custom Domain
# Edit terraform.tfvars
domain = "yourdomain.com"
use_load_balancer = true
create_dns_zone = true
# Apply (this creates many resources)
terraform apply
# Get nameservers
terraform output dns_nameservers
# Update at your domain registrar
Import Existing Resources
If resources were created manually:
# Import existing DNS zone
terraform import 'google_dns_managed_zone.main[0]' \
projects/PROJECT_ID/managedZones/ZONE_NAME
# Import existing Artifact Registry
terraform import google_artifact_registry_repository.docker \
projects/PROJECT_ID/locations/REGION/repositories/appart-agent
# Import existing secrets
terraform import 'google_secret_manager_secret.logfire_token' \
projects/PROJECT_ID/secrets/logfire-token
View Resource Details
# List all resources
terraform state list
# Show specific resource
terraform state show google_cloud_run_v2_service.backend
# Show all outputs
terraform output -json
Destroy Infrastructure
# Preview destruction
terraform plan -destroy
# Destroy all resources
terraform destroy
Warning
This permanently deletes:
- Cloud SQL database and all data
- Redis cache
- Storage buckets and all files
- All Cloud Run services
- Secrets and service accounts
Partial Destruction
# Destroy only Cloud Run services
terraform destroy -target=google_cloud_run_v2_service.backend
terraform destroy -target=google_cloud_run_v2_service.frontend
# Destroy only load balancer
terraform destroy -target=google_compute_global_forwarding_rule.https
Cost Estimation
Development Setup
| Resource |
Configuration |
Cost/month |
| Cloud SQL |
db-g1-small |
~$25 |
| Redis |
BASIC 1GB |
~$35 |
| Cloud Run |
min_instances=0 |
~$0-10 |
| Storage |
~10GB |
~$1 |
| Total |
|
~$65/month |
Production Setup
| Resource |
Configuration |
Cost/month |
| Cloud SQL |
db-custom-2-4096 |
~$70 |
| Redis |
STANDARD_HA 1GB |
~$70 |
| Cloud Run |
min_instances=1 (default) |
~$100-150 |
| Load Balancer |
Global LB |
~$20 |
| Storage |
~50GB |
~$5 |
| Total |
|
~$265-315/month |
Troubleshooting
API not enabled:
# Enable required API
gcloud services enable SERVICE_NAME.googleapis.com
Permission denied:
# Check current user
gcloud auth list
# Ensure correct project
gcloud config set project YOUR_PROJECT_ID
Resource already exists:
# Import the resource
terraform import RESOURCE_ADDRESS RESOURCE_ID
State Issues
State lock:
# Force unlock (use carefully)
terraform force-unlock LOCK_ID
State out of sync:
# Refresh state from GCP
terraform refresh
Security Best Practices
- State file security - Use remote backend with encryption
- Sensitive variables - Use
sensitive = true for secrets
- Least privilege - Service accounts have minimal permissions
- Private networking - Database and Redis use private IPs only
- Secrets in Secret Manager - No secrets in Terraform variables
Next Steps