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
61 changes: 61 additions & 0 deletions .github/workflows/deploy-frontend.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: Deploy Frontend

on:
push:
branches: [main]
paths:
- "frontend/**"
- "infra/terraform/**"
- ".github/workflows/deploy-frontend.yml"

jobs:
deploy:
name: Build & Deploy to S3 + CloudFront
runs-on: ubuntu-latest
environment: production
permissions:
id-token: write
contents: read

steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: frontend/package-lock.json

- name: Install dependencies
run: npm ci
working-directory: frontend

- name: Build frontend
env:
VITE_GOVERNANCE_CONTRACT_ID: ${{ vars.GOVERNANCE_CONTRACT_ID }}
VITE_TOKEN_CONTRACT_ID: ${{ vars.TOKEN_CONTRACT_ID }}
VITE_STELLAR_NETWORK: ${{ vars.STELLAR_NETWORK || 'mainnet' }}
VITE_STELLAR_RPC_URL: ${{ vars.STELLAR_RPC_URL }}
run: npm run build
working-directory: frontend

- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
aws-region: us-east-1

- name: Sync to S3
run: |
aws s3 sync frontend/dist/ s3://${{ secrets.S3_BUCKET }} \
--delete \
--cache-control "public,max-age=31536000,immutable" \
--exclude "index.html"
aws s3 cp frontend/dist/index.html s3://${{ secrets.S3_BUCKET }}/index.html \
--cache-control "no-cache,no-store,must-revalidate"

- name: Invalidate CloudFront cache
run: |
aws cloudfront create-invalidation \
--distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} \
--paths "/*"
75 changes: 75 additions & 0 deletions docs/frontend-deployment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Frontend Deployment (S3 + CloudFront)

The frontend is deployed to AWS S3 + CloudFront via Terraform and GitHub Actions.

## Infrastructure

Terraform configuration lives in `infra/terraform/`.

| File | Purpose |
|------|---------|
| `main.tf` | S3 bucket, CloudFront distribution, OAC |
| `variables.tf` | Input variables |
| `outputs.tf` | Bucket name, CloudFront domain and ID |
| `envs/staging.tfvars` | Staging environment values |
| `envs/production.tfvars` | Production environment values |

## First-time Setup

```bash
cd infra/terraform

# Staging
terraform init
terraform apply -var-file=envs/staging.tfvars

# Production
terraform apply -var-file=envs/production.tfvars
```

Record the outputs and add them as GitHub secrets/variables:

| Output | GitHub secret/variable |
|--------|----------------------|
| `s3_bucket` | `S3_BUCKET` |
| `cloudfront_distribution_id` | `CLOUDFRONT_DISTRIBUTION_ID` |

Also set:
- `AWS_DEPLOY_ROLE_ARN` — IAM role ARN with S3 write + CloudFront invalidation permissions
- `GOVERNANCE_CONTRACT_ID` (variable) — deployed governance contract ID
- `TOKEN_CONTRACT_ID` (variable) — deployed token contract ID
- `STELLAR_RPC_URL` (variable) — RPC endpoint

## Environment Config Injection

Vite reads environment variables prefixed with `VITE_` at build time. The CI workflow injects:

```
VITE_GOVERNANCE_CONTRACT_ID
VITE_TOKEN_CONTRACT_ID
VITE_STELLAR_NETWORK
VITE_STELLAR_RPC_URL
```

Use them in the frontend via `import.meta.env.VITE_*`.

## CI/CD

The `.github/workflows/deploy-frontend.yml` workflow triggers on pushes to `main` that touch `frontend/` or `infra/terraform/`. It:

1. Builds the frontend with environment-specific config
2. Syncs the `dist/` output to S3 (immutable cache for assets, no-cache for `index.html`)
3. Invalidates the CloudFront distribution

## Manual Deploy

