Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,41 +1,40 @@
# .github/workflows/terraform-deploy.yml
# Terraform Infrastructure as Code β€” Plan & Apply for DigitalOcean
# .github/workflows/terraform.yml
# Terraform Infrastructure as Code β€” AWS ECS Fargate
#
# Plan: runs on every pull request targeting main (shows diff in PR comment).
# Plan: runs on every pull request targeting main (posts plan as a PR comment).
# Apply: runs on push to main with a manual approval gate via GitHub Environments.
#
# Required GitHub Secrets:
# DIGITALOCEAN_TOKEN β€” DigitalOcean personal access token
# DIGITALOCEAN_SPACES_KEY β€” Spaces access key (for remote state backend)
# DIGITALOCEAN_SPACES_SECRET β€” Spaces secret key (for remote state backend)
# AWS_ROLE_ARN β€” IAM role to assume via OIDC (configure-aws-credentials)
# TF_STATE_BUCKET β€” S3 bucket holding Terraform state
# TF_STATE_LOCK_TABLE β€” DynamoDB table used for state locking
#
# Optional GitHub Secrets:
# SSH_PUBLIC_KEY β€” SSH public key content for Droplet access
# TF_VAR_db_password β€” Override auto-generated DB password
# TF_VAR_redis_password β€” Override auto-generated Redis password
# Optional GitHub Secrets (overrides):
# TF_VAR_allowed_origins β€” CORS origins for the deployed app

name: Terraform Deploy
name: Terraform

on:
pull_request:
branches: [main]
paths:
- "terraform/**"
- ".github/workflows/terraform-deploy.yml"
- ".github/workflows/terraform.yml"
push:
branches: [main]
paths:
- "terraform/**"
- ".github/workflows/terraform-deploy.yml"
- ".github/workflows/terraform.yml"
workflow_dispatch:
inputs:
environment:
description: "Deployment environment (staging or production)"
description: "Deployment environment (dev, staging, or prod)"
required: true
type: choice
options:
- dev
- staging
- production
- prod
action:
description: "Terraform action to perform"
required: true
Expand All @@ -54,10 +53,11 @@ jobs:
# ─── Terraform Plan (on PR) ───────────────────────────────────────────────
plan:
name: Terraform Plan
if: github.event_name == 'pull_request'
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
pull-requests: write

defaults:
Expand All @@ -72,18 +72,28 @@ jobs:
with:
terraform_version: ${{ env.TF_VERSION }}

- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
role-session-name: terraform-plan
aws-region: ${{ env.AWS_REGION }}

- name: Terraform Format Check
id: fmt
run: terraform fmt -check -recursive
continue-on-error: true

- name: Terraform Init
- name: Terraform Init (remote state)
id: init
run: terraform init
env:
DIGITALOCEAN_TOKEN: ${{ secrets.DIGITALOCEAN_TOKEN }}
AWS_ACCESS_KEY_ID: ${{ secrets.DIGITALOCEAN_SPACES_KEY || '' }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET || '' }}
run: |
terraform init \
-input=false \
-backend-config="bucket=${{ secrets.TF_STATE_BUCKET }}" \
-backend-config="key=finchippay/dev/terraform.tfstate" \
-backend-config="region=${{ env.AWS_REGION }}" \
-backend-config="dynamodb_table=${{ secrets.TF_STATE_LOCK_TABLE }}" \
-backend-config="encrypt=true"

- name: Terraform Validate
id: validate
Expand All @@ -95,21 +105,17 @@ jobs:
terraform plan \
-no-color \
-input=false \
-var="environment=staging" \
-var="environment=dev" \
-var-file="environments/dev/dev.tfvars" \
-out=tfplan \
| tee /tmp/plan.txt
env:
DIGITALOCEAN_TOKEN: ${{ secrets.DIGITALOCEAN_TOKEN }}
AWS_ACCESS_KEY_ID: ${{ secrets.DIGITALOCEAN_SPACES_KEY || '' }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET || '' }}
TF_VAR_do_token: ${{ secrets.DIGITALOCEAN_TOKEN }}
TF_VAR_db_password: ${{ secrets.TF_VAR_db_password || '' }}
TF_VAR_redis_password: ${{ secrets.TF_VAR_redis_password || '' }}
TF_VAR_allowed_origins: ${{ secrets.TF_VAR_allowed_origins || '' }}
continue-on-error: true

