Skip to content

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 modules

Backend 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

  1. Create R2 Bucket:

    • Name: jubiloop-terraform-state
    • Region: Auto (managed by Cloudflare)
  2. Create R2 API Token:

    • Permission: Cloudflare R2:Edit
    • Save Access Key ID and Secret Access Key
  3. 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:Read
    • Zone: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 IP
    • qa-api.jubiloop.ca → Droplet IP
  • Cloudflare Pages projects:
    • dev-app-jubiloop-ca → dev-app.jubiloop.ca
    • qa-app-jubiloop-ca → qa-app.jubiloop.ca
    • dev-jubiloop-ca → dev.jubiloop.ca
    • qa-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_qa

Production 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_prod

Planning Changes

bash
# Review planned changes
terraform plan

# Save plan for approval
terraform plan -out=tfplan

Applying Changes

bash
# Apply infrastructure changes
terraform apply

# Or apply saved plan
terraform apply tfplan

Viewing Outputs

bash
# Show all outputs
terraform output

# Get specific output
terraform output droplet_info
terraform output ssh_connection

Resource 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.web

State 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_name

Troubleshooting

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.web

Best Practices

  1. Always Plan First: Review changes before applying
  2. Use Workspaces: Separate state per environment
  3. Lock State: Prevent concurrent modifications
  4. Version Control: Track infrastructure changes
  5. Document Changes: Update README for significant changes
  6. Test First: Use dev-qa before production
  7. Backup State: Regular state backups to R2

CI/CD Integration

Infrastructure deployment is automated via GitHub Actions:

  1. Terraform Plan: On pull requests
  2. Terraform Apply: On merge to main
  3. State Management: Automatic state locking
  4. Secret Handling: Via GitHub Secrets

Disaster Recovery

Backup Procedures

  1. State Backup: Automated in R2
  2. Configuration Backup: Git repository
  3. Secret Backup: 1Password

Recovery Steps

  1. Restore state from R2 backup
  2. Run terraform plan to verify
  3. Apply to recreate infrastructure
  4. 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

  1. Test upgrades in dev-qa first
  2. Plan during maintenance windows
  3. Have rollback plan ready
  4. 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

  1. User visits protected domain (e.g., dev-app.jubiloop.ca)
  2. Redirected to Cloudflare Access login page
  3. User enters their email address
  4. Receives 6-digit code via email
  5. Enters code and gains access for configured duration
  6. Can use the application normally with existing auth

Managing Access

Adding/Removing Users

  1. Update email lists in terraform.tfvars
  2. Run through GitHub Actions (or manually if emergency):
    bash
    cd infra/deploy/terraform/dev-qa  # or /prod for production
    terraform plan
    terraform apply
  3. Changes take effect immediately

Disabling Access Protection

If you need to disable Access:

  1. Comment out the Access module blocks in main.tf
  2. Run terraform apply
  3. 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

Built with ❤️ by the Jubiloop team