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
13 changes: 13 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,19 @@ override.tf.json
# Ignore transient lock info files created by terraform apply
.terraform.tfstate.lock.info

# Include override files you do wish to add to version control using negated pattern
# !example_override.tf

# Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan
# example: *tfplan*

# Ignore CLI configuration files
.terraformrc
terraform.rc

# Optional: ignore graph output files generated by `terraform graph`
# *.dot

# Optional: ignore plan files saved before destroying Terraform configuration
# Uncomment the line below if you want to ignore planout files.
# planout
73 changes: 1 addition & 72 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,73 +1,2 @@
# infrastructure

Stack Duel Infrastructure

## Layout

```
terraform/
modules/
app-platform/ Shared module: resource group(s), Container App (Linux
container, websockets work natively), Azure SQL
Database (serverless, Always Free limit), storage
account, static web app client, Log Analytics + App
Insights, subscription budget, Clerk auth resources
(redirect URLs, allowlist, JWT template, Svix).
environments/
scrum/ Non-prod/dev environment. Container App scaled to
zero by default, Azure SQL serverless on the Always
Free limit.
```

Everything in the `app-platform` module targets Azure's Always Free tier
(Container Apps consumption grant, Azure SQL serverless free limit, Static
Web Apps Free plan, Blob Storage). There is no 12-month-free or trial-credit
resource anywhere in this config, so it runs the same way on a Pay-As-You-Go
subscription.

## Setup

To run this repository you need to first run `az login`.

Create a `secret.tfvars` file in the environment directory you're working in
and fill in secret values (see that environment's `variables.tf` for the
full list). `*.tfvars` is gitignored — never commit one.

### Scrum

```bash
cd terraform/environments/scrum
terraform init
terraform plan -var-file="secret.tfvars"
terraform apply -var-file="secret.tfvars"
```

## Notes

- The container app's `image` is set to a placeholder quickstart image and
then ignored (`lifecycle.ignore_changes`) — real deploys update the
running image via CI (`az containerapp update`), not via Terraform.
- SignalR runs self-hosted inside the API container, not via Azure SignalR
Service — keep `container_max_replicas = 1` until a Redis/SignalR Service
backplane is added, since a self-hosted hub can't broadcast across
replicas on its own.
- The SQL database is enrolled in Azure's Always Free limit
(`useFreeLimit = true`, `freeLimitExhaustionBehavior = "AutoPause"`) via
an `azapi_update_resource` patch, since the azurerm provider (checked up
to 4.81.0) doesn't expose those two properties itself. The behavior is
set to `AutoPause`, not `BillForUsage` — if the monthly free vCore-second
allowance is exhausted the database pauses instead of silently billing.
Only one free database is allowed per subscription per region, so don't
reuse `enable_sql = true` for a second environment in the same region.
- Clerk resources come from the unofficial `buildwithdeck/clerk` provider
(not published by Clerk itself — audit it before relying on it for prod).
`clerk_redirect_url` entries point at the **Next.js** static web app
(`/sso-callback`), since that's where the browser lands after OAuth —
your .NET API is never involved in that flow. `clerk_svix_webhook` only
turns on the Svix integration; it can't create the actual endpoint, so
after `apply`, open the Clerk Dashboard's Webhooks page once and add an
endpoint using the `clerk_webhook_endpoint_url` output — that one *does*
point at the .NET container app, and your API needs a route there
(`POST /webhooks/clerk`) that verifies the Svix signature.
- `clerk_allowlist_identifiers` is empty by default (open sign-ups). Add
your own email(s) there to lock scrum down to invite-only while testing.
Infrastructure for Stack Duel
22 changes: 22 additions & 0 deletions terraform/environments/prod/.terraform.lock.hcl

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

40 changes: 40 additions & 0 deletions terraform/environments/prod/ci-migration-identity.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
data "azurerm_client_config" "current" {}

resource "azurerm_user_assigned_identity" "github_migrator" {
name = "id-gh-migrator-prod-centralus-01"
resource_group_name = module.prod.resource_group_names["centralus"]
location = module.prod.resource_group_locations["centralus"]
}

resource "azurerm_federated_identity_credential" "github_migrator_production" {
name = "gh-server-production"
parent_id = azurerm_user_assigned_identity.github_migrator.id
audience = ["api://AzureADTokenExchange"]
issuer = "https://token.actions.githubusercontent.com"
subject = "repo:Stack-Duel/server:environment:production"
}

resource "azurerm_role_definition" "postgres_firewall_manager" {
name = "Postgres Flexible Server Firewall Manager"
scope = azurerm_postgresql_flexible_server.prod.id
description = "Allows adding and removing firewall rules on the prod Postgres flexible server only. Used by CI to open a rule for the duration of a migration/seed job."

permissions {
actions = [
"Microsoft.DBforPostgreSQL/flexibleServers/firewallRules/read",
"Microsoft.DBforPostgreSQL/flexibleServers/firewallRules/write",
"Microsoft.DBforPostgreSQL/flexibleServers/firewallRules/delete",
]
not_actions = []
}

assignable_scopes = [
azurerm_postgresql_flexible_server.prod.id,
]
}