- name: Post Plan to PR
uses: actions/github-script@v7
if: always()
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
Expand All @@ -133,7 +139,9 @@ jobs:

> **Action:** Review the plan above. Merge to \`main\` to trigger \`terraform apply\`.
> **Format:** ${{ steps.fmt.outcome }}
> **Init:** ${{ steps.init.outcome }}
> **Validate:** ${{ steps.validate.outcome }}
> **Plan:** ${{ steps.plan.outcome }}
`;

const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
Expand All @@ -159,9 +167,9 @@ jobs:
path: ${{ env.TF_WORKING_DIR }}/tfplan
retention-days: 7

# ─── Terraform Apply (on push to main) ────────────────────────────────────
# ─── Terraform Apply (on push to main, prod requires approval) ───────────
apply:
name: Terraform Apply
name: Terraform Apply (prod)
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment:
Expand All @@ -182,36 +190,40 @@ jobs:
with:
terraform_version: ${{ env.TF_VERSION }}

- name: Terraform Init
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
role-session-name: terraform-apply
aws-region: ${{ env.AWS_REGION }}

- name: Terraform Init (remote state)
id: init
run: terraform init
env:
DIGITALOCEAN_TOKEN: ${{ secrets.DIGITALOCEAN_TOKEN }}
AWS_ACCESS_KEY_ID: ${{ secrets.DIGITALOCEAN_SPACES_KEY || '' }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET || '' }}
run: |
terraform init \
-input=false \
-backend-config="bucket=${{ secrets.TF_STATE_BUCKET }}" \
-backend-config="key=finchippay/prod/terraform.tfstate" \
-backend-config="region=${{ env.AWS_REGION }}" \
-backend-config="dynamodb_table=${{ secrets.TF_STATE_LOCK_TABLE }}" \
-backend-config="encrypt=true"

- name: Terraform Plan (for apply)
id: plan
run: |
terraform plan \
-no-color \
-input=false \
-var="environment=production" \
-var-file="environments/prod/prod.tfvars" \
-out=tfplan
env:
DIGITALOCEAN_TOKEN: ${{ secrets.DIGITALOCEAN_TOKEN }}
TF_VAR_do_token: ${{ secrets.DIGITALOCEAN_TOKEN }}
TF_VAR_db_password: ${{ secrets.TF_VAR_db_password || '' }}
TF_VAR_redis_password: ${{ secrets.TF_VAR_redis_password || '' }}
TF_VAR_allowed_origins: ${{ secrets.TF_VAR_allowed_origins || '' }}

- name: Terraform Apply
id: apply
run: terraform apply -input=false -auto-approve tfplan
env:
DIGITALOCEAN_TOKEN: ${{ secrets.DIGITALOCEAN_TOKEN }}
TF_VAR_do_token: ${{ secrets.DIGITALOCEAN_TOKEN }}
TF_VAR_db_password: ${{ secrets.TF_VAR_db_password || '' }}
TF_VAR_redis_password: ${{ secrets.TF_VAR_redis_password || '' }}
TF_VAR_allowed_origins: ${{ secrets.TF_VAR_allowed_origins || '' }}

- name: Terraform Output
id: output
Expand All @@ -224,34 +236,6 @@ jobs:
path: ${{ env.TF_WORKING_DIR }}/terraform-outputs.json
retention-days: 90

- name: Post Apply Summary
if: always()
uses: actions/github-script@v7
with:
script: |
const status = '${{ job.status }}' === 'success' ? 'βœ… Success' : '❌ Failed';
const sha = context.sha.substring(0, 7);
const workflowUrl = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`;

const body = [
`## πŸ—οΈ Terraform Apply β€” ${status}`,
'',
`Infrastructure changes applied on commit [\`${sha}\`](${workflowUrl}).`,
'',
'| Step | Status |',
'|------|--------|',
`| Init | ${{ steps.init.outcome }} |`,
`| Plan | ${{ steps.plan.outcome }} |`,
`| Apply | ${{ steps.apply.outcome }} |`,
].join('\n');

await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number || 1,
body,
}).catch(() => {});

# ─── Workflow Dispatch (manual) ───────────────────────────────────────────
manual:
name: Manual Terraform (${{ github.event.inputs.action }})
Expand All @@ -272,52 +256,47 @@ jobs:
with:
terraform_version: ${{ env.TF_VERSION }}