```bash
cd frontend
VITE_GOVERNANCE_CONTRACT_ID=<id> \
VITE_TOKEN_CONTRACT_ID=<id> \
VITE_STELLAR_NETWORK=mainnet \
npm run build

aws s3 sync dist/ s3://<bucket> --delete
aws cloudfront create-invalidation --distribution-id <id> --paths "/*"
```
2 changes: 2 additions & 0 deletions infra/terraform/envs/production.tfvars
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
environment = "production"
aws_region = "us-east-1"
2 changes: 2 additions & 0 deletions infra/terraform/envs/staging.tfvars
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
environment = "staging"
aws_region = "us-east-1"
117 changes: 117 additions & 0 deletions infra/terraform/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}

provider "aws" {
region = var.aws_region
}

# CloudFront requires ACM certs in us-east-1
provider "aws" {
alias = "us_east_1"
region = "us-east-1"
}

locals {
bucket_name = "cosmosvote-frontend-${var.environment}"
origin_id = "cosmosvote-s3-${var.environment}"
}

# ─── S3 bucket ────────────────────────────────────────────────────────────────
resource "aws_s3_bucket" "frontend" {
bucket = local.bucket_name
force_destroy = var.environment != "production"
}

resource "aws_s3_bucket_public_access_block" "frontend" {
bucket = aws_s3_bucket.frontend.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}

resource "aws_s3_bucket_versioning" "frontend" {
bucket = aws_s3_bucket.frontend.id
versioning_configuration { status = "Enabled" }
}

# ─── CloudFront OAC ───────────────────────────────────────────────────────────
resource "aws_cloudfront_origin_access_control" "frontend" {
name = local.origin_id
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}

resource "aws_s3_bucket_policy" "frontend" {
bucket = aws_s3_bucket.frontend.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "AllowCloudFront"
Effect = "Allow"
Principal = { Service = "cloudfront.amazonaws.com" }
Action = "s3:GetObject"
Resource = "${aws_s3_bucket.frontend.arn}/*"
Condition = {
StringEquals = {
"AWS:SourceArn" = aws_cloudfront_distribution.frontend.arn
}
}
}]
})
}

# ─── CloudFront distribution ──────────────────────────────────────────────────
resource "aws_cloudfront_distribution" "frontend" {
enabled = true
default_root_object = "index.html"
price_class = "PriceClass_100"
comment = "CosmosVote frontend (${var.environment})"

origin {
domain_name = aws_s3_bucket.frontend.bucket_regional_domain_name
origin_id = local.origin_id
origin_access_control_id = aws_cloudfront_origin_access_control.frontend.id
}

default_cache_behavior {
target_origin_id = local.origin_id
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
compress = true

forwarded_values {
query_string = false
cookies { forward = "none" }
}
}

# SPA fallback: return index.html for 403/404
custom_error_response {
error_code = 403
response_code = 200
response_page_path = "/index.html"
}
custom_error_response {
error_code = 404
response_code = 200
response_page_path = "/index.html"
}

restrictions {
geo_restriction { restriction_type = "none" }
}

viewer_certificate {
cloudfront_default_certificate = true
}
}
14 changes: 14 additions & 0 deletions infra/terraform/outputs.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
output "cloudfront_domain" {
description = "CloudFront distribution domain name"
value = aws_cloudfront_distribution.frontend.domain_name
}

output "s3_bucket" {
description = "S3 bucket name"
value = aws_s3_bucket.frontend.id
}

output "cloudfront_distribution_id" {
description = "CloudFront distribution ID (needed for cache invalidation)"
value = aws_cloudfront_distribution.frontend.id
}
14 changes: 14 additions & 0 deletions infra/terraform/variables.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
variable "aws_region" {
description = "AWS region for S3 bucket"
type = string
default = "us-east-1"
}

variable "environment" {
description = "Deployment environment: staging or production"
type = string
validation {
condition = contains(["staging", "production"], var.environment)
error_message = "environment must be staging or production"
}
}