Appearance
Terraform Setup
Overview
Jubiloop uses Terraform to manage infrastructure as code across all environments. This guide is for emergency/testing purposes only - all production deployments are automated through GitHub Actions.
Important: Manual Terraform runs should only be used for:
- Testing infrastructure changes on isolated environments
- Debugging deployment issues
- Emergency interventions (requires approval and must be followed by GitHub Actions deployment)
All normal deployments MUST go through GitHub Actions for proper audit trails and consistency.
Directory Structure
infra/deploy/terraform/
├── dev-qa/ # Development and QA shared infrastructure
├── prod/ # Production infrastructure
└── modules/ # Reusable Terraform modulesBackend Configuration
Terraform state is stored remotely in Cloudflare R2 buckets for:
- State persistence across team members
- State locking to prevent concurrent modifications
- Encrypted storage at rest
R2 Setup
Create R2 Bucket:
- Name:
jubiloop-terraform-state - Region: Auto (managed by Cloudflare)
- Name:
Create R2 API Token:
- Permission:
Cloudflare R2:Edit - Save Access Key ID and Secret Access Key
- Permission:
Configure Backend:
hcl# backend.hcl bucket = "jubiloop-terraform-state" endpoints = { s3 = "https://ACCOUNT-ID.r2.cloudflarestorage.com" } access_key = "your-r2-access-key-id" secret_key = "your-r2-secret-access-key"
Required Credentials
All credentials are stored in 1Password (Jubiloop group) and configured in GitHub Secrets:
DigitalOcean
- API Token: Full access for resource management
- Project ID: Optional for resource organization
Cloudflare
- API Token: Custom token with permissions:
Zone:Zone:ReadZone:DNS:Edit
- Zone IDs: For
jubiloop.ca - R2 Credentials: For state backend
SSH Access
- Deploy Key: Ed25519 key for server access
- Public Key: Added to droplets for deployment
Environment Configuration
Development & QA
Shared infrastructure configuration:
hcl
# terraform.tfvars
digital_ocean_region = "tor1"
droplet_size = "s-1vcpu-1gb"
deploy_user = "deploy"Resources created:
- Single droplet hosting both environments ($6/month, s-1vcpu-1gb)
- Reserved IP for static addressing
- Cloudflare DNS records:
dev-api.jubiloop.ca→ Droplet IPqa-api.jubiloop.ca→ Droplet IP
- Cloudflare Pages projects:
dev-app-jubiloop-ca→ dev-app.jubiloop.caqa-app-jubiloop-ca→ qa-app.jubiloop.cadev-jubiloop-ca→ dev.jubiloop.caqa-jubiloop-ca→ qa.jubiloop.ca
- Cloudflare Zero Trust Access protection for all frontend apps
- DigitalOcean firewall rules:
- SSH: Open to all (configurable)
- HTTP/HTTPS: Cloudflare IPs only
Production
Dedicated infrastructure configuration:
hcl
# terraform.tfvars
digital_ocean_region = "tor1"
droplet_size = "s-1vcpu-1gb" # Same size as dev-qa but dedicated
deploy_user = "deploy"Resources created:
- Dedicated droplet for production only ($6/month, s-1vcpu-1gb)
- Reserved IP for static addressing
- Cloudflare DNS records:
api.jubiloop.ca→ Droplet IP
- Cloudflare Pages projects:
app-jubiloop-ca→ app.jubiloop.ca (Protected with Zero Trust temporarily)jubiloop-ca→ jubiloop.ca and www.jubiloop.ca (Public)
- Neon managed PostgreSQL (external, free tier)
- Enhanced backup configuration enabled
- Cloudflare Zero Trust Access for app.jubiloop.ca only (temporary)
- DigitalOcean firewall rules:
- SSH: Open to all (configurable)
- HTTP/HTTPS
Deployment Process
Initial Setup
bash
# Navigate to environment directory
cd infra/deploy/terraform/dev-qa # or /prod for production
# Copy configuration files
cp terraform.tfvars.example terraform.tfvars
cp backend.hcl.example backend.hcl
# Fill in values from 1Password
# Edit terraform.tfvars and backend.hcl
# Initialize Terraform
terraform init -backend-config="backend.hcl"SSH Key Configuration
The infrastructure uses separate SSH keys for security isolation:
Dev/QA Environments (shared key):
bash
# Generate key for dev/qa
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_jubiloop_dev_qa -C "devops@jubiloop.ca" -N ""
# Add to GitHub Secrets:
# - DEV_QA_SSH_PUBLIC_KEY: contents of ~/.ssh/id_ed25519_jubiloop_dev_qa.pub
# - DEV_QA_SSH_PRIVATE_KEY: contents of ~/.ssh/id_ed25519_jubiloop_dev_qaProduction Environment (separate key):
bash
# Generate key for production
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_jubiloop_prod -C "devops@jubiloop.ca" -N ""
# Add to GitHub Secrets:
# - PROD_SSH_PUBLIC_KEY: contents of ~/.ssh/id_ed25519_jubiloop_prod.pub
# - PROD_SSH_PRIVATE_KEY: contents of ~/.ssh/id_ed25519_jubiloop_prodPlanning Changes
bash
# Review planned changes
terraform plan
# Save plan for approval
terraform plan -out=tfplanApplying Changes
bash
# Apply infrastructure changes
terraform apply
# Or apply saved plan
terraform apply tfplanViewing Outputs
bash
# Show all outputs
terraform output
# Get specific output
terraform output droplet_info
terraform output ssh_connectionResource Management
Terraform Modules
The infrastructure is organized into reusable modules:
Droplet Module (modules/droplet/)
Creates standardized DigitalOcean droplets:
hcl
module "dev_qa_droplet" {
source = "../modules/droplet"
droplet_name = "dev-qa-server"
environment = "dev-qa"
digital_ocean_region = var.digital_ocean_region
size = var.droplet_size
ssh_key_ids = [digitalocean_ssh_key.deploy_key.id]
deploy_user = var.deploy_user
deploy_ssh_public_key = var.ssh_public_key
use_reserved_ip = true
enable_backups = false # Cost optimization
tags = ["dev-qa", "jubiloop", "terraform-managed"]
}Features:
- Cloud-init configuration for initial setup
- Firewall with Cloudflare IP whitelist
- Optional reserved IP and volumes
- Monitoring enabled by default
- IPv6 support
DNS Module (modules/dns/)
Manages Cloudflare DNS records with support for apex domains:
hcl
# Regular subdomain
module "api_dns" {
source = "../modules/dns"
zone_id = var.cloudflare_zone_id_jubiloop_ca
dns_content = module.prod_droplet.public_ip
ipv6_dns_content = module.prod_droplet.ipv6_address
domain = "jubiloop.ca"
subdomain = "api" # Regular subdomain
with_www = false # No www.api.jubiloop.ca
}
# Apex domain with www
module "marketing_dns" {
source = "../modules/dns"
zone_id = var.cloudflare_zone_id_jubiloop_ca
dns_content = cloudflare_pages_project.marketing.subdomain
domain = "jubiloop.ca"
subdomain = "" # Empty for apex domain
with_www = true # Also creates www.jubiloop.ca
}Creates A and AAAA records with proxy enabled. Handles apex domains when subdomain is empty or "@".
Cloudflare Pages Module (modules/cloudflare_pages/)
Configures Pages projects with support for apex and www domains:
hcl
# Regular subdomain
module "app_pages" {
source = "../modules/cloudflare_pages"
project_name = "app-jubiloop-ca"
production_branch = "main"
zone_id = var.cloudflare_zone_id_jubiloop_ca
account_id = var.cloudflare_account_id
domain = "jubiloop.ca"
subdomain = "app"
with_www = false # No www.app.jubiloop.ca (free tier limit)
}
# Apex domain with www
module "marketing_pages" {
source = "../modules/cloudflare_pages"
project_name = "jubiloop-ca"
production_branch = "main"
zone_id = var.cloudflare_zone_id_jubiloop_ca
account_id = var.cloudflare_account_id
domain = "jubiloop.ca"
subdomain = "" # Empty for apex domain
with_www = true # Creates both jubiloop.ca and www.jubiloop.ca
}Droplets
Configuration includes:
- Ubuntu 22.04 base image
- Deploy user with SSH key
- Docker-ready setup via cloud-init
- Security hardening
- Swap space configuration
Networking
- Reserved IPs: Static addresses for DNS
- Firewall Rules:
- SSH (22): Configurable IP whitelist (default: open)
- HTTP (80): Cloudflare IPs only
- HTTPS (443): Cloudflare IPs only
- Private Networking: Internal service communication
DNS Management
Automated DNS record creation:
- API endpoints (proxied through Cloudflare)
- Direct droplet access (when needed)
- Automatic SSL/TLS via Cloudflare
- IPv6 support
State Management
Viewing State
bash
# Show current state
terraform show
# List resources
terraform state list
# Show specific resource
terraform state show digitalocean_droplet.webState Operations
bash
# Pull latest state
terraform state pull
# Move resources
terraform state mv old_name new_name
# Remove from state (careful!)
terraform state rm resource_nameTroubleshooting
Common Issues
Backend Initialization Failures:
- Verify R2 credentials in
backend.hcl - Check bucket exists and is accessible
- Ensure account ID is correct
Resource Creation Failures:
- Check API token permissions
- Verify quota limits
- Review error messages for specifics
DNS Issues:
- Confirm Cloudflare API token permissions
- Verify zone IDs are correct
- Check domain ownership
Debug Mode
bash
# Enable debug logging
export TF_LOG=DEBUG
terraform apply
# Target specific resources
terraform apply -target=digitalocean_droplet.webBest Practices
- Always Plan First: Review changes before applying
- Use Workspaces: Separate state per environment
- Lock State: Prevent concurrent modifications
- Version Control: Track infrastructure changes
- Document Changes: Update README for significant changes
- Test First: Use dev-qa before production
- Backup State: Regular state backups to R2
CI/CD Integration
Infrastructure deployment is automated via GitHub Actions:
- Terraform Plan: On pull requests
- Terraform Apply: On merge to main
- State Management: Automatic state locking
- Secret Handling: Via GitHub Secrets
Disaster Recovery
Backup Procedures
- State Backup: Automated in R2
- Configuration Backup: Git repository
- Secret Backup: 1Password
Recovery Steps
- Restore state from R2 backup
- Run
terraform planto verify - Apply to recreate infrastructure
- Redeploy applications via CI/CD
Security Considerations
- State Encryption: Encrypted at rest in R2
- Access Control: Limited to authorized team members
- Audit Trail: All changes tracked in Git
- Secret Management: Never commit secrets
- Network Security: Firewall rules enforced
Maintenance
Regular Tasks
- Review and update provider versions
- Audit resource usage and costs
- Update security group rules
- Clean up unused resources
Upgrade Procedures
- Test upgrades in dev-qa first
- Plan during maintenance windows
- Have rollback plan ready
- Document any breaking changes
Cloudflare Zero Trust Access
Overview
Cloudflare Zero Trust Access provides email-based authentication (OTP) to protect dev and QA frontend applications while keeping APIs accessible for existing session-based auth.
Protected Domains
Dev Environment
dev-app.jubiloop.ca- Web Application (Protected)dev.jubiloop.ca- Marketing Site (Protected)dev-api.jubiloop.ca- API Server (Not Protected)
QA Environment
qa-app.jubiloop.ca- Web Application (Protected)qa.jubiloop.ca- Marketing Site (Protected)qa-api.jubiloop.ca- API Server (Not Protected)
Production Environment
app.jubiloop.ca- Web Application (Protected - temporary until public launch)jubiloop.ca- Marketing Site (Public)www.jubiloop.ca- Marketing Site (Public)api.jubiloop.ca- API Server (Not Protected)
Configuration
Email Allowlists
Configure allowed emails in your terraform.tfvars:
hcl
# Dev environment access
dev_allowed_emails = [
"accounts@jubiloop.ca",
"developer@example.com"
]
# QA environment access (can include clients)
qa_allowed_emails = [
"team@jubiloop.ca",
"client@example.com",
"qa-tester@example.com",
"accounts@jubiloop.ca"
]
# Production app access (temporary until public launch)
prod_allowed_emails = [
"admin@jubiloop.ca",
"team@jubiloop.ca"
]
# Session duration (default: 24h)
access_session_duration = "24h"Environment Variables
You can also pass email lists via environment variables:
bash
# As comma-separated string
export TF_VAR_dev_allowed_emails="user1@example.com,user2@example.com"
export TF_VAR_qa_allowed_emails="user1@example.com,user2@example.com"
# Or as JSON array
export TF_VAR_dev_allowed_emails='["user1@example.com","user2@example.com"]'Required Permissions
Your Cloudflare API token needs:
Account:Cloudflare Access: Edit- All existing permissions remain unchanged
User Experience
- User visits protected domain (e.g.,
dev-app.jubiloop.ca) - Redirected to Cloudflare Access login page
- User enters their email address
- Receives 6-digit code via email
- Enters code and gains access for configured duration
- Can use the application normally with existing auth
Managing Access
Adding/Removing Users
- Update email lists in
terraform.tfvars - Run through GitHub Actions (or manually if emergency):bash
cd infra/deploy/terraform/dev-qa # or /prod for production terraform plan terraform apply - Changes take effect immediately
Disabling Access Protection
If you need to disable Access:
- Comment out the Access module blocks in
main.tf - Run
terraform apply - Sites become publicly accessible immediately
API Access Strategy
APIs remain unprotected by Cloudflare Access to maintain compatibility with:
- Existing session-based authentication
- CORS policies configured in the application
- Service-to-service communication
The frontend protection ensures only authorized users can access the web applications that communicate with the APIs.
Troubleshooting
Can't receive email codes
- Check spam folder
- Ensure email is in the allowlist
- Try a different email provider
Session expires too quickly
- Increase
access_session_duration(max: 30d) - Consider using "remember me" in app auth