- name: Terraform Init
run: terraform init
env:
DIGITALOCEAN_TOKEN: ${{ secrets.DIGITALOCEAN_TOKEN }}
AWS_ACCESS_KEY_ID: ${{ secrets.DIGITALOCEAN_SPACES_KEY || '' }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET || '' }}
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
role-session-name: terraform-manual
aws-region: ${{ env.AWS_REGION }}

- name: Terraform Init (remote state)
run: |
terraform init \
-input=false \
-backend-config="bucket=${{ secrets.TF_STATE_BUCKET }}" \
-backend-config="key=finchippay/${{ github.event.inputs.environment }}/terraform.tfstate" \
-backend-config="region=${{ env.AWS_REGION }}" \
-backend-config="dynamodb_table=${{ secrets.TF_STATE_LOCK_TABLE }}" \
-backend-config="encrypt=true"

- name: Terraform Plan
if: github.event.inputs.action == 'plan'
run: |
terraform plan \
-no-color \
-input=false \
-var="environment=${{ github.event.inputs.environment }}"
env:
DIGITALOCEAN_TOKEN: ${{ secrets.DIGITALOCEAN_TOKEN }}
TF_VAR_do_token: ${{ secrets.DIGITALOCEAN_TOKEN }}
TF_VAR_db_password: ${{ secrets.TF_VAR_db_password || '' }}
TF_VAR_redis_password: ${{ secrets.TF_VAR_redis_password || '' }}
-var-file="environments/${{ github.event.inputs.environment }}/${{ github.event.inputs.environment }}.tfvars"

- name: Terraform Apply
if: github.event.inputs.action == 'apply'
run: |
terraform plan \
-no-color \
-input=false \
-var="environment=${{ github.event.inputs.environment }}" \
-var-file="environments/${{ github.event.inputs.environment }}/${{ github.event.inputs.environment }}.tfvars" \
-out=tfplan
terraform apply -input=false -auto-approve tfplan
env:
DIGITALOCEAN_TOKEN: ${{ secrets.DIGITALOCEAN_TOKEN }}
TF_VAR_do_token: ${{ secrets.DIGITALOCEAN_TOKEN }}
TF_VAR_db_password: ${{ secrets.TF_VAR_db_password || '' }}
TF_VAR_redis_password: ${{ secrets.TF_VAR_redis_password || '' }}

