diff --git a/.github/workflows/deploy-aws.yml b/.github/workflows/deploy-aws.yml new file mode 100644 index 0000000..44f6ee9 --- /dev/null +++ b/.github/workflows/deploy-aws.yml @@ -0,0 +1,137 @@ +name: Build & Deploy to AWS + +on: + push: + branches: + - main + workflow_dispatch: # Allow manual trigger + +env: + AWS_REGION: us-east-1 + TERRAGRUNT_VERSION: v0.55.1 + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Run type check + run: npm run type-check + + - name: Build application + run: npm run build + env: + VERSION: ${{ github.sha }} + BUILD_DATE: ${{ github.event.head_commit.timestamp }} + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/gluko-github-actions + aws-region: ${{ env.AWS_REGION }} + + - name: Get S3 bucket name + id: s3-bucket + run: | + BUCKET=$(aws s3 ls | grep gluko-pwa | awk '{print $3}') + echo "bucket=$BUCKET" >> $GITHUB_OUTPUT + echo "Using S3 bucket: $BUCKET" + + - name: Upload to S3 + run: | + # Sync all files with default cache (1 year for hashed files) + aws s3 sync dist/ s3://${{ steps.s3-bucket.outputs.bucket }}/ \ + --delete \ + --cache-control "public, max-age=31536000" + if: steps.s3-bucket.outputs.bucket != '' + + - name: Update index.html cache (1 hour) + run: | + if [ -f dist/index.html ]; then + aws s3 cp dist/index.html s3://${{ steps.s3-bucket.outputs.bucket }}/index.html \ + --content-type "text/html; charset=utf-8" \ + --cache-control "public, max-age=3600" + fi + if: steps.s3-bucket.outputs.bucket != '' + + - name: Update manifest.json cache (no cache) + run: | + if [ -f dist/manifest.json ]; then + aws s3 cp dist/manifest.json s3://${{ steps.s3-bucket.outputs.bucket }}/manifest.json \ + --content-type "application/json" \ + --cache-control "public, max-age=0, must-revalidate" + fi + if: steps.s3-bucket.outputs.bucket != '' + + - name: Get CloudFront distribution ID + id: cloudfront-id + run: | + DIST_ID=$(aws cloudfront list-distributions \ + --query "Distributions[?Origins[0].DomainName=='${{ steps.s3-bucket.outputs.bucket }}.s3.${{ env.AWS_REGION }}.amazonaws.com'].Id" \ + --output text) + echo "distribution_id=$DIST_ID" >> $GITHUB_OUTPUT + echo "CloudFront Distribution ID: $DIST_ID" + if: steps.s3-bucket.outputs.bucket != '' + + - name: Invalidate CloudFront cache + run: | + aws cloudfront create-invalidation \ + --distribution-id ${{ steps.cloudfront-id.outputs.distribution_id }} \ + --paths "/index.html" "/manifest.json" + echo "⏳ CloudFront invalidation initiated..." + if: steps.cloudfront-id.outputs.distribution_id != '' + + - name: Wait for CloudFront invalidation + run: | + DIST_ID=${{ steps.cloudfront-id.outputs.distribution_id }} + # CloudFront invalidation typically takes 1-5 minutes + echo "⏳ Waiting for CloudFront to clear cache..." + sleep 10 + echo "✅ Deployment complete! Changes will be visible in 1-5 minutes" + if: steps.cloudfront-id.outputs.distribution_id != '' + + - name: Deployment Summary + run: | + echo "## ✅ Deployment Successful" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "- **Commit**: ${{ github.sha }}" >> $GITHUB_STEP_SUMMARY + echo "- **Branch**: ${{ github.ref_name }}" >> $GITHUB_STEP_SUMMARY + echo "- **S3 Bucket**: ${{ steps.s3-bucket.outputs.bucket }}" >> $GITHUB_STEP_SUMMARY + echo "- **CloudFront Distribution**: ${{ steps.cloudfront-id.outputs.distribution_id }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Changes will be live in 1-5 minutes once CloudFront cache is invalidated." >> $GITHUB_STEP_SUMMARY + + - name: Notify Slack (optional) + uses: slackapi/slack-github-action@v1.25 + if: always() + with: + webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }} + payload: | + { + "text": "🚀 Gluko deployment ${{ job.status }} - ${{ github.sha }}", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Gluko Deployment ${{ job.status }}*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|${{ github.sha }}>\nBranch: ${{ github.ref_name }}" + } + } + ] + } + continue-on-error: true diff --git a/.github/workflows/terragrunt-apply.yml b/.github/workflows/terragrunt-apply.yml new file mode 100644 index 0000000..d3f9916 --- /dev/null +++ b/.github/workflows/terragrunt-apply.yml @@ -0,0 +1,61 @@ +name: Terragrunt Apply + +on: + push: + branches: + - main + paths: + - "terragrunt/**" + - ".github/workflows/terragrunt-apply.yml" + workflow_dispatch: + +permissions: + id-token: write + contents: read + +env: + AWS_REGION: us-east-1 + TERRAFORM_VERSION: 1.8.0 + TERRAGRUNT_VERSION: 0.59.6 + +concurrency: + group: terragrunt-apply + cancel-in-progress: false + +jobs: + terragrunt-apply: + name: Terragrunt Apply + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Terraform and Terragrunt + uses: cds-snc/terraform-tools-setup@v1 + with: + terraform-version: ${{ env.TERRAFORM_VERSION }} + terragrunt-version: ${{ env.TERRAGRUNT_VERSION }} + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-terragrunt-role + aws-region: ${{ env.AWS_REGION }} + role-session-name: github-terragrunt-apply + + - name: Terragrunt Apply All Modules + working-directory: terragrunt/environments/prod + run: | + terragrunt run-all apply -auto-approve + + - name: Output Route53 Nameservers + if: success() + working-directory: terragrunt/environments/prod/route53 + run: | + echo "## ✅ Infrastructure Deployed Successfully" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Update your domain registrar with these nameservers:**" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + terragrunt output -raw nameservers_string >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/terragrunt-plan.yml b/.github/workflows/terragrunt-plan.yml new file mode 100644 index 0000000..e9ab257 --- /dev/null +++ b/.github/workflows/terragrunt-plan.yml @@ -0,0 +1,59 @@ +name: Terragrunt Plan + +on: + pull_request: + branches: + - main + paths: + - "terragrunt/**" + - ".github/workflows/terragrunt-plan.yml" + workflow_dispatch: + +permissions: + id-token: write + contents: read + pull-requests: write + +env: + AWS_REGION: us-east-1 + TERRAFORM_VERSION: 1.14.5 + TERRAGRUNT_VERSION: 0.99.4 + +jobs: + plan: + name: Terragrunt Plan All Modules + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-terragrunt-role + aws-region: ${{ env.AWS_REGION }} + role-session-name: github-terragrunt-plan + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: ${{ env.TERRAFORM_VERSION }} + terraform_wrapper: false + + - name: Setup Terragrunt + run: | + wget -q https://github.com/gruntwork-io/terragrunt/releases/download/v${{ env.TERRAGRUNT_VERSION }}/terragrunt_linux_amd64 + chmod +x terragrunt_linux_amd64 + sudo mv terragrunt_linux_amd64 /usr/local/bin/terragrunt + terragrunt --version + + - name: Terragrunt Init All + working-directory: terragrunt/environments/prod + run: | + terragrunt run --non-interactive --all init + + - name: Terragrunt Plan All + working-directory: terragrunt/environments/prod + run: | + terragrunt run --non-interactive --all plan -- -no-color 2>&1 | tee plan.txt \ No newline at end of file diff --git a/terragrunt/.terragruntignore b/terragrunt/.terragruntignore new file mode 100644 index 0000000..5fff6ee --- /dev/null +++ b/terragrunt/.terragruntignore @@ -0,0 +1,17 @@ +# Terragrunt ignore patterns +# Don't include these in terraform operations + +.git +.gitignore +.terraform +.terragrunt-cache +terraform.tfvars +*.tfstate +*.tfstate.* +*.backup +*.swp +*.swo +*~ +.DS_Store +node_modules +dist diff --git a/terragrunt/README.md b/terragrunt/README.md new file mode 100644 index 0000000..184ed7b --- /dev/null +++ b/terragrunt/README.md @@ -0,0 +1,403 @@ +# Terragrunt/Terraform Infrastructure for Gluko PWA + +This directory contains Infrastructure as Code (IaC) for deploying Gluko to AWS using Terragrunt and Terraform. + +## Quick Links + +- **[Setup Guide](./SETUP_GUIDE.md)**: Detailed setup instructions for GitHub Actions deployment +- **[Architecture](#architecture-overview)**: System design and services +- **[Cost](#cost-estimation)**: Pricing breakdown (~$0.50/month) + +## Architecture Overview + +``` +Custom Domain (registrar) + ↓ + Route 53 DNS + ↓ + CloudFront CDN (Free Tier $0/mo) + ↓ + S3 Bucket (Static Files) + ↓ + Built Assets (HTML, CSS, JS, JSON) +``` + +## Services Deployed + +- **S3**: Static file hosting with versioning and encryption (private, accessed via CloudFront only) +- **CloudFront**: Global CDN with DDoS protection, HTTPS, intelligent caching +- **Route 53**: DNS management with alias records to CloudFront +- **ACM**: Free SSL/TLS certificate with auto-renewal + +## Prerequisites + +### Required Tools + +```bash +# Homebrew (macOS) +brew install terraform terragrunt aws-cli + +# Or download manually: +# - Terraform: https://www.terraform.io/downloads +# - Terragrunt: https://terragrunt.gruntwork.io/docs/getting-started/install/ +# - AWS CLI: https://aws.amazon.com/cli/ +``` + +### AWS Account Setup + +1. Create AWS account: https://aws.amazon.com/ +2. Configure AWS credentials: + +```bash +# Option 1: Interactive +aws configure + +# Option 2: Environment variables +export AWS_ACCESS_KEY_ID="your-access-key" +export AWS_SECRET_ACCESS_KEY="your-secret-key" +export AWS_REGION="us-east-1" + +# Option 3: AWS credentials file (~/.aws/credentials) +[default] +aws_access_key_id = your-access-key +aws_secret_access_key = your-secret-key +``` + +### Domain Setup (Registrar) + +1. Domain is managed at an external registrar +2. Route 53 hosted zone is created in AWS +3. Nameservers at the registrar are updated to the Route 53 nameservers +4. ACM validates the certificate via DNS records in Route 53 + +## Project Structure + +``` +terragrunt/ +├── terragrunt.hcl # Root config (shared by all modules) +├── modules/ # Reusable Terraform modules +│ ├── s3/ # S3 bucket for static files +│ ├── cloudfront/ # CloudFront CDN +│ ├── route53/ # Route 53 DNS +│ └── acm/ # SSL/TLS certificate +└── environments/ + └── prod/ + ├── region.hcl # Environment variables + ├── s3/terragrunt.hcl # S3 deployment config + ├── cloudfront/terragrunt.hcl + ├── route53/terragrunt.hcl + └── acm/terragrunt.hcl +``` + +## Configuration + +### 1. Update Domain Name + +Edit `terragrunt/environments/prod/region.hcl`: + +```hcl +locals { + domain_name = "your-actual-domain.com" # NOT gluko.example.com + # ... rest of config +} +``` + +Replace `your-actual-domain.com` with the production domain. + +### 2. AWS Region + +All infrastructure is in `us-east-1` (required for CloudFront + ACM certificates). + +## Deployment Steps + +### Step 1: Initialize Terragrunt + +```bash +cd terragrunt/environments/prod + +# Initialize - creates .terraform directory and downloads providers +terragrunt run-all init +``` + +### Step 2: Plan Infrastructure + +```bash +# See what will be created (dry-run) +terragrunt run-all plan + +# Or plan individual modules: +terragrunt plan -chdir s3/ +terragrunt plan -chdir cloudfront/ +terragrunt plan -chdir route53/ +terragrunt plan -chdir acm/ +``` + +### Step 3: Deploy Infrastructure + +```bash +# Apply all changes +terragrunt run-all apply + +# Or apply modules in order (with dependencies): +terragrunt apply -chdir s3/ +terragrunt apply -chdir route53/ +terragrunt apply -chdir acm/ # Uses Route 53 for DNS validation +terragrunt apply -chdir cloudfront/ # Needs S3 + ACM +``` + +When prompted, review the changes and type `yes` to confirm. + +**Note**: First deployment typically takes 15-30 minutes (CloudFront distribution creation is slow). + +### Step 4: Get Route 53 Nameservers + +After Route 53 deploys successfully: + +```bash +# Get the nameservers +terragrunt output -chdir route53/ nameservers + +# Output will be like: +# ns-123.awsdns-45.com. +# ns-456.awsdns-78.net. +# ns-789.awsdns-01.com. +# ns-012.awsdns-34.org. +``` + +### Step 5: Update Registrar Nameservers + +1. Login to the registrar +2. Go to domain settings → name servers +3. Select external name servers +4. Enter the 4 Route 53 nameservers from Step 4 +5. Save (propagation varies by registrar) + +### Step 6: Verify CloudFront & Certificate + +After nameservers propagate: + +```bash +# Get CloudFront distribution ID +terragrunt output -chdir cloudfront/ distribution_id + +# Get domain name +terragrunt output -chdir cloudfront/ domain_name + +# Check ACM certificate status +terragrunt output -chdir acm/ certificate_status +``` + +Visit `https://your-domain.com` - should show HTTPS ✅ + +## Uploading Build Files to S3 + +```bash +# Build your app locally +npm run build + +# Get S3 bucket name +BUCKET=$(terragrunt output -chdir s3/ bucket_id 2>/dev/null | tr -d '"') + +# Sync built files to S3 +aws s3 sync dist/ s3://$BUCKET/ --delete --cache-control "public, max-age=31536000" + +# Special handling for index.html and manifest.json (short TTL) +aws s3 cp dist/index.html s3://$BUCKET/ --cache-control "public, max-age=3600" +aws s3 cp dist/manifest.json s3://$BUCKET/ --cache-control "public, max-age=0" + +# Invalidate CloudFront cache +DIST_ID=$(terragrunt output -chdir cloudfront/ distribution_id 2>/dev/null | tr -d '"') +aws cloudfront create-invalidation --distribution-id $DIST_ID --paths "/index.html" "/manifest.json" +``` + +**Tip**: Use the GitHub Actions workflow for automated deployments (see below). + +## Useful Commands + +```bash +# Show all outputs across all modules +terragrunt run-all output + +# Show specific output +terragrunt output -chdir route53/ nameservers + +# Destroy all infrastructure (use with caution!) +terragrunt run-all destroy + +# Refresh state (sync local state with AWS) +terragrunt run-all refresh + +# Format all Terraform files +terragrunt run-all fmt + +# Validate all Terraform configurations +terragrunt run-all validate + +# Show costs estimate (requires setup) +# terragrunt run-all plan -out=tfplan && terraform show -json tfplan | jq .resource_changes +``` + +## Terraform State Management + +State files are stored in S3 with native lockfiles: + +- **S3 Bucket**: `gluko-terraform-state-{ACCOUNT_ID}` +- **Locking**: S3 lockfile (`use_lockfile = true`) +- **Encryption**: Enabled by default +- **Versioning**: Enabled for recovery + +**Never commit state files to Git!** (already in .gitignore) + +## GitHub Actions Deployment (CI/CD) + +See the Terragrunt workflows for automated deployments: +- `.github/workflows/terragrunt-plan.yml` (PR plans) +- `.github/workflows/terragrunt-apply.yml` (apply on main) + +Setup: + +1. Create IAM role for GitHub with Terragrunt permissions +2. Configure OIDC trust between GitHub and AWS +3. Add `AWS_ACCOUNT_ID` secret to GitHub Actions +4. Push to `main` branch triggers apply workflow + +## Cost Estimation + +| Service | Free Tier | Notes | +| ---------- | ------------- | -------------------------- | +| S3 | $0 | 5 GB storage included | +| CloudFront | $0 | Free plan ($0/mo) | +| Route 53 | ~$0.50/mo | Hosted zone + DNS queries | +| ACM | $0 | Non-exportable cert (free) | +| **Total** | **~$0.50/mo** | ✅ Essentially free | + +Free tier usage is expected to cover S3, CloudFront, and ACM. Route 53 hosted zone cost remains. + +## Monitoring & Debugging + +### Check Distribution Status + +```bash +aws cloudfront get-distribution \ + --id $(terragrunt output -chdir cloudfront/ distribution_id | tr -d '"') +``` + +### View CloudFront Metrics + +``` +AWS Console → CloudFront → Distributions → Select gluko-pwa → Monitoring +``` + +### Test HTTPS Certificate + +```bash +openssl s_client -connect your-domain.com:443 -servername your-domain.com +``` + +### Check DNS Resolution + +```bash +# Should resolve to CloudFront distribution +dig your-domain.com + +# View nameserver records +dig NS your-domain.com +``` + +### Monitor S3 Bucket + +```bash +# List all objects in S3 +aws s3 ls s3://gluko-pwa/ --recursive + +# Check bucket size +aws s3 ls s3://gluko-pwa/ --recursive --summarize +``` + +## Troubleshooting + +### "Certificate validation pending" (ACM) + +Ensure Route 53 hosted zone is active and nameservers are updated at the registrar. Validation can take 5-60 minutes. + +### CloudFront returns 403 (Forbidden) + +- Check S3 bucket policy is correct (OAC configured) +- Verify CloudFront origin points to S3 bucket +- Check S3 bucket Block Public Access settings + +### Changes not visible after deployment + +CloudFront caches files: +- `index.html`: cached 1 hour (update within 60 minutes) +- `manifest.json`: not cached (update immediately) +- JS/CSS: cached 1 year (must change filename to update) + +Manually invalidate cache: + +```bash +aws cloudfront create-invalidation --distribution-id DIST_ID --paths "/index.html" +``` + +### Nameservers not updating at registrar + +- Propagation time varies by registrar (often 5 minutes to 24 hours) +- Verify the correct domain is being updated +- Check DNS with: `dig NS your-domain.com` + +### Terraform lock timeout + +If another deployment is running: + +```bash +# Inspect and remove stale lockfile in S3 (use with caution) +aws s3 ls s3://gluko-terraform-state-{ACCOUNT_ID}/ +aws s3 rm s3://gluko-terraform-state-{ACCOUNT_ID}/path/to/terraform.tfstate.lock +``` + +## Security Best Practices + +✅ **Already Configured**: +- S3 encryption at rest (AES-256) +- HTTPS only (redirects HTTP → HTTPS) +- CloudFront DDoS protection (included in free tier) +- Origin Access Control (S3 not publicly accessible) +- Public access blocked on S3 + +⚠️ **Additional Recommendations**: +- Enable MFA on AWS account +- Use IAM roles (not root credentials) +- Add billing alerts: AWS Console → Billing → Budget +- Enable CloudTrail for audit logging +- Consider AWS WAF for additional protection (not in free tier) + +## Next Steps + +1. **Update domain name** in `region.hcl` +2. **Run `terragrunt run-all plan`** to review +3. **Deploy infrastructure** with `terragrunt run-all apply` +4. **Update registrar nameservers** (get from Route 53 output) +5. **Wait 24h for propagation** +6. **Test HTTPS** at your domain +7. **Deploy app files** to S3 +8. **Set up GitHub Actions** for CI/CD + +## Resources + +- **Terraform Docs**: https://registry.terraform.io/ +- **Terragrunt Docs**: https://terragrunt.gruntwork.io/ +- **AWS Docs**: https://docs.aws.amazon.com/ +- **CloudFront Caching**: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/caching-and-serving.html + +## Support + +For issues: + +1. Check troubleshooting section +2. Review logs: `terragrunt run-all apply --terragrunt-log-level debug` +3. Check AWS Console for error messages +4. Review Terraform state: `terragrunt output -chdir /` + +## License + +Same as Gluko project diff --git a/terragrunt/environments/prod/acm/terragrunt.hcl b/terragrunt/environments/prod/acm/terragrunt.hcl new file mode 100644 index 0000000..99ea2c9 --- /dev/null +++ b/terragrunt/environments/prod/acm/terragrunt.hcl @@ -0,0 +1,30 @@ +include { + path = find_in_parent_folders("root.hcl") +} + +dependency "route53_zone" { + config_path = "../route53-zone" + + mock_outputs = { + hosted_zone_id = "Z1234567890ABC" + } + + mock_outputs_allowed_terraform_commands = ["plan", "validate", "init"] +} + +locals { + env_vars = read_terragrunt_config(find_in_parent_folders("region.hcl")) +} + +inputs = { + project_name = local.env_vars.locals.project_name + domain_name = local.env_vars.locals.domain_name + subject_alternative_names = [ + "www.${local.env_vars.locals.domain_name}", + ] + validation_method = "DNS" + route53_zone_id = dependency.route53_zone.outputs.hosted_zone_id + create_route53_records = true + wait_for_validation = true + tags = local.env_vars.locals.tags +} diff --git a/terragrunt/environments/prod/cloudfront/terragrunt.hcl b/terragrunt/environments/prod/cloudfront/terragrunt.hcl new file mode 100644 index 0000000..0225ed5 --- /dev/null +++ b/terragrunt/environments/prod/cloudfront/terragrunt.hcl @@ -0,0 +1,39 @@ +include { + path = find_in_parent_folders("root.hcl") +} + +dependency "s3" { + config_path = "../s3" + + mock_outputs = { + bucket_id = "gluko-pwa" + bucket_arn = "arn:aws:s3:::gluko-pwa" + bucket_regional_domain_name = "gluko-pwa.s3.us-east-1.amazonaws.com" + } + + mock_outputs_allowed_terraform_commands = ["plan", "validate", "init"] +} + +dependency "acm" { + config_path = "../acm" + + mock_outputs = { + certificate_arn = "arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012" + } + + mock_outputs_allowed_terraform_commands = ["plan", "validate", "init"] +} + +locals { + env_vars = read_terragrunt_config(find_in_parent_folders("region.hcl")) +} + +inputs = { + distribution_name = "${local.env_vars.locals.project_name}" + s3_bucket_id = dependency.s3.outputs.bucket_id + s3_bucket_arn = dependency.s3.outputs.bucket_arn + s3_bucket_regional_domain_name = dependency.s3.outputs.bucket_regional_domain_name + acm_certificate_arn = dependency.acm.outputs.certificate_arn + domain_name = local.env_vars.locals.domain_name + tags = local.env_vars.locals.tags +} diff --git a/terragrunt/environments/prod/region.hcl b/terragrunt/environments/prod/region.hcl new file mode 100644 index 0000000..c37700e --- /dev/null +++ b/terragrunt/environments/prod/region.hcl @@ -0,0 +1,11 @@ +locals { + environment = "prod" + aws_region = "us-east-1" # Required for CloudFront + ACM + project_name = "gluko-pwa" + domain_name = "gluko.ca" + + tags = { + Environment = "production" + Terraform = "true" + } +} diff --git a/terragrunt/environments/prod/route53-records/terragrunt.hcl b/terragrunt/environments/prod/route53-records/terragrunt.hcl new file mode 100644 index 0000000..0343866 --- /dev/null +++ b/terragrunt/environments/prod/route53-records/terragrunt.hcl @@ -0,0 +1,37 @@ +include { + path = find_in_parent_folders("root.hcl") +} + +dependency "route53_zone" { + config_path = "../route53-zone" + + mock_outputs = { + hosted_zone_id = "Z1234567890ABC" + } + + mock_outputs_allowed_terraform_commands = ["plan", "validate", "init"] +} + +dependency "cloudfront" { + config_path = "../cloudfront" + + mock_outputs = { + domain_name = "d123abc.cloudfront.net" + distribution_id = "E123ABC" + } + + mock_outputs_allowed_terraform_commands = ["plan", "validate", "init"] +} + +locals { + env_vars = read_terragrunt_config(find_in_parent_folders("region.hcl")) +} + +inputs = { + domain_name = local.env_vars.locals.domain_name + route53_zone_id = dependency.route53_zone.outputs.hosted_zone_id + cloudfront_domain_name = dependency.cloudfront.outputs.domain_name + cloudfront_zone_id = "Z2FDTNDATAQYW2" # CloudFront zone ID for us-east-1 + create_www_subdomain = true + tags = local.env_vars.locals.tags +} \ No newline at end of file diff --git a/terragrunt/environments/prod/route53-zone/terragrunt.hcl b/terragrunt/environments/prod/route53-zone/terragrunt.hcl new file mode 100644 index 0000000..cd6b4cb --- /dev/null +++ b/terragrunt/environments/prod/route53-zone/terragrunt.hcl @@ -0,0 +1,12 @@ +include { + path = find_in_parent_folders("root.hcl") +} + +locals { + env_vars = read_terragrunt_config(find_in_parent_folders("region.hcl")) +} + +inputs = { + domain_name = local.env_vars.locals.domain_name + tags = local.env_vars.locals.tags +} diff --git a/terragrunt/environments/prod/s3/terragrunt.hcl b/terragrunt/environments/prod/s3/terragrunt.hcl new file mode 100644 index 0000000..7d5c114 --- /dev/null +++ b/terragrunt/environments/prod/s3/terragrunt.hcl @@ -0,0 +1,13 @@ +include { + path = find_in_parent_folders("root.hcl") +} + +locals { + env_vars = read_terragrunt_config(find_in_parent_folders("region.hcl")) +} + +inputs = { + bucket_name = "${local.env_vars.locals.project_name}" + domain_name = local.env_vars.locals.domain_name + tags = local.env_vars.locals.tags +} diff --git a/terragrunt/modules/acm/main.tf b/terragrunt/modules/acm/main.tf new file mode 100644 index 0000000..173fc3f --- /dev/null +++ b/terragrunt/modules/acm/main.tf @@ -0,0 +1,50 @@ +# ACM certificates for CloudFront must be created in us-east-1 +provider "aws" { + alias = "us_east_1" + region = "us-east-1" +} + +resource "aws_acm_certificate" "website" { + provider = aws.us_east_1 + domain_name = var.domain_name + validation_method = var.validation_method + subject_alternative_names = var.subject_alternative_names + + tags = merge( + var.tags, + { + Name = "${var.project_name}-certificate" + } + ) + + lifecycle { + create_before_destroy = true + } +} + +resource "aws_route53_record" "cert_validation" { + for_each = var.create_route53_records ? { + for dvo in aws_acm_certificate.website.domain_validation_options : dvo.domain_name => { + name = dvo.resource_record_name + record = dvo.resource_record_value + type = dvo.resource_record_type + } + } : {} + + zone_id = var.route53_zone_id + name = each.value.name + type = each.value.type + ttl = 60 + records = [each.value.record] +} + +resource "aws_acm_certificate_validation" "website" { + count = var.wait_for_validation ? 1 : 0 + certificate_arn = aws_acm_certificate.website.arn + + timeouts { + create = "5m" + } + + depends_on = [aws_route53_record.cert_validation] +} \ No newline at end of file diff --git a/terragrunt/modules/acm/outputs.tf b/terragrunt/modules/acm/outputs.tf new file mode 100644 index 0000000..92e3d18 --- /dev/null +++ b/terragrunt/modules/acm/outputs.tf @@ -0,0 +1,26 @@ +output "certificate_arn" { + description = "ARN of the ACM certificate" + value = aws_acm_certificate.website.arn +} + +output "certificate_domain" { + description = "Domain name of the certificate" + value = aws_acm_certificate.website.domain_name +} + +output "certificate_status" { + description = "Status of the certificate (PENDING_VALIDATION, ISSUED, etc.)" + value = aws_acm_certificate.website.status +} + +output "validation_options" { + description = "Certificate validation options" + value = [ + for option in aws_acm_certificate.website.domain_validation_options : { + domain_name = option.domain_name + record_name = option.resource_record_name + record_type = option.resource_record_type + record_value = option.resource_record_value + } + ] +} diff --git a/terragrunt/modules/acm/variables.tf b/terragrunt/modules/acm/variables.tf new file mode 100644 index 0000000..c289c15 --- /dev/null +++ b/terragrunt/modules/acm/variables.tf @@ -0,0 +1,50 @@ +variable "domain_name" { + description = "Primary domain name for the certificate" + type = string +} + +variable "subject_alternative_names" { + description = "Alternative domain names for the certificate" + type = list(string) + default = [] +} + +variable "validation_method" { + description = "Certificate validation method (DNS or EMAIL)" + type = string + default = "DNS" + validation { + condition = contains(["DNS", "EMAIL"], var.validation_method) + error_message = "Validation method must be DNS or EMAIL." + } +} + +variable "route53_zone_id" { + description = "Route 53 Hosted Zone ID for DNS validation" + type = string + default = "" +} + +variable "create_route53_records" { + description = "Whether to create Route 53 records for DNS validation" + type = bool + default = true +} + +variable "wait_for_validation" { + description = "Whether to wait for certificate validation before returning" + type = bool + default = true +} + +variable "tags" { + description = "Common tags for all resources" + type = map(string) + default = {} +} + +variable "project_name" { + description = "Project name for resource naming" + type = string + default = "gcharest" +} \ No newline at end of file diff --git a/terragrunt/modules/cloudfront/main.tf b/terragrunt/modules/cloudfront/main.tf new file mode 100644 index 0000000..faa1834 --- /dev/null +++ b/terragrunt/modules/cloudfront/main.tf @@ -0,0 +1,294 @@ +resource "aws_cloudfront_origin_access_control" "s3" { + name = "${var.distribution_name}-oac" + description = "OAC for ${var.distribution_name}" + origin_access_control_origin_type = "s3" + + signing_behavior = "always" + signing_protocol = "sigv4" +} + + +resource "aws_cloudfront_distribution" "website" { + origin { + domain_name = var.s3_bucket_regional_domain_name + origin_id = "S3Origin" + origin_access_control_id = aws_cloudfront_origin_access_control.s3.id + } + + enabled = true + is_ipv6_enabled = true + comment = "Gluko PWA CDN" + default_root_object = "index.html" + + aliases = [var.domain_name, "www.${var.domain_name}"] + + custom_error_response { + error_code = 404 + response_code = 200 + response_page_path = "/index.html" + } + + custom_error_response { + error_code = 403 + response_code = 200 + response_page_path = "/index.html" + } + + default_cache_behavior { + allowed_methods = ["GET", "HEAD", "OPTIONS"] + cached_methods = ["GET", "HEAD"] + target_origin_id = "S3Origin" + + cache_policy_id = aws_cloudfront_cache_policy.immutable_assets.id + response_headers_policy_id = aws_cloudfront_response_headers_policy.security_headers.id + + compress = true + viewer_protocol_policy = "redirect-to-https" + } + + ordered_cache_behavior { + path_pattern = "manifest.json" + allowed_methods = ["GET", "HEAD", "OPTIONS"] + cached_methods = ["GET", "HEAD"] + target_origin_id = "S3Origin" + + cache_policy_id = aws_cloudfront_cache_policy.no_cache.id + + compress = true + viewer_protocol_policy = "redirect-to-https" + } + + ordered_cache_behavior { + path_pattern = "index.html" + allowed_methods = ["GET", "HEAD", "OPTIONS"] + cached_methods = ["GET", "HEAD"] + target_origin_id = "S3Origin" + + cache_policy_id = aws_cloudfront_cache_policy.index_html.id + + compress = true + viewer_protocol_policy = "redirect-to-https" + } + + ordered_cache_behavior { + path_pattern = "data/*" + allowed_methods = ["GET", "HEAD", "OPTIONS"] + cached_methods = ["GET", "HEAD"] + target_origin_id = "S3Origin" + + cache_policy_id = aws_cloudfront_cache_policy.data_shards.id + + compress = true + viewer_protocol_policy = "redirect-to-https" + } + + restrictions { + geo_restriction { + restriction_type = "none" + } + } + + viewer_certificate { + acm_certificate_arn = var.acm_certificate_arn + minimum_protocol_version = "TLSv1.2_2021" + ssl_support_method = "sni-only" + cloudfront_default_certificate = false + } + + # Logging disabled to stay within free tier + # Enable only if needed for debugging (adds cost) + + tags = merge( + var.tags, + { + Name = var.distribution_name + } + ) + + depends_on = [aws_s3_bucket_policy.cloudfront_access] +} + +data "aws_iam_policy_document" "cloudfront_oac" { + statement { + sid = "CloudFrontAccess" + effect = "Allow" + + principals { + type = "Service" + identifiers = ["cloudfront.amazonaws.com"] + } + + actions = [ + "s3:GetObject", + ] + + resources = [ + "${var.s3_bucket_arn}/*", + ] + + condition { + test = "StringEquals" + variable = "AWS:SourceArn" + values = [aws_cloudfront_distribution.website.arn] + } + } + + statement { + sid = "ListBucketAccess" + effect = "Allow" + + principals { + type = "Service" + identifiers = ["cloudfront.amazonaws.com"] + } + + actions = [ + "s3:ListBucket", + ] + + resources = [ + var.s3_bucket_arn, + ] + + condition { + test = "StringEquals" + variable = "AWS:SourceArn" + values = [aws_cloudfront_distribution.website.arn] + } + } +} + +resource "aws_s3_bucket_policy" "cloudfront_access" { + bucket = var.s3_bucket_id + policy = data.aws_iam_policy_document.cloudfront_oac.json +} + +resource "aws_cloudfront_cache_policy" "immutable_assets" { + name = "${var.distribution_name}-immutable-assets" + comment = "Cache policy for hashed JS/CSS files (1 year)" + default_ttl = 31536000 + max_ttl = 31536000 + min_ttl = 0 + + parameters_in_cache_key_and_forwarded_to_origin { + enable_accept_encoding_gzip = true + enable_accept_encoding_brotli = true + query_strings_config { + query_string_behavior = "none" + } + headers_config { + header_behavior = "none" + } + cookies_config { + cookie_behavior = "none" + } + } +} + +resource "aws_cloudfront_cache_policy" "index_html" { + name = "${var.distribution_name}-index-html" + comment = "Cache policy for index.html (1 hour)" + default_ttl = 3600 + max_ttl = 3600 + min_ttl = 0 + + parameters_in_cache_key_and_forwarded_to_origin { + enable_accept_encoding_gzip = true + enable_accept_encoding_brotli = true + query_strings_config { + query_string_behavior = "none" + } + headers_config { + header_behavior = "none" + } + cookies_config { + cookie_behavior = "none" + } + } +} + +resource "aws_cloudfront_cache_policy" "no_cache" { + name = "${var.distribution_name}-no-cache" + comment = "No cache policy for manifest.json" + default_ttl = 0 + max_ttl = 0 + min_ttl = 0 + + parameters_in_cache_key_and_forwarded_to_origin { + enable_accept_encoding_gzip = true + enable_accept_encoding_brotli = true + query_strings_config { + query_string_behavior = "none" + } + headers_config { + header_behavior = "none" + } + cookies_config { + cookie_behavior = "none" + } + } +} + +resource "aws_cloudfront_cache_policy" "data_shards" { + name = "${var.distribution_name}-data-shards" + comment = "Cache policy for data shards (24 hours)" + default_ttl = 86400 + max_ttl = 86400 + min_ttl = 0 + + parameters_in_cache_key_and_forwarded_to_origin { + enable_accept_encoding_gzip = true + enable_accept_encoding_brotli = true + query_strings_config { + query_string_behavior = "none" + } + headers_config { + header_behavior = "none" + } + cookies_config { + cookie_behavior = "none" + } + } +} + +resource "aws_cloudfront_response_headers_policy" "security_headers" { + name = "${var.distribution_name}-security-headers" + comment = "Security headers for PWA" + + security_headers_config { + strict_transport_security { + access_control_max_age_sec = 31536000 + include_subdomains = true + preload = true + override = true + } + + content_type_options { + override = true + } + + frame_options { + frame_option = "DENY" + override = true + } + + xss_protection { + mode_block = true + protection = true + override = true + } + + referrer_policy { + referrer_policy = "same-origin" + override = true + } + } + + custom_headers_config { + items { + header = "Permissions-Policy" + value = "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()" + override = true + } + } +} diff --git a/terragrunt/modules/cloudfront/outputs.tf b/terragrunt/modules/cloudfront/outputs.tf new file mode 100644 index 0000000..ba516ba --- /dev/null +++ b/terragrunt/modules/cloudfront/outputs.tf @@ -0,0 +1,19 @@ +output "distribution_id" { + description = "CloudFront distribution ID" + value = aws_cloudfront_distribution.website.id +} + +output "distribution_arn" { + description = "CloudFront distribution ARN" + value = aws_cloudfront_distribution.website.arn +} + +output "domain_name" { + description = "CloudFront distribution domain name" + value = aws_cloudfront_distribution.website.domain_name +} + +output "status" { + description = "Status of the CloudFront distribution" + value = aws_cloudfront_distribution.website.status +} diff --git a/terragrunt/modules/cloudfront/variables.tf b/terragrunt/modules/cloudfront/variables.tf new file mode 100644 index 0000000..c612727 --- /dev/null +++ b/terragrunt/modules/cloudfront/variables.tf @@ -0,0 +1,35 @@ +variable "distribution_name" { + description = "Name of the CloudFront distribution" + type = string +} + +variable "s3_bucket_id" { + description = "S3 bucket ID for CloudFront origin" + type = string +} + +variable "s3_bucket_arn" { + description = "S3 bucket ARN" + type = string +} + +variable "s3_bucket_regional_domain_name" { + description = "S3 bucket regional domain name" + type = string +} + +variable "acm_certificate_arn" { + description = "ACM certificate ARN for HTTPS" + type = string +} + +variable "domain_name" { + description = "Domain name for the distribution" + type = string +} + +variable "tags" { + description = "Common tags for all resources" + type = map(string) + default = {} +} diff --git a/terragrunt/modules/route53-records/main.tf b/terragrunt/modules/route53-records/main.tf new file mode 100644 index 0000000..afb6354 --- /dev/null +++ b/terragrunt/modules/route53-records/main.tf @@ -0,0 +1,36 @@ +resource "aws_route53_record" "cloudfront" { + zone_id = var.route53_zone_id + name = var.domain_name + type = "A" + + alias { + name = var.cloudfront_domain_name + zone_id = var.cloudfront_zone_id + evaluate_target_health = false + } +} + +resource "aws_route53_record" "cloudfront_ipv6" { + zone_id = var.route53_zone_id + name = var.domain_name + type = "AAAA" + + alias { + name = var.cloudfront_domain_name + zone_id = var.cloudfront_zone_id + evaluate_target_health = false + } +} + +resource "aws_route53_record" "www" { + count = var.create_www_subdomain ? 1 : 0 + zone_id = var.route53_zone_id + name = "www.${var.domain_name}" + type = "A" + + alias { + name = var.cloudfront_domain_name + zone_id = var.cloudfront_zone_id + evaluate_target_health = false + } +} \ No newline at end of file diff --git a/terragrunt/modules/route53-records/outputs.tf b/terragrunt/modules/route53-records/outputs.tf new file mode 100644 index 0000000..9054980 --- /dev/null +++ b/terragrunt/modules/route53-records/outputs.tf @@ -0,0 +1,24 @@ +output "hosted_zone_id" { + description = "Route 53 Hosted Zone ID" + value = aws_route53_zone.main.zone_id +} + +output "hosted_zone_name" { + description = "Route 53 Hosted Zone name" + value = aws_route53_zone.main.name +} + +output "nameservers" { + description = "Route 53 nameservers (use these at your registrar)" + value = aws_route53_zone.main.name_servers +} + +output "nameservers_string" { + description = "Route 53 nameservers as a formatted string for copy-paste" + value = join("\n", aws_route53_zone.main.name_servers) +} + +output "primary_nameserver" { + description = "Primary Route 53 nameserver" + value = aws_route53_zone.main.name_servers[0] +} diff --git a/terragrunt/modules/route53-records/variables.tf b/terragrunt/modules/route53-records/variables.tf new file mode 100644 index 0000000..83bfcd7 --- /dev/null +++ b/terragrunt/modules/route53-records/variables.tf @@ -0,0 +1,32 @@ +variable "domain_name" { + description = "Primary domain name for DNS records" + type = string +} + +variable "route53_zone_id" { + description = "Route 53 hosted zone ID where records will be created" + type = string +} + +variable "cloudfront_domain_name" { + description = "CloudFront distribution domain name" + type = string +} + +variable "cloudfront_zone_id" { + description = "CloudFront distribution zone ID (for alias records)" + type = string + default = "Z2FDTNDATAQYW2" +} + +variable "create_www_subdomain" { + description = "Whether to create www subdomain record" + type = bool + default = true +} + +variable "tags" { + description = "Common tags for all resources" + type = map(string) + default = {} +} diff --git a/terragrunt/modules/route53-zone/main.tf b/terragrunt/modules/route53-zone/main.tf new file mode 100644 index 0000000..a20eed4 --- /dev/null +++ b/terragrunt/modules/route53-zone/main.tf @@ -0,0 +1,11 @@ +resource "aws_route53_zone" "main" { + name = var.domain_name + comment = "Hosted zone for ${var.domain_name}" + + tags = merge( + var.tags, + { + Name = var.domain_name + } + ) +} \ No newline at end of file diff --git a/terragrunt/modules/route53-zone/outputs.tf b/terragrunt/modules/route53-zone/outputs.tf new file mode 100644 index 0000000..adfcf12 --- /dev/null +++ b/terragrunt/modules/route53-zone/outputs.tf @@ -0,0 +1,14 @@ +output "hosted_zone_id" { + description = "The Route53 hosted zone ID" + value = aws_route53_zone.main.zone_id +} + +output "name_servers" { + description = "The name servers for the hosted zone" + value = aws_route53_zone.main.name_servers +} + +output "zone_arn" { + description = "The ARN of the hosted zone" + value = aws_route53_zone.main.arn +} diff --git a/terragrunt/modules/route53-zone/variables.tf b/terragrunt/modules/route53-zone/variables.tf new file mode 100644 index 0000000..ecd9a74 --- /dev/null +++ b/terragrunt/modules/route53-zone/variables.tf @@ -0,0 +1,24 @@ +variable "domain_name" { + description = "Primary domain name for Route 53 hosted zone" + type = string + validation { + condition = can(regex("^[a-z0-9][a-z0-9-]*[a-z0-9]+\\.[a-z]{2,}$", var.domain_name)) + error_message = "Domain name must be a valid domain." + } +} + +variable "tags" { + description = "Common tags for all resources" + type = map(string) + default = {} +} + +variable "aws_region" { + description = "AWS region for resources" + type = string +} + +variable "environment" { + description = "Environment name" + type = string +} diff --git a/terragrunt/modules/s3/main.tf b/terragrunt/modules/s3/main.tf new file mode 100644 index 0000000..3d9ec12 --- /dev/null +++ b/terragrunt/modules/s3/main.tf @@ -0,0 +1,87 @@ +resource "aws_s3_bucket" "website" { + bucket = var.bucket_name + + tags = merge( + var.tags, + { + Name = var.bucket_name + } + ) +} + +resource "aws_s3_bucket_versioning" "website" { + bucket = aws_s3_bucket.website.id + + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_s3_bucket_public_access_block" "website" { + bucket = aws_s3_bucket.website.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "website" { + bucket = aws_s3_bucket.website.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} + +resource "aws_s3_bucket_lifecycle_configuration" "website" { + bucket = aws_s3_bucket.website.id + + rule { + id = "delete-old-versions" + status = "Enabled" + + noncurrent_version_expiration { + noncurrent_days = 30 + } + } + + rule { + id = "delete-incomplete-uploads" + status = "Enabled" + + abort_incomplete_multipart_upload { + days_after_initiation = 7 + } + } +} + +resource "aws_s3_bucket_website_configuration" "website" { + bucket = aws_s3_bucket.website.id + + index_document { + suffix = "index.html" + } + + error_document { + key = "index.html" + } +} + +resource "aws_s3_bucket_cors_configuration" "website" { + bucket = aws_s3_bucket.website.id + + cors_rule { + allowed_headers = ["*"] + allowed_methods = ["GET", "HEAD"] + allowed_origins = [ + "https://${var.domain_name}", + "http://localhost:5173", # Local dev + "http://localhost:3000", # Alternative dev port + ] + expose_headers = ["ETag"] + max_age_seconds = 3000 + } +} diff --git a/terragrunt/modules/s3/outputs.tf b/terragrunt/modules/s3/outputs.tf new file mode 100644 index 0000000..9458176 --- /dev/null +++ b/terragrunt/modules/s3/outputs.tf @@ -0,0 +1,24 @@ +output "bucket_id" { + description = "S3 bucket ID" + value = aws_s3_bucket.website.id +} + +output "bucket_name" { + description = "The name of the bucket" + value = aws_s3_bucket.website.bucket +} + +output "bucket_arn" { + description = "S3 bucket ARN" + value = aws_s3_bucket.website.arn +} + +output "bucket_regional_domain_name" { + description = "S3 bucket regional domain name" + value = aws_s3_bucket.website.bucket_regional_domain_name +} + +output "bucket_region" { + description = "S3 bucket region" + value = aws_s3_bucket.website.region +} diff --git a/terragrunt/modules/s3/variables.tf b/terragrunt/modules/s3/variables.tf new file mode 100644 index 0000000..b8bf300 --- /dev/null +++ b/terragrunt/modules/s3/variables.tf @@ -0,0 +1,19 @@ +variable "bucket_name" { + description = "S3 bucket name for hosting static website files" + type = string + validation { + condition = can(regex("^[a-z0-9-]{3,63}$", var.bucket_name)) + error_message = "Bucket name must be 3-63 lowercase alphanumeric characters or hyphens." + } +} + +variable "domain_name" { + description = "Primary domain name for CORS configuration" + type = string +} + +variable "tags" { + description = "Common tags for all resources" + type = map(string) + default = {} +} diff --git a/terragrunt/root.hcl b/terragrunt/root.hcl new file mode 100644 index 0000000..d79a8d4 --- /dev/null +++ b/terragrunt/root.hcl @@ -0,0 +1,55 @@ +terraform { + source = "${get_parent_terragrunt_dir()}/modules//${path_relative_to_include()}" +} + +remote_state { + backend = "s3" + config = { + bucket = "gluko-terraform-state-${get_aws_account_id()}" + key = "${path_relative_to_include()}/terraform.tfstate" + region = get_env("AWS_REGION", "us-east-1") + encrypt = true + use_lockfile = true + } + generate = { + path = "backend.tf" + if_exists = "overwrite_terragrunt" + } +} + +generate "provider" { + path = "provider.tf" + if_exists = "overwrite_terragrunt" + contents = <<-EOF +terraform { + required_version = ">= 1.0" + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.0" + } + } +} + +provider "aws" { + region = var.aws_region + + default_tags { + tags = { + Project = "gluko" + Environment = var.environment + ManagedBy = "Terraform" + CreatedAt = timestamp() + } + } +} +EOF +} + +locals { + env_vars = read_terragrunt_config(find_in_parent_folders("region.hcl")) +} + +inputs = merge( + local.env_vars.locals, +)