├── .vscode ├── extensions.json └── settings.json ├── .editorconfig ├── core ├── variables.tf ├── main.tf ├── outputs.tf ├── backup.tf ├── backup-truenas.tf ├── terraform-backend.tf └── cloudflare.tf ├── default.auto.tfvars ├── variables.tf ├── main.tf ├── outputs.tf ├── LICENSE ├── .gitignore ├── map-kv-to-env-vars.ps1 ├── Get-VmReservationQuotes.ps1 ├── .terraform.lock.hcl └── README.md /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "davidanson.vscode-markdownlint", 4 | "editorconfig.editorconfig", 5 | "esbenp.prettier-vscode", 6 | "hashicorp.terraform" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | 10 | [*.md] 11 | max_line_length = off 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /core/variables.tf: -------------------------------------------------------------------------------- 1 | variable "location" { 2 | type = string 3 | } 4 | 5 | variable "aks_location" { 6 | type = string 7 | } 8 | 9 | variable "tags" { 10 | type = map(string) 11 | } 12 | 13 | variable "cloudflare_account_id" { 14 | type = string 15 | sensitive = true 16 | } 17 | -------------------------------------------------------------------------------- /default.auto.tfvars: -------------------------------------------------------------------------------- 1 | location = "Switzerland North" 2 | aks_location = "East US" 3 | 4 | tags = { 5 | "Environment" = "Production" 6 | "Management Framework" = "Terraform" 7 | "Project" = "infrastructure" 8 | } 9 | 10 | cloudflare_account_id = "8f84a121132c24150e658805048b53a8" 11 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "editor.formatOnSave": true, 4 | "[terraform]": { 5 | "editor.defaultFormatter": "hashicorp.terraform", 6 | "editor.formatOnSaveMode": "file" 7 | }, 8 | "[terraform-vars]": { 9 | "editor.defaultFormatter": "hashicorp.terraform", 10 | "editor.formatOnSaveMode": "file" 11 | }, 12 | "terraform.experimentalFeatures.validateOnSave": true 13 | } 14 | -------------------------------------------------------------------------------- /core/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | azurerm = { 4 | source = "azurerm" 5 | version = ">= 3.47.0" 6 | } 7 | 8 | cloudflare = { 9 | source = "cloudflare/cloudflare" 10 | version = ">= 4.1.0" 11 | } 12 | 13 | random = { 14 | source = "random" 15 | version = ">= 3.4.3" 16 | } 17 | } 18 | } 19 | 20 | data "azurerm_subscription" "subscription" {} 21 | 22 | resource "random_id" "default" { 23 | byte_length = 1 24 | } 25 | -------------------------------------------------------------------------------- /variables.tf: -------------------------------------------------------------------------------- 1 | variable "location" { 2 | type = string 3 | description = "Azure region where resources will be deployed." 4 | } 5 | 6 | variable "aks_location" { 7 | type = string 8 | description = "Azure region where AKS will be deployed." 9 | } 10 | 11 | variable "tags" { 12 | type = map(string) 13 | description = "Default Azure tags applied to any resource." 14 | } 15 | 16 | variable "cloudflare_account_id" { 17 | type = string 18 | description = "Account ID to manage the zone resource in." 19 | sensitive = true 20 | } 21 | -------------------------------------------------------------------------------- /core/outputs.tf: -------------------------------------------------------------------------------- 1 | output "backup_truenas_account_name" { 2 | value = azurerm_storage_account.backup_truenas.name 3 | sensitive = true 4 | } 5 | 6 | output "backup_truenas_account_key" { 7 | value = azurerm_storage_account.backup_truenas.secondary_access_key 8 | sensitive = true 9 | } 10 | 11 | output "cloudflare_schnerring_net_zone_id" { 12 | value = cloudflare_zone.schnerring_net.id 13 | } 14 | 15 | output "schnerring_net_dns_servers" { 16 | value = cloudflare_zone.schnerring_net.name_servers 17 | } 18 | 19 | output "schnerring_app_dns_servers" { 20 | value = cloudflare_zone.schnerring_app.name_servers 21 | } 22 | 23 | output "sensingskies_org_dns_servers" { 24 | value = cloudflare_zone.sensingskies_org.name_servers 25 | } 26 | -------------------------------------------------------------------------------- /main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = ">= 1.4.0" 3 | 4 | required_providers { 5 | azurerm = { 6 | source = "azurerm" 7 | version = "~> 3.90" 8 | } 9 | 10 | cloudflare = { 11 | source = "cloudflare/cloudflare" 12 | version = "~> 4.23" 13 | } 14 | 15 | random = { 16 | source = "random" 17 | version = "~> 3.6" 18 | } 19 | } 20 | 21 | backend "azurerm" {} 22 | } 23 | 24 | # Providers 25 | 26 | provider "azurerm" { 27 | features { 28 | key_vault { 29 | purge_soft_delete_on_destroy = true 30 | } 31 | } 32 | } 33 | 34 | provider "cloudflare" {} 35 | 36 | # Modules 37 | 38 | module "core" { 39 | source = "./core" 40 | 41 | location = var.location 42 | aks_location = var.aks_location 43 | tags = var.tags 44 | 45 | cloudflare_account_id = var.cloudflare_account_id 46 | } 47 | -------------------------------------------------------------------------------- /outputs.tf: -------------------------------------------------------------------------------- 1 | output "backup_truenas_account_name" { 2 | value = module.core.backup_truenas_account_name 3 | description = "TrueNAS cloud credential account name." 4 | sensitive = true 5 | } 6 | 7 | output "backup_truenas_account_key" { 8 | value = module.core.backup_truenas_account_key 9 | description = "TrueNAS cloud credential account key." 10 | sensitive = true 11 | } 12 | 13 | output "schnerring_net_dns_servers" { 14 | value = module.core.schnerring_net_dns_servers 15 | description = "Cloudflare-assigned schnerring.net DNS servers." 16 | } 17 | 18 | output "schnerring_app_dns_servers" { 19 | value = module.core.schnerring_app_dns_servers 20 | description = "Cloudflare-assigned schnerring.app DNS servers." 21 | } 22 | 23 | output "sensingskies_org_dns_servers" { 24 | value = module.core.sensingskies_org_dns_servers 25 | description = "Cloudflare-assigned sensingskies.org DNS servers." 26 | } 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Michael Schnerring 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | VmReservationQuotes.csv 2 | *.tfplan 3 | 4 | # Created by https://www.toptal.com/developers/gitignore/api/vscode,terraform 5 | # Edit at https://www.toptal.com/developers/gitignore?templates=vscode,terraform 6 | 7 | ### Terraform ### 8 | # Local .terraform directories 9 | **/.terraform/* 10 | 11 | # .tfstate files 12 | *.tfstate 13 | *.tfstate.* 14 | 15 | # Crash log files 16 | crash.log 17 | 18 | # Ignore any .tfvars files that are generated automatically for each Terraform run. Most 19 | # .tfvars files are managed as part of configuration and so should be included in 20 | # version control. 21 | # 22 | # example.tfvars 23 | 24 | # Ignore override files as they are usually used to override resources locally and so 25 | # are not checked in 26 | override.tf 27 | override.tf.json 28 | *_override.tf 29 | *_override.tf.json 30 | 31 | # Include override files you do wish to add to version control using negated pattern 32 | # !example_override.tf 33 | 34 | # Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan 35 | # example: *tfplan* 36 | 37 | ### vscode ### 38 | .vscode/* 39 | !.vscode/settings.json 40 | !.vscode/tasks.json 41 | !.vscode/launch.json 42 | !.vscode/extensions.json 43 | *.code-workspace 44 | 45 | # End of https://www.toptal.com/developers/gitignore/api/vscode,terraform 46 | -------------------------------------------------------------------------------- /map-kv-to-env-vars.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .SYNOPSIS 3 | Get all secrets from Azure Key Vault and map them to PowerShell environment 4 | variables. 5 | .PARAMETER KeyVault 6 | Name of the Azure Key Vault. 7 | .PARAMETER Subscription 8 | Name of the Azure subscription. If omitted, the default subscription is 9 | selected. 10 | #> 11 | param ( 12 | [Parameter(Mandatory, HelpMessage="Name of the Azure Key Vault")] 13 | [string] 14 | $KeyVault, 15 | 16 | [Parameter(HelpMessage="Name of the Azure subscription")] 17 | [string] 18 | $Subscription 19 | ) 20 | 21 | $ErrorActionPreference = "Stop" 22 | 23 | Write-Information "Logging into Azure ..." 24 | if ($Subscription) { 25 | Connect-AzAccount -Subscription $Subscription 26 | } else { 27 | Connect-AzAccount 28 | } 29 | 30 | Write-Information "Geting secret list from Key Vault: $KeyVault ..." 31 | $secrets = Get-AzKeyVaultSecret -VaultName $KeyVault -Name "*" 32 | 33 | $i = 1; 34 | foreach ($secret in $secrets) { 35 | $percentComplete = $i/$secrets.Count*100 36 | $envVarName = $secret.Name.Replace("-", "_") 37 | Write-Progress "Mapping secrets to environment variables ..." -Status $envVarName -PercentComplete $percentComplete 38 | $secretValuePlain = Get-AzKeyVaultSecret -VaultName $KeyVault -Name $secret.Name -AsPlainText 39 | Set-Item -Path env:$envVarName -Value $secretValuePlain 40 | $i++ 41 | } 42 | -------------------------------------------------------------------------------- /core/backup.tf: -------------------------------------------------------------------------------- 1 | resource "azurerm_resource_group" "backup" { 2 | name = "backup-rg" 3 | location = var.location 4 | } 5 | 6 | resource "azurerm_data_protection_backup_vault" "backup" { 7 | name = "backup-bv" 8 | resource_group_name = azurerm_resource_group.backup.name 9 | location = azurerm_resource_group.backup.location 10 | 11 | datastore_type = "VaultStore" 12 | redundancy = "LocallyRedundant" 13 | 14 | identity { 15 | type = "SystemAssigned" 16 | } 17 | } 18 | 19 | resource "azurerm_data_protection_backup_policy_blob_storage" "p30d" { 20 | name = "blob-30d-bp" 21 | vault_id = azurerm_data_protection_backup_vault.backup.id 22 | retention_duration = "P30D" # ISO 8601 - 30 day duration 23 | } 24 | 25 | resource "azurerm_role_assignment" "terraform_infrastructure_core" { 26 | scope = azurerm_storage_account.terraform_infrastructure_core.id 27 | role_definition_name = "Storage Account Backup Contributor" 28 | principal_id = azurerm_data_protection_backup_vault.backup.identity[0].principal_id 29 | } 30 | 31 | resource "azurerm_data_protection_backup_instance_blob_storage" "terraform_infrastructure_core" { 32 | name = "terraform-infrastructure-core-bb" 33 | location = azurerm_resource_group.backup.location 34 | 35 | vault_id = azurerm_data_protection_backup_vault.backup.id 36 | backup_policy_id = azurerm_data_protection_backup_policy_blob_storage.p30d.id 37 | storage_account_id = azurerm_storage_account.terraform_infrastructure_core.id 38 | 39 | depends_on = [azurerm_role_assignment.terraform_infrastructure_core] 40 | } 41 | -------------------------------------------------------------------------------- /core/backup-truenas.tf: -------------------------------------------------------------------------------- 1 | # TrueNAS backup resources 2 | 3 | resource "azurerm_resource_group" "backup_truenas" { 4 | name = "truenas-backup-rg" 5 | location = var.location 6 | tags = var.tags 7 | } 8 | 9 | resource "azurerm_storage_account" "backup_truenas" { 10 | name = "truenasbackupst${random_id.default.dec}" 11 | resource_group_name = azurerm_resource_group.backup_truenas.name 12 | location = var.location 13 | tags = var.tags 14 | 15 | account_tier = "Standard" 16 | account_replication_type = "LRS" 17 | } 18 | 19 | resource "azurerm_storage_management_policy" "backup_truenas" { 20 | storage_account_id = azurerm_storage_account.backup_truenas.id 21 | 22 | rule { 23 | name = "rule1" 24 | enabled = true 25 | filters { 26 | blob_types = ["blockBlob"] 27 | } 28 | actions { 29 | base_blob { 30 | tier_to_cool_after_days_since_modification_greater_than = 7 31 | } 32 | snapshot { 33 | delete_after_days_since_creation_greater_than = 30 34 | } 35 | } 36 | } 37 | } 38 | 39 | locals { 40 | backup_datasets = toset([ 41 | "misc", 42 | "arrs", 43 | "backup", 44 | "backup-k8s", 45 | "books", 46 | "documents", 47 | "games", 48 | "home", 49 | "hp-scan", 50 | "obs", 51 | "paperless", 52 | "photoprism", 53 | "pictures", 54 | "syncthing", 55 | "tech", 56 | "test" # TODO can remove? 57 | ]) 58 | } 59 | 60 | resource "azurerm_storage_container" "backup_truenas" { 61 | for_each = local.backup_datasets 62 | name = each.key 63 | storage_account_name = azurerm_storage_account.backup_truenas.name 64 | } 65 | -------------------------------------------------------------------------------- /core/terraform-backend.tf: -------------------------------------------------------------------------------- 1 | # Create storage account and storage container to store Terraform state 2 | 3 | resource "azurerm_resource_group" "terraform" { 4 | name = "terraform-rg" 5 | location = var.location 6 | tags = var.tags 7 | } 8 | 9 | resource "azurerm_storage_account" "terraform_infrastructure_core" { 10 | name = "tfinfracorest${random_id.default.dec}" 11 | resource_group_name = azurerm_resource_group.terraform.name 12 | location = var.location 13 | tags = var.tags 14 | 15 | account_tier = "Standard" 16 | account_replication_type = "LRS" 17 | } 18 | 19 | resource "azurerm_storage_container" "terraform_infrastructure_core" { 20 | name = "terraform-backend" 21 | storage_account_name = azurerm_storage_account.terraform_infrastructure_core.name 22 | } 23 | 24 | # Secret store for infrastructure-core resources 25 | # https://github.com/schnerring/infrastructure-core 26 | # 27 | # To manage Key Vault secrets, the user requires the `Key Vault Administrator` 28 | # and `Key Vault Secrets Officer` roles. 29 | # 30 | # To access Key Vault secrets, the user requires the `Key Vault Secrets User` 31 | # role. 32 | 33 | resource "azurerm_key_vault" "terraform_infrastructure_core" { 34 | name = "tfinfracorekv${random_id.default.dec}" 35 | location = azurerm_resource_group.terraform.location 36 | resource_group_name = azurerm_resource_group.terraform.name 37 | tenant_id = data.azurerm_subscription.subscription.tenant_id 38 | tags = var.tags 39 | 40 | sku_name = "standard" 41 | enable_rbac_authorization = true 42 | 43 | # Many secrets inside this KV are managed manually, e.g., the Matrix Synapse 44 | # signing key. To protect against accidental or malicious deletion of these 45 | # secrets, enforce keeping soft-deleted secrets for the duration of retention 46 | # period. 47 | purge_protection_enabled = true 48 | soft_delete_retention_days = 90 49 | } 50 | -------------------------------------------------------------------------------- /Get-VmReservationQuotes.ps1: -------------------------------------------------------------------------------- 1 | param ( 2 | [Parameter(Mandatory, HelpMessage="Azure subscription ID")] 3 | [string] 4 | $SubscriptionId, 5 | 6 | [Parameter(HelpMessage="Reservation term duration in years (1 or 3)")] 7 | [int] 8 | $TermYears = 3, 9 | 10 | [Parameter(HelpMessage="Azure regions")] 11 | [string[]] 12 | $Locations = @( 13 | "canadaeast", 14 | "canadacentral", 15 | "eastus", 16 | "eastus2", 17 | "northeurope", 18 | "westeurope", 19 | "ukwest", 20 | "uksouth", 21 | "switzerlandnorth", 22 | "germanywestcentral", 23 | "francecentral", 24 | "norwayeast", 25 | "swedencentral" 26 | ), 27 | 28 | [Parameter(HelpMessage="CSV output filename")] 29 | [string] 30 | $OutFile = "VmReservationQuotes.csv" 31 | ) 32 | 33 | function Get-VmLocation ($vmSize) { 34 | return $vmSize.Locations[0].ToLower() 35 | } 36 | 37 | function Test-VmCapability ($vmSize, $capabilityName, $capabilityValue) { 38 | foreach($capability in $vmSize.Capabilities) 39 | { 40 | if ($capability.Name -eq $capabilityName -and $capability.Value -eq $capabilityValue) 41 | { 42 | return $true 43 | } 44 | } 45 | return $false 46 | } 47 | 48 | $vmSizes = @() 49 | 50 | foreach ($vmSize in Get-AzComputeResourceSku) { 51 | # Skip `availibilitySets`, `disks`, etc. 52 | if (-not ($vmSize.ResourceType -eq 'virtualMachines')) { 53 | continue 54 | } 55 | 56 | # Skip unavailable offers 57 | if ($vmSize.Restrictions.Count -gt 0) { 58 | continue 59 | } 60 | 61 | # Filter locations 62 | if (-not $Locations.Contains((Get-VmLocation $vmSize))) { 63 | continue 64 | } 65 | 66 | # Select D-Series VMs 67 | if (-not $vmSize.Name.StartsWith("Standard_D")) { 68 | continue 69 | } 70 | 71 | # Exclude confidential VMs 72 | if ($vmSize.Name.StartsWith("Standard_DC")) { 73 | continue 74 | } 75 | 76 | # Exclude memory-optimized VMs 77 | if ($vmSize.Name.StartsWith("Standard_D11") -or $vmSize.Name.StartsWith("Standard_DS11")) { 78 | continue 79 | } 80 | 81 | if (-not (Test-VmCapability $vmSize "EphemeralOSDiskSupported" "True")) { 82 | continue 83 | } 84 | 85 | if (-not (Test-VmCapability $vmSize "PremiumIO" "True")) { 86 | continue 87 | } 88 | 89 | if (-not (Test-VmCapability $vmSize "vCPUs" "2")) { 90 | continue 91 | } 92 | 93 | $vmSizes += $vmSize 94 | } 95 | 96 | $i = 0 97 | foreach ($vmSize in $vmSizes) { 98 | $location = Get-VmLocation $vmSize 99 | $displayName = "$($vmSize.Name)-$location" 100 | 101 | # Progress Bar 102 | $i++ 103 | $percent = [Math]::Floor(($i / $vmSizes.Count) * 100) 104 | Write-Progress -Activity "Requesting VM quotes" -Status "$percent% $displayName" -PercentComplete $percent 105 | 106 | $quote = Get-AzReservationQuote ` 107 | -ReservedResourceType "VirtualMachines" ` 108 | -Sku $vmSize.Name ` 109 | -Location $location ` 110 | -Term "P${TermYears}Y" ` 111 | -BillingScopeId $SubscriptionId ` 112 | -Quantity 1 ` 113 | -AppliedScopeType Shared ` 114 | -DisplayName "$displayName" 115 | 116 | # BillingCurrencyTotal is a JSON string, e.g.: 117 | # { 118 | # "currencyCode": "CHF", 119 | # "amount": 1130 120 | # } 121 | 122 | # Extract amount value `1130` from JSON 123 | $billingCurrencyTotal = $quote.BillingCurrencyTotal | ConvertFrom-Json 124 | $termPrice = $billingCurrencyTotal.amount 125 | 126 | @{ 127 | Name = $vmSize.Name; 128 | Location = $location; 129 | PricePerMonth = $termPrice / ($TermYears * 12) 130 | } | Export-Csv -Path "${OutFile}" -NoTypeInformation -Delimiter ";" -Append 131 | } 132 | -------------------------------------------------------------------------------- /.terraform.lock.hcl: -------------------------------------------------------------------------------- 1 | # This file is maintained automatically by "terraform init". 2 | # Manual edits may be lost in future updates. 3 | 4 | provider "registry.terraform.io/cloudflare/cloudflare" { 5 | version = "4.23.0" 6 | constraints = ">= 4.1.0, ~> 4.1" 7 | hashes = [ 8 | "h1:uaa+wA2dkyNHUZiHA8OBWr9Az8hXVDBZ8CprVAtW27o=", 9 | "zh:034aae9f29e51b008eb5ff62bcfea4078d92d74fd8eb6e0f1833395002bf483d", 10 | "zh:0e4f72b52647791e34894c231c7d17b55c701fb4ff9d8aeb8355031378b20910", 11 | "zh:248ecf3820a65870a8a811a90488a77a8fcc49ee6e3099734328912250c4145a", 12 | "zh:750114d16fefb3ce6cfc81fc4d86ab3746062dccd3fc5556a6dff39d600d55f3", 13 | "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", 14 | "zh:8fe4b545d8c90eb55b75ede1bc5a6bb1483a00466364cd08b1096abddc52e34b", 15 | "zh:ba203d96d07a313dd77933ff29d09110c1dc5100a44aa540c2c73ea280215c85", 16 | "zh:be22358de9729068edc462985c2c99c4d49eec87c6662e75e7216962b0b47a12", 17 | "zh:c55add4c66855191020b5ed61fe8561403eac9d3f55f343876f1f0a5e2ccf1bc", 18 | "zh:c57034c34a10317715264b9455a74b53b2604a3cb206f2c5089ae61b5e8e18fa", 19 | "zh:c95b026d652cb2f90b526cdc79dc22faa0789a049e55b5f2a41412ac45bca2ec", 20 | "zh:ca49437e5462c060b64d0ebf7a7d1370f55139afdb6a23f032694d363b44243b", 21 | "zh:d52788bd6ca087fa72ae9d22c09693c3f5ce5502a00e2c195bea5f420735006c", 22 | "zh:e43da4d400951310020969bd5952483c05de824d67fdcdddc76ec9d97de0d18e", 23 | "zh:ff150dddcbb0d623ff1948d1359fa956519f0672f832faedb121fc809e9c4c22", 24 | ] 25 | } 26 | 27 | provider "registry.terraform.io/hashicorp/azurerm" { 28 | version = "3.90.0" 29 | constraints = ">= 3.47.0, ~> 3.90" 30 | hashes = [ 31 | "h1:iTDnPxmhsEKFA7fVxsE+aAUeu5x8K4bI275sJAinRe0=", 32 | "zh:194a4342620958403beabf4d57d552133ca6ac18eef3027d6d1a98846b52f8ab", 33 | "zh:1d8ee378aaa793e3288c9328e056763c98d0f2e8560357296bc3446fbd3b1b9d", 34 | "zh:24aba7903e912570e36edb03f79c68028d3e254175947b588c96521f09f89df4", 35 | "zh:27f91fbeef9d04c6382014b6c32883a96dbe91cf7a4fa07a97be5d6b03991f95", 36 | "zh:59eeaa2f50f698bab6f36ada0e865d6b624625ff5d76309334b3c3aa366cb692", 37 | "zh:732af42d18fa222ee88f7f97c0898d4955ae48fde5456e22af3b8f5d324c6b41", 38 | "zh:766034eac5e6a66cf3631580956dd584b1c2e6134167302fc8b95d6b42ebf08b", 39 | "zh:a5b2ec52abfc3fb154047af45ea692c98c646c2b5c336b12b6341a49be95025c", 40 | "zh:bdd72f85d770fa4a2e6ebf542858341d3df7e858a4d70c0f94df758721bcd811", 41 | "zh:e9f15f2399c667c24b3daf8a843f1cadd13bc619becf6362b46c3216b17009b1", 42 | "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", 43 | "zh:f73b1ec8b372bc1480ca0d93e78914f1c9cebe81395f20273d7bc99579b84809", 44 | ] 45 | } 46 | 47 | provider "registry.terraform.io/hashicorp/random" { 48 | version = "3.6.0" 49 | constraints = "~> 3.4, >= 3.4.3" 50 | hashes = [ 51 | "h1:t0mRdJzegohRKhfdoQEJnv3JRISSezJRblN0HIe67vo=", 52 | "zh:03360ed3ecd31e8c5dac9c95fe0858be50f3e9a0d0c654b5e504109c2159287d", 53 | "zh:1c67ac51254ba2a2bb53a25e8ae7e4d076103483f55f39b426ec55e47d1fe211", 54 | "zh:24a17bba7f6d679538ff51b3a2f378cedadede97af8a1db7dad4fd8d6d50f829", 55 | "zh:30ffb297ffd1633175d6545d37c2217e2cef9545a6e03946e514c59c0859b77d", 56 | "zh:454ce4b3dbc73e6775f2f6605d45cee6e16c3872a2e66a2c97993d6e5cbd7055", 57 | "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", 58 | "zh:91df0a9fab329aff2ff4cf26797592eb7a3a90b4a0c04d64ce186654e0cc6e17", 59 | "zh:aa57384b85622a9f7bfb5d4512ca88e61f22a9cea9f30febaa4c98c68ff0dc21", 60 | "zh:c4a3e329ba786ffb6f2b694e1fd41d413a7010f3a53c20b432325a94fa71e839", 61 | "zh:e2699bc9116447f96c53d55f2a00570f982e6f9935038c3810603572693712d0", 62 | "zh:e747c0fd5d7684e5bfad8aa0ca441903f15ae7a98a737ff6aca24ba223207e2c", 63 | "zh:f1ca75f417ce490368f047b63ec09fd003711ae48487fba90b4aba2ccf71920e", 64 | ] 65 | } 66 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # infrastructure 2 | 3 | This project contains the configuration for my cloud infrastructure, for which I use [Terraform](https://www.terraform.io/), an open-source infrastructure-as-code tool. 4 | 5 | You can find additional info about some of the code on my blog: 6 | 7 | - [Use Terraform to Deploy the Remark42 Commenting System to Kubernetes and Integrate it with a Hugo Website](https://schnerring.net/blog/use-terraform-to-deploy-the-remark42-commenting-system-to-kubernetes-and-integrate-it-with-a-hugo-website/) 8 | 9 | ## Local Development 10 | 11 | ### Authentication 12 | 13 | [Use the Azure CLI to authenticate to Azure](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/guides/azure_cli) to interactively run Terraform: 14 | 15 | ```shell 16 | az login 17 | ``` 18 | 19 | For GitHub and Cloudflare, use [personal access tokens (PAT)](https://docs.github.com/en/rest/overview/other-authentication-methods#basic-authentication) and put them into the following environment variables: 20 | 21 | - `GITHUB_TOKEN` with `public_repo` scope 22 | - `CLOUDFLARE_API_TOKEN` with `Zone.Zone` and `Zone.DNS` permissions. 23 | 24 | ### Terraform Input Variables 25 | 26 | Terraform input variables to configure the deployment are defined inside the [variables.tf](./variables.tf) file. 27 | 28 | Use the `tfinfracorekv37` key vault to store sensitive Terraform variable values. It enhances operational security because storing secrets in plaintext files or environment variables can be avoided. The [map-kv-to-env-vars.ps1](./map-kv-to-env-vars.ps1) convenience script maps the `TF-VAR-*` key vault secrets to `TF_VAR_*` environment variables. The mappings are not persisted and are only available within the PowerShell session that executed the script. 29 | 30 | ```powershell 31 | .\map-kv-to-env-vars.ps1 -KeyVault tfinfracorekv37 32 | ``` 33 | 34 | To access the key vault, the user requires the following role assignments: 35 | 36 | - `Key Vault Administrator` and `Key Vault Secrets Officer` roles to manage secrets 37 | - `Key Vault Secrets User` to read secrets 38 | 39 | I like to manage these role assignments with the Azure Portal and not add them to the Terraform state. 40 | 41 | ### Initialize the Terraform Backend 42 | 43 | Initialize the [Terraform azurerm backend](https://www.terraform.io/docs/language/settings/backends/azurerm.html): 44 | 45 | ```shell 46 | terraform init \ 47 | -backend-config="resource_group_name=terraform-rg" \ 48 | -backend-config="storage_account_name=tfinfracorest37" \ 49 | -backend-config="container_name=terraform-backend" \ 50 | -backend-config="key=infrastructure-core.tfstate" 51 | ``` 52 | 53 | ### Deploy 54 | 55 | ```shell 56 | terraform plan -out infrastructure-core.tfplan 57 | terraform apply infrastructure-core.tfplan 58 | ``` 59 | 60 | ### Core 61 | 62 | Core infrastructure. 63 | 64 | | File | Description | 65 | | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | 66 | | [`aks.tf`](./core/aks.tf) | Azure Kubernetes Service (AKS) cluster resources | 67 | | [`backup-truenas.tf`](./core/backup-truenas.tf) | Azure storage account containers used for TrueNAS cloud sync tasks | 68 | | [`backup.tf`](./core/backup.tf) | Azure backup vault to protect blob storage for Terraform state | 69 | | [`cloudflare.tf`](./core/cloudflare.tf) | Common Cloudflare DNS records and Page Rules | 70 | | [`terraform-backend.tf`](./core/terraform-backend.tf) | Azure storage configuration for [Terraform Remote State](https://www.terraform.io/docs/language/state/remote.html) and Azure Key Vault for Terraform secrets | 71 | -------------------------------------------------------------------------------- /core/cloudflare.tf: -------------------------------------------------------------------------------- 1 | resource "cloudflare_zone" "schnerring_net" { 2 | account_id = var.cloudflare_account_id 3 | zone = "schnerring.net" 4 | } 5 | 6 | # Email SPF 7 | 8 | resource "cloudflare_record" "spf" { 9 | zone_id = cloudflare_zone.schnerring_net.id 10 | name = "schnerring.net" 11 | type = "TXT" 12 | value = "v=spf1 include:mailgun.org include:_spf.protonmail.ch mx ~all" 13 | ttl = 86400 14 | } 15 | 16 | # ProtonMail 17 | 18 | resource "cloudflare_record" "protonmail_verification" { 19 | zone_id = cloudflare_zone.schnerring_net.id 20 | name = "schnerring.net" 21 | type = "TXT" 22 | value = "protonmail-verification=15dc53d4ac7f44c8c021a551bf61ed21410beab5" 23 | ttl = 86400 24 | } 25 | 26 | resource "cloudflare_record" "protonmail_mx_1" { 27 | zone_id = cloudflare_zone.schnerring_net.id 28 | name = "schnerring.net" 29 | type = "MX" 30 | value = "mail.protonmail.ch" 31 | ttl = 86400 32 | priority = 10 33 | } 34 | 35 | resource "cloudflare_record" "protonmail_mx_2" { 36 | zone_id = cloudflare_zone.schnerring_net.id 37 | name = "schnerring.net" 38 | type = "MX" 39 | value = "mailsec.protonmail.ch" 40 | ttl = 86400 41 | priority = 20 42 | } 43 | 44 | resource "cloudflare_record" "protonmail_dkim_1" { 45 | zone_id = cloudflare_zone.schnerring_net.id 46 | name = "protonmail._domainkey" 47 | type = "CNAME" 48 | value = "protonmail.domainkey.dj4kj3y2wss6natk5aychy474cv3uutffovaawtyl2qdey7roqmvq.domains.proton.ch" 49 | ttl = 86400 50 | } 51 | 52 | resource "cloudflare_record" "protonmail_dkim_2" { 53 | zone_id = cloudflare_zone.schnerring_net.id 54 | name = "protonmail2._domainkey" 55 | type = "CNAME" 56 | value = "protonmail2.domainkey.dj4kj3y2wss6natk5aychy474cv3uutffovaawtyl2qdey7roqmvq.domains.proton.ch" 57 | ttl = 86400 58 | } 59 | 60 | resource "cloudflare_record" "protonmail_dkim_3" { 61 | zone_id = cloudflare_zone.schnerring_net.id 62 | name = "protonmail3._domainkey" 63 | type = "CNAME" 64 | value = "protonmail3.domainkey.dj4kj3y2wss6natk5aychy474cv3uutffovaawtyl2qdey7roqmvq.domains.proton.ch" 65 | ttl = 86400 66 | } 67 | 68 | resource "cloudflare_record" "protonmail_dmarc" { 69 | zone_id = cloudflare_zone.schnerring_net.id 70 | name = "_dmarc" 71 | type = "TXT" 72 | value = "v=DMARC1; p=none; rua=mailto:dmarc@schnerring.net" 73 | ttl = 86400 74 | } 75 | 76 | # Mailgun 77 | 78 | resource "cloudflare_record" "mailgun_dkim" { 79 | zone_id = cloudflare_zone.schnerring_net.id 80 | name = "email._domainkey" 81 | type = "TXT" 82 | value = "k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA7ayPkPghq2agA/MInpj6zrOzfNzRlC+vUHVzKn7oKBu8EMBZnfv+xeA4gtqAZD5iymZL8p+wcovfDIrxIR2hIMQCsfuVP1vml96jJTXSf721SzfgD68ET97wCun6yi7GDtI5itkgk58nqlxAohF7u6fztBDHTGLaFZ0QXG8hlmN6qrgbxd3QWMcOgpQEeocU6zzQZsb0VNFJxWZR58n4DBEkY3OWd3Jui5BRioBRC3NQ4gtQparskkjIuTx/+kmksOzfGe4+BcG/NjJRNKcKYpLOMq83G5DSIyf3ql46kQPA3eqRUrST7FEpiF5kAJovGTAs/ryH+DmuLVa5dIX4iQIDAQAB" 83 | ttl = 86400 84 | } 85 | 86 | # GitHub Pages 87 | 88 | resource "cloudflare_record" "gh_pages_apex" { 89 | zone_id = cloudflare_zone.schnerring_net.id 90 | name = "schnerring.net" 91 | type = "CNAME" 92 | value = "schnerring.github.io" 93 | proxied = true 94 | } 95 | 96 | resource "cloudflare_record" "gh_pages_www" { 97 | zone_id = cloudflare_zone.schnerring_net.id 98 | name = "www" 99 | type = "CNAME" 100 | value = "schnerring.github.io" 101 | proxied = true 102 | } 103 | 104 | resource "cloudflare_record" "gh_pages_" { 105 | zone_id = cloudflare_zone.schnerring_net.id 106 | name = "commit-and-checkout-actions-workflow" 107 | type = "CNAME" 108 | value = "schnerring.github.io" 109 | proxied = false 110 | } 111 | 112 | resource "cloudflare_page_rule" "gh_pages_rule_forward_www_to_apex" { 113 | zone_id = cloudflare_zone.schnerring_net.id 114 | target = "https://www.schnerring.net/" 115 | priority = 2 116 | 117 | actions { 118 | forwarding_url { 119 | url = "https://schnerring.net/" 120 | status_code = 301 121 | } 122 | } 123 | } 124 | 125 | resource "cloudflare_page_rule" "gh_pages_rule_cache_everything" { 126 | zone_id = cloudflare_zone.schnerring_net.id 127 | target = "https://schnerring.net/*" 128 | priority = 1 129 | 130 | actions { 131 | cache_level = "cache_everything" 132 | } 133 | } 134 | 135 | # Azure Active Directory domain verification 136 | 137 | resource "cloudflare_record" "azure_verification" { 138 | zone_id = cloudflare_zone.schnerring_net.id 139 | name = "schnerring.net" 140 | type = "TXT" 141 | value = "MS=ms51347144" 142 | ttl = 86400 143 | } 144 | 145 | # Cloudflare Pages 146 | 147 | resource "cloudflare_record" "hugo_theme_gruvbox" { 148 | zone_id = cloudflare_zone.schnerring_net.id 149 | name = "hugo-theme-gruvbox" 150 | type = "CNAME" 151 | value = "hugo-theme-gruvbox.pages.dev" 152 | proxied = true 153 | } 154 | 155 | # Google Search Console 156 | 157 | resource "cloudflare_record" "google_search_console_verification" { 158 | zone_id = cloudflare_zone.schnerring_net.id 159 | name = "schnerring.net" 160 | type = "TXT" 161 | value = "google-site-verification=rDrVxUuHJAgkWBR7JfDMV2hGwwldC30PeDfRFza-TVg" 162 | ttl = 86400 163 | } 164 | 165 | # Sea Bats 166 | 167 | resource "cloudflare_zone" "sensingskies_org" { 168 | account_id = var.cloudflare_account_id 169 | zone = "sensingskies.org" 170 | } 171 | 172 | resource "cloudflare_record" "sensingskies_gh_pages_apex" { 173 | zone_id = cloudflare_zone.sensingskies_org.id 174 | name = "sensingskies.org" 175 | type = "CNAME" 176 | value = "schnerring.github.io" 177 | proxied = true 178 | } 179 | 180 | resource "cloudflare_record" "sensingskies_gh_pages_www" { 181 | zone_id = cloudflare_zone.sensingskies_org.id 182 | name = "www" 183 | type = "CNAME" 184 | value = "schnerring.github.io" 185 | proxied = true 186 | } 187 | 188 | resource "cloudflare_page_rule" "sensingskies_gh_pages_rule_forward_www_to_apex" { 189 | zone_id = cloudflare_zone.sensingskies_org.id 190 | target = "https://www.sensingskies.org/" 191 | priority = 1 192 | 193 | actions { 194 | forwarding_url { 195 | url = "https://sensingskies.org/" 196 | status_code = 301 197 | } 198 | } 199 | } 200 | 201 | # Self-hosted apps 202 | 203 | resource "cloudflare_zone" "schnerring_app" { 204 | account_id = var.cloudflare_account_id 205 | zone = "schnerring.app" 206 | } 207 | 208 | resource "cloudflare_record" "apps_cname" { 209 | zone_id = cloudflare_zone.schnerring_app.id 210 | name = "*" 211 | type = "CNAME" 212 | value = "schnerring.app" 213 | proxied = true 214 | } 215 | 216 | resource "cloudflare_record" "apps_caa" { 217 | zone_id = cloudflare_zone.schnerring_app.id 218 | name = "schnerring.app" 219 | type = "CAA" 220 | ttl = 86400 221 | 222 | data { 223 | flags = "0" 224 | tag = "issuewild" 225 | value = "letsencrypt.org" 226 | } 227 | } 228 | --------------------------------------------------------------------------------