resource "azurerm_role_assignment" "github_migrator_firewall" {
scope = azurerm_postgresql_flexible_server.prod.id
role_definition_id = azurerm_role_definition.postgres_firewall_manager.role_definition_resource_id
principal_id = azurerm_user_assigned_identity.github_migrator.principal_id
}
58 changes: 58 additions & 0 deletions terraform/environments/prod/database.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
resource "azurerm_postgresql_flexible_server" "prod" {
name = "psql-sdapi-prod-centralus-01"
resource_group_name = module.prod.resource_group_names["centralus"]
location = module.prod.resource_group_locations["centralus"]

version = "16"
administrator_login = var.postgres_admin_username
administrator_password = var.postgres_admin_password

storage_mb = 32768
sku_name = "B_Standard_B1ms"
zone = var.postgres_zone

backup_retention_days = 7
geo_redundant_backup_enabled = false

public_network_access_enabled = true
}

resource "azurerm_postgresql_flexible_server_database" "stackduel" {
name = "stackduel"
server_id = azurerm_postgresql_flexible_server.prod.id
collation = "en_US.utf8"
charset = "UTF8"
}

resource "azurerm_postgresql_flexible_server_firewall_rule" "app_outbound" {
for_each = toset(module.prod.web_app_possible_outbound_ips)

name = "allow-app-${replace(each.value, ".", "-")}"
server_id = azurerm_postgresql_flexible_server.prod.id
start_ip_address = each.value
end_ip_address = each.value
}

resource "azurerm_postgresql_flexible_server_firewall_rule" "migration_client" {
count = local.migration_client_ip_set ? 1 : 0

name = "allow-migration-client"
server_id = azurerm_postgresql_flexible_server.prod.id
start_ip_address = var.migration_client_ip
end_ip_address = var.migration_client_ip
}

resource "azurerm_postgresql_flexible_server_firewall_rule" "developer_client" {
count = local.developer_client_ip_set ? 1 : 0

name = "allow-developer-client"
server_id = azurerm_postgresql_flexible_server.prod.id
start_ip_address = var.developer_client_ip
end_ip_address = var.developer_client_ip
}

locals {
migration_client_ip_set = var.migration_client_ip != null && var.migration_client_ip != ""
developer_client_ip_set = var.developer_client_ip != null && var.developer_client_ip != ""
db_connection_string = "Host=${azurerm_postgresql_flexible_server.prod.fqdn};Port=5432;Database=${azurerm_postgresql_flexible_server_database.stackduel.name};Username=${var.postgres_admin_username};Password=${var.postgres_admin_password};SSL Mode=Require;Trust Server Certificate=true"
}
32 changes: 32 additions & 0 deletions terraform/environments/prod/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
module "prod" {
source = "../../modules/app-platform"

environment_key = "prod"
region_configurations = var.region_configurations
primary_region_key = var.primary_region_key
client_region_key = var.client_region_key

compute_type = "app_service"
os_type = "Linux"
sku_name = "B1"
always_on = true
compute_name_suffix = var.compute_name_suffix
resource_name_suffix = "01"
service_bus_name_suffix = "02"

enable_app_insights = true
enable_service_bus = true
service_bus_sku = "Basic"

enable_avatar_storage = true

enable_budget = true
enable_alerts = true
subscription_id = var.subscription_id
budget_name = "monthly-budget"
budget_amount = var.budget_amount
budget_start_date = var.budget_start_date
budget_end_date = var.budget_end_date
budget_thresholds = [50, 80, 100]
contact_emails = var.contact_emails
}
34 changes: 34 additions & 0 deletions terraform/environments/prod/outputs.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
output "db_connection_string" {
value = local.db_connection_string
sensitive = true
}

output "app_insights_connection_string" {
value = module.prod.app_insights_connection_string
sensitive = true
}

output "service_bus_connection_string" {
value = module.prod.service_bus_connection_string
sensitive = true
}

output "web_app_default_hostname" {
value = module.prod.web_app_default_hostname
}

output "github_migrator_client_id" {
value = azurerm_user_assigned_identity.github_migrator.client_id
}

output "github_migrator_tenant_id" {
value = azurerm_user_assigned_identity.github_migrator.tenant_id
}

output "postgres_server_name" {
value = azurerm_postgresql_flexible_server.prod.name
}

output "postgres_resource_group" {
value = module.prod.resource_group_names["centralus"]
}
3 changes: 3 additions & 0 deletions terraform/environments/prod/provider.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
provider "azurerm" {
features {}
}
70 changes: 70 additions & 0 deletions terraform/environments/prod/variables.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
variable "region_configurations" {
type = list(object({
key = string
location = string
resource_group = string
}))
}

variable "primary_region_key" {
type = string
default = "centralus"
}

variable "client_region_key" {
type = string
default = "eastus2"
}

variable "subscription_id" {
type = string
sensitive = true
}

variable "contact_emails" {
type = list(string)
sensitive = true
}

variable "budget_amount" {
type = number
default = 5
}

variable "budget_start_date" {
type = string
}

variable "budget_end_date" {
type = string
}

variable "postgres_admin_username" {
type = string
sensitive = true
}

variable "postgres_admin_password" {
type = string
sensitive = true
}

variable "compute_name_suffix" {
type = string
default = "02"
}

variable "migration_client_ip" {
type = string
default = null
}

variable "developer_client_ip" {
type = string
sensitive = true
default = null
}

variable "postgres_zone" {
type = string
}
16 changes: 16 additions & 0 deletions terraform/environments/prod/versions.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
terraform {
cloud {
organization = "stack-duel"
workspaces {
name = "Prod"
}
}

required_version = ">= 1.14.3"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.61.0"
}
}
}
Loading
Loading