- name: Terraform Destroy
if: github.event.inputs.action == 'destroy'
run: |
terraform plan -destroy \
-no-color \
-input=false \
-var="environment=${{ github.event.inputs.environment }}" \
-var-file="environments/${{ github.event.inputs.environment }}/${{ github.event.inputs.environment }}.tfvars" \
-out=tfdestroy
terraform apply -input=false -auto-approve tfdestroy
env:
DIGITALOCEAN_TOKEN: ${{ secrets.DIGITALOCEAN_TOKEN }}
TF_VAR_do_token: ${{ secrets.DIGITALOCEAN_TOKEN }}
TF_VAR_db_password: ${{ secrets.TF_VAR_db_password || '' }}
TF_VAR_redis_password: ${{ secrets.TF_VAR_redis_password || '' }}
terraform apply -input=false -auto-approve tfdestroy
29 changes: 14 additions & 15 deletions CI_CD.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ Comprehensive reference for every automated workflow in the Finchippay-Solution
| 10 | **Benchmark** | `benchmark.yml` | PR β†’ main, manual | Performance regression detection |
| 11 | **SDK Check** | `sdk-check.yml` | PR β†’ main, push main | SDK type drift detection |
| 12 | **Renovate** | `renovate-approve.yml` | PR from renovate/dependabot | Auto-approve dependency patches |
| 13 | **Terraform** | `terraform-deploy.yml` | PR (terraform/**), push main, manual | Plan β†’ PR comment β†’ Apply (staging/production) |
| 13 | **Terraform** | `terraform.yml` | PR (terraform/**), push main, manual | fmt β†’ validate β†’ Plan β†’ PR comment β†’ Apply (dev/staging/prod, prod gated) |
| 14 | **Kubernetes** | `kubernetes-deploy.yml` | PR (k8s/**), push main, manual | Build 3 images β†’ Scan β†’ Push β†’ Helm deploy |
| 15 | **Greptile** | `greptile-review.yml` | PR opened/reopened, issue opened, manual | Auto-label PRs, post review context |

Expand Down Expand Up @@ -210,20 +210,20 @@ Generate image SBOMs β†’ Upload artifacts
- Supports frontend, backend, or both targets
- Posts PR comment with final status

#### 13. Terraform Deploy (`terraform-deploy.yml`)
#### 13. Terraform (`terraform.yml`)

**Triggers:** `pull_request` (terraform/** paths), `push` β†’ main, `workflow_dispatch`

| Trigger | Action | Environment |
|---------|--------|-------------|
| PR | `terraform fmt β†’ init β†’ validate β†’ plan` β†’ post as PR comment | staging |
| Push to main | `terraform init β†’ plan β†’ apply -auto-approve` | production |
| Manual β€” plan | `terraform plan` only | staging or production |
| Manual β€” apply | `terraform plan β†’ apply -auto-approve` | staging or production |
| Manual β€” destroy | `terraform plan -destroy β†’ apply` | staging or production |
| PR | `terraform fmt β†’ init β†’ validate β†’ plan` β†’ post as PR comment | dev |
| Push to main | `terraform init β†’ plan β†’ apply -auto-approve` | prod |
| Manual β€” plan | `terraform plan` only | dev, staging, or prod |
| Manual β€” apply | `terraform plan β†’ apply -auto-approve` | dev, staging, or prod |
| Manual β€” destroy | `terraform plan -destroy β†’ apply` | dev, staging, or prod |

**Required secrets:** `DIGITALOCEAN_TOKEN`, `DIGITALOCEAN_SPACES_KEY`, `DIGITALOCEAN_SPACES_SECRET`
**Optional secrets:** `TF_VAR_db_password`, `TF_VAR_redis_password`
**Required secrets:** `AWS_ROLE_ARN` (OIDC role), `TF_STATE_BUCKET`, `TF_STATE_LOCK_TABLE`
**Optional secrets:** `TF_VAR_allowed_origins`

**Safety:** Production apply requires the `production` GitHub Environment approval gate. Plan artifacts are uploaded and retained for 7 days.

Expand Down Expand Up @@ -352,11 +352,10 @@ Upload SBOM bundle artifact (retained 365 days)
| `VERCEL_ORG_ID` | `vercel-deploy` | βœ… | Vercel organization ID |
| `VERCEL_PROJECT_ID` | `vercel-deploy` | βœ… | Vercel project ID |
| `NEXT_PUBLIC_SENTRY_DSN` | `vercel-deploy` | ❌ | Sentry DSN for frontend error tracking |
| `DIGITALOCEAN_TOKEN` | `terraform-deploy` | βœ… | DigitalOcean API token |
| `DIGITALOCEAN_SPACES_KEY` | `terraform-deploy` | βœ… | DO Spaces access key (Terraform remote state) |
| `DIGITALOCEAN_SPACES_SECRET` | `terraform-deploy` | βœ… | DO Spaces secret key (Terraform remote state) |
| `TF_VAR_db_password` | `terraform-deploy` | ❌ | Override auto-generated DB password |
| `TF_VAR_redis_password` | `terraform-deploy` | ❌ | Override auto-generated Redis password |
| `AWS_ROLE_ARN` | `terraform` | βœ… | IAM role to assume via OIDC for Terraform plan/apply |
| `TF_STATE_BUCKET` | `terraform` | βœ… | S3 bucket holding Terraform state |
| `TF_STATE_LOCK_TABLE` | `terraform` | βœ… | DynamoDB table for Terraform state locking |
| `TF_VAR_allowed_origins` | `terraform` | ❌ | CORS origins for the deployed app |
| `KUBE_CONFIG` | `kubernetes-deploy` | βœ… | Base64-encoded kubeconfig |
| `JWT_SECRET` | `kubernetes-deploy` | ❌ | JWT signing secret for backend |
| `DB_URL` | `kubernetes-deploy` | ❌ | Database connection string |
Expand All @@ -375,7 +374,7 @@ Upload SBOM bundle artifact (retained 365 days)

| Environment | Required Approvers | Used By |
|-------------|-------------------|---------|
| `production` | Manual approval gate | `vercel-deploy`, `terraform-deploy`, `kubernetes-deploy`, `canary-deploy` |
| `production` | Manual approval gate | `vercel-deploy`, `terraform`, `kubernetes-deploy`, `canary-deploy` |
| `preview` | None | `vercel-deploy` (preview) |
| `testnet` | None | `contract-deploy` |

Expand Down
Loading
Loading