From 2339a7fd76efa4c98fc0eb8d4fe2c3ea73e18f74 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Henry=20Gr=C3=A6sberg?=
Date: Tue, 10 Nov 2026 20:16:34 +0100
Subject: [PATCH 1/7] Add terraform IaC for azure infrastructure deployment
---
.gitignore | 6 +
infra/.gitignore | 3 +
infra/bootstrap/.terraform.lock.hcl | 22 +++
infra/bootstrap/main.tf | 44 +++++
infra/prod/.terraform.lock.hcl | 144 ++++++++++++++++
infra/prod/containers.tf | 250 ++++++++++++++++++++++++++++
infra/prod/db.tf | 27 +++
infra/prod/dns.tf | 105 ++++++++++++
infra/prod/main.tf | 66 ++++++++
infra/prod/network.tf | 36 ++++
infra/prod/outputs.tf | 17 ++
infra/prod/roles.tf | 17 ++
infra/prod/vars.tf | 42 +++++
13 files changed, 779 insertions(+)
create mode 100644 infra/.gitignore
create mode 100644 infra/bootstrap/.terraform.lock.hcl
create mode 100644 infra/bootstrap/main.tf
create mode 100644 infra/prod/.terraform.lock.hcl
create mode 100644 infra/prod/containers.tf
create mode 100644 infra/prod/db.tf
create mode 100644 infra/prod/dns.tf
create mode 100644 infra/prod/main.tf
create mode 100644 infra/prod/network.tf
create mode 100644 infra/prod/outputs.tf
create mode 100644 infra/prod/roles.tf
create mode 100644 infra/prod/vars.tf
diff --git a/.gitignore b/.gitignore
index caea02b..94ac645 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,9 @@ __unconfig*
todos.json
**/src/generated/
test-results/
+
+# Terraform
+infra/**/.terraform/
+infra/**/*.tfstate
+infra/**/*.tfstate.*
+infra/**/tfplan
diff --git a/infra/.gitignore b/infra/.gitignore
new file mode 100644
index 0000000..024ef20
--- /dev/null
+++ b/infra/.gitignore
@@ -0,0 +1,3 @@
+vars.tfvars
+*.config
+*.tfplan
diff --git a/infra/bootstrap/.terraform.lock.hcl b/infra/bootstrap/.terraform.lock.hcl
new file mode 100644
index 0000000..004439c
--- /dev/null
+++ b/infra/bootstrap/.terraform.lock.hcl
@@ -0,0 +1,22 @@
+# This file is maintained automatically by "terraform init".
+# Manual edits may be lost in future updates.
+
+provider "registry.terraform.io/hashicorp/azurerm" {
+ version = "5.0.0"
+ constraints = "5.0.0"
+ hashes = [
+ "h1:KUb/twsQAO1T3V+gO44gy71zI1vUFPXSBw3Zh0sFphE=",
+ "zh:305ebfc93982e46136e5b9ecd7fd95f0a84b5331f3915023e36822048f509bb2",
+ "zh:4f262e7da990865a2e04098ba74a2fcfe20693810f8c7a584bc5c25b45e35cae",
+ "zh:67194b475ad8aa74e162aba5103d634f1482d9c7341ac17d756e6212724d14fa",
+ "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
+ "zh:915264224576e3d23400e4cda909c76b30d9967e88aefcd33160d991cb90b347",
+ "zh:979a8afa2182cfde14d56c0413144f42c52e8c17fbbdfaf21bf7abda2eec8f07",
+ "zh:c14b2a6cde352966ea272b81bae8d51600d9c1833f9c686e378c21ae696620a1",
+ "zh:c2bb3add87a5d165b9f4ec54cf0d77ed632ffe41c08deee892357a1c28567f9c",
+ "zh:f2dba6b8f2443a33704a3d19fcdb79aff3062fba6be566827cfd6abbe844ecfd",
+ "zh:f3f85a0cba4d983ac229a98616214647cb219139e80aa653065b4a994eff4a2e",
+ "zh:fbfd09ddd2c19d9afe7c2f43d4ad80fb67f563bc5ff94c043812cb29fdd42ddd",
+ "zh:fe5f631898e73a12be0ae4552afca9f61c09dbaedbb42d2ffce255c07e4842c8",
+ ]
+}
diff --git a/infra/bootstrap/main.tf b/infra/bootstrap/main.tf
new file mode 100644
index 0000000..9ce7de9
--- /dev/null
+++ b/infra/bootstrap/main.tf
@@ -0,0 +1,44 @@
+terraform {
+ backend "local" {
+ path = "bootstrap.tfstate"
+ }
+
+ required_providers {
+ azurerm = {
+ source = "hashicorp/azurerm"
+ version = "=5.0.0"
+ }
+ }
+}
+
+provider "azurerm" {
+ features {}
+
+ resource_provider_registrations = "core"
+ resource_providers_to_register = ["Microsoft.Storage"]
+}
+
+resource "azurerm_resource_group" "rg_gspotter" {
+ name = "rg-gspotter"
+ location = "Norway East"
+}
+
+resource "azurerm_storage_account" "bootstrap" {
+ name = "gspotterstorageacc"
+ resource_group_name = azurerm_resource_group.rg_gspotter.name
+ location = azurerm_resource_group.rg_gspotter.location
+ account_tier = "Standard"
+ access_tier = "Cool"
+ account_replication_type = "LRS"
+}
+
+resource "azurerm_storage_container" "bootstrap" {
+ name = "tfstate"
+ storage_account_id = azurerm_storage_account.bootstrap.id
+}
+
+resource "azurerm_role_assignment" "blob_data_owner" {
+ scope = azurerm_storage_account.bootstrap.id
+ role_definition_name = "Storage Blob Data Owner"
+ principal_id = "0e18a31c-3e2e-4901-87f8-9cdfdd922242"
+}
diff --git a/infra/prod/.terraform.lock.hcl b/infra/prod/.terraform.lock.hcl
new file mode 100644
index 0000000..9b73039
--- /dev/null
+++ b/infra/prod/.terraform.lock.hcl
@@ -0,0 +1,144 @@
+# This file is maintained automatically by "terraform init".
+# Manual edits may be lost in future updates.
+
+provider "registry.terraform.io/chilicat/pkcs12" {
+ version = "0.4.0"
+ constraints = "0.4.0"
+ hashes = [
+ "h1:Wag2aKktTl1AzNsmTweNBWHvUFdwsfLj8lrLMy3Oi60=",
+ "zh:210702ac09f83aabb5f4ed9b8e998a73da5211617a9dba8bd47657edb4a586aa",
+ "zh:277d3385bcb42e316e112d04d165d3fda518dca472f2237fce58b7a2df12cb77",
+ "zh:3fe852b0bf847187a36b90f0c27f90a5d40857dff291bdba60647dd74ec5e931",
+ "zh:5488369bb5db2a7eae64417536320d78fcaa157245ee7a9c0f61140255738185",
+ "zh:5f1dd1b6ad4def355a36ce3171d7abdc7d5c0308652cf66fc8262e238bc16f59",
+ "zh:6d0bde8ae8d2d9bfdd2775438169637aa63d3ed66e1ab6d61ac50622604a6a8b",
+ "zh:86dcb95bb132caf673baf1b4fd6ee8217c0c6597c74ec3745a5880280361e1b9",
+ "zh:9509a8a543337006524521ac7e099f94bd64c61c9b9822c903a44e63e8b102f2",
+ "zh:a339c1db379960f8599d71d00c11a7c8672f9f8520af5a41d41461133dfe0228",
+ "zh:abb644d180f750172c551aac694fbb55b6c6213110d4a4863c22cabe803515ed",
+ "zh:c5cc3e8cebe042aa6faae7ffc5237ba6725a8c42fde98a42a9ab8efe47dd6eb2",
+ "zh:eae6d811afa012aaa7336c8ada729a28136566428961eb16bde7cedefec039b4",
+ "zh:fda03296aa6124117a35f67a2f19e2117097499bc8eb1f858a8ee537465b1e7f",
+ ]
+}
+
+provider "registry.terraform.io/cloudflare/cloudflare" {
+ version = "5.28.1"
+ constraints = "~> 5.0"
+ hashes = [
+ "h1:t6x20B8TpQAG0TsUlyWLrAWNO+V8JE8K3Ij0hW0feSU=",
+ "zh:219d464dd727c0e5c90a97b44aa5d7988acc4eec47f3684f7ecd006b17c46d9c",
+ "zh:2511f2380209c81121e6eb943d7e68d03602a4fcf4f0311dfc67e198179b9303",
+ "zh:2ae200482bf592cd703981c4d4fe87a52c0dfae81a70261295f59b1bb0d01a6c",
+ "zh:7df6fe6573379c2faf2e801fdbca58ac909266dbee604feaa993439b8616c9b3",
+ "zh:9fb5a295588351ce565515dc2a770047e8bab786a4a9665511ab9a9ca2db5f1b",
+ "zh:a182f4aec86c74e9db4affa22323c5a21f5d92c1fcf91b934570753d04f4daf8",
+ "zh:a39bb8624fb6c714e38bc0e632aae117a9fede5a18a248ab15dd1b60013c0324",
+ "zh:bef2ffbdb68569549fe432b550592e07456759a7288f84f7c37c9d933a493f54",
+ "zh:f809ab383cca0a5f83072981c64208cbd7fa67e986a86ee02dd2c82333221e32",
+ ]
+}
+
+provider "registry.terraform.io/hashicorp/azurerm" {
+ version = "5.0.0"
+ constraints = "5.0.0"
+ hashes = [
+ "h1:KUb/twsQAO1T3V+gO44gy71zI1vUFPXSBw3Zh0sFphE=",
+ "zh:305ebfc93982e46136e5b9ecd7fd95f0a84b5331f3915023e36822048f509bb2",
+ "zh:4f262e7da990865a2e04098ba74a2fcfe20693810f8c7a584bc5c25b45e35cae",
+ "zh:67194b475ad8aa74e162aba5103d634f1482d9c7341ac17d756e6212724d14fa",
+ "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
+ "zh:915264224576e3d23400e4cda909c76b30d9967e88aefcd33160d991cb90b347",
+ "zh:979a8afa2182cfde14d56c0413144f42c52e8c17fbbdfaf21bf7abda2eec8f07",
+ "zh:c14b2a6cde352966ea272b81bae8d51600d9c1833f9c686e378c21ae696620a1",
+ "zh:c2bb3add87a5d165b9f4ec54cf0d77ed632ffe41c08deee892357a1c28567f9c",
+ "zh:f2dba6b8f2443a33704a3d19fcdb79aff3062fba6be566827cfd6abbe844ecfd",
+ "zh:f3f85a0cba4d983ac229a98616214647cb219139e80aa653065b4a994eff4a2e",
+ "zh:fbfd09ddd2c19d9afe7c2f43d4ad80fb67f563bc5ff94c043812cb29fdd42ddd",
+ "zh:fe5f631898e73a12be0ae4552afca9f61c09dbaedbb42d2ffce255c07e4842c8",
+ ]
+}
+
+provider "registry.terraform.io/hashicorp/random" {
+ version = "3.9.1"
+ constraints = "~> 3.7"
+ hashes = [
+ "h1:PlW+UZ4EElQF3NQwf41KQwavFujab3Czc51zu9dyVM8=",
+ "zh:05f4734c1f0be840b711b3eff259ebc5fca436784c728955b1678078466f48d7",
+ "zh:0b91bf19371d012434eba1deeb6aab77158def9b39601dcbd94450b3974a2a26",
+ "zh:0ee6eacd47ec00183d55d726a4b6c4ce951a199f944bf22f1aa58392ebdfa7a2",
+ "zh:19388a4074b76a89a43a6c8328d7ae8ee2e7de3d346af51e80d3e6d3d12925f1",
+ "zh:23e74d48c5e2ac2e823fd527f49fee9db37d32a1990c9e3bf126ead697b843eb",
+ "zh:3cabf7fbd096c520064aae3aba61aba670af83ab91291a71fa1b1332929c2b7f",
+ "zh:5c0a3b8af0be60be4eca12ddee385cfa8babc1ec8e98cdf9de2f2274c73eabfa",
+ "zh:60b4f8a8ef18f52bf8e19215229dae408bee732825964092db7c989fd2de4097",
+ "zh:7359015acfedcbd6366f2329c854cf8d3c8ca5cd0faa89d2d37db358d6eba6c5",
+ "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
+ "zh:7b38758402f0e13a1071162da28994023cd2ac676e54af350c9ffd8dfa73fa7b",
+ "zh:7c7fbb8895eb75bb4de1f933e98553bd99c8d048c89a925ddba490aa5a67f7dc",
+ "zh:8c2b8c6a7ccdec16b73e2fb9f3700ea097f58c592571e4c5de60c93d2301732c",
+ ]
+}
+
+provider "registry.terraform.io/hashicorp/time" {
+ version = "0.14.2"
+ constraints = "~> 0.13"
+ hashes = [
+ "h1:gnP2hptiFIHSHUFBvAFKhE/Yh5u5yVEx+P7XSB58A/E=",
+ "zh:0aa1028d91041f4dceba193e3707dac57358d0063d97e20700e554758b67baca",
+ "zh:32bee9f2b2678e2a0789ad86e716d09ca1d5450180b3cd8033ee7a251bfd352e",
+ "zh:3aded9ef4dc6f4aec202a50c68a08b40013d325f9947f10168ebc8bee54109fc",
+ "zh:4d924637f3115ffa4ffc7f16d3f366bc472594f7447d400adb9def7ac92e3fc8",
+ "zh:5c35008e1363deafaa440ab43519409866dd7ec72aabeb2317bc16cd82756784",
+ "zh:6b30d97c9827501d7010fe49c8889d7a6cc8b45b77cdb1668c65af7f28a27d73",
+ "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
+ "zh:851e0f0e9c4de395e42220de51e7fb20d0e845e629f3cf37056da19e597304e7",
+ "zh:85622b4779b3ba7424780f7f8efcfa585227e245201cdb68303f2118b388e971",
+ "zh:9039153e3d45147183804188a1ce36c3811db9a4dca80f36fa382408d5f50b72",
+ "zh:a3d385413dc258a53fe8f4ded5d1fcd1eba715c65a015d74e70911bab2919207",
+ "zh:b9310a2327f7c8ad2aa3266f89c4d026b4bf18b09f7c4257c0ecd71a32f32db3",
+ "zh:cee7f2143da0c494115da94984bf3630e3e5855e27dae720a7bc29bb6acd9be2",
+ ]
+}
+
+provider "registry.terraform.io/hashicorp/tls" {
+ version = "4.4.2"
+ constraints = "~> 4.0"
+ hashes = [
+ "h1:/RQKd6LussdsR99zSuIYdV4e3JrV4l0stQ5Vb1qnbWo=",
+ "zh:034fdb3f45f0fe3e6fc905bfe451eab8f7055c253f27c787470f550621993c4b",
+ "zh:16a5d4055a8e2f6d35e139d6c4c23fb8c53d9f6105b8d509030bb9720b301e02",
+ "zh:2d4009339fcbb2c9c2fe0eeb1cd9154d8bc06794c1c34265a32f97a5a56001f7",
+ "zh:3ee85048c51bbc06c7327c5aadf621e360d87fa39fbf7910eabb2a4e9edd98d4",
+ "zh:4d5ba1e4e9c5054ff123286a53d8d3e082818f20162f5cbdd3b0c634fab0b3f6",
+ "zh:4e55f5c32d9e2e87eec31a874535a4916ba5add06c1123dd4ae5d5c0909f785d",
+ "zh:8cd5cd017c2e8c93f9beb7f54776480210f22b2498a8fdf90dc01dfdf0930ba2",
+ "zh:a3996a6ffa641d1670cebde54421441f858104702a3c512c85b832eab3eaf564",
+ "zh:ab88ea84cef01a0cbdc8f65fb67fc6e840ad1475844a0f4995a7559816bc592e",
+ "zh:aef59a5322085ec071013fa79621ec75716846ce25f98fb181233df657e60d03",
+ "zh:dfb0f5cfe5b139cdddb941baae0d40f793c7080546f7a46988c5df2644c010bb",
+ "zh:e5b9b260e468fed152a31a7a815eaf4cb048e579857d7da883cdf6fa012415f9",
+ "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
+ ]
+}
+
+provider "registry.terraform.io/kislerdm/neon" {
+ version = "0.18.0"
+ hashes = [
+ "h1:tcGix4plewNFy/ogZAItaUqreW9HjuxszUkD0Cma6lg=",
+ "zh:098c5e17387113154965b49b6132a997b0a85d870cd50ab824d778ddf52b1a6f",
+ "zh:0e9f82511541ac35b6c542165a4ecd37b24e7d362b04cb66797845b871489b7c",
+ "zh:136ad6984a8823f36f4cac32d7e46a567a06dc08d097d086e8c8849914007cb4",
+ "zh:1ec50258faab7217998109cf9042a1484ffaee998daf086ad64fd11f9306af20",
+ "zh:24e361ea8495d86cb00701c859759aec9fbdd3dd34b5e958a60584a8e7991b0d",
+ "zh:5749d4f1b3b83b353fb409076e7ae15ffbd0c061faf3bbdd6ef8afe3a24f124e",
+ "zh:587ba1719df733cc1cad3629ac56fe08bce38ea7b9f31f1e416079d807f6330d",
+ "zh:6dc982210b1cc2d8536dc58d95ccdb5c5c42dcaf64e4d9c480535b5b6754c069",
+ "zh:81fe9fc808d3dbd2756e10f3d26c26d679bba10113923a19530d49055852a77e",
+ "zh:adb63f02b1da7f4618c057645ca3ac344d6bcfe4337ff5a2c5cef0065a41a894",
+ "zh:b2bc806a824f95d5ba53969a5ce6a87c73563608eb5034d7e9fd203e8330738d",
+ "zh:b66f92bf88e301962b55ffe3cef467396c452021d6126ef49a66767ab9a85d4b",
+ "zh:e51f049ab211afe588c55931ff6e7d7f109d40abc6b46c93456ac79e34062805",
+ "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
+ ]
+}
diff --git a/infra/prod/containers.tf b/infra/prod/containers.tf
new file mode 100644
index 0000000..6366784
--- /dev/null
+++ b/infra/prod/containers.tf
@@ -0,0 +1,250 @@
+resource "random_id" "acr_suffix" {
+ byte_length = 4
+}
+
+resource "azurerm_container_registry" "registry" {
+ // ACR names must be globally unique
+ // so a random suffix guarantees availability.
+ name = "gspotteracr${random_id.acr_suffix.hex}"
+ resource_group_name = data.azurerm_resource_group.rg_gspotter.name
+ location = data.azurerm_resource_group.rg_gspotter.location
+ sku = "Basic"
+ admin_enabled = false
+}
+
+resource "azurerm_container_app_environment" "gspotter_env" {
+ name = "gspotter-container-app-env"
+ resource_group_name = data.azurerm_resource_group.rg_gspotter.name
+ location = data.azurerm_resource_group.rg_gspotter.location
+
+ // Attaching a subnet creates a consumption (non-express) environment, which
+ // supports the Certificates API - see network.tf.
+ infrastructure_subnet_id = azurerm_subnet.infrastructure.id
+
+ // Without a workspace there is nowhere for app/job logs to go: the portal
+ // shows "Could not find a corresponding Log Analytics Workspace".
+ logs_destination = "log-analytics"
+ log_analytics_workspace_id = azurerm_log_analytics_workspace.logs.id
+}
+
+resource "azurerm_log_analytics_workspace" "logs" {
+ name = "gspotter-logger"
+ location = data.azurerm_resource_group.rg_gspotter.location
+ resource_group_name = data.azurerm_resource_group.rg_gspotter.name
+ sku = "PerGB2018"
+ retention_in_days = 30
+}
+
+/*
+ Backend - Apollo/GraphQL on port 3001.
+ Ingress is INTERNAL ONLY: the backend has no public URL and is reached
+ exclusively through the frontend's /graphql proxy (same origin, so no
+ CORS) and via BACKEND_URL for SSR fetches.
+*/
+
+resource "azurerm_container_app" "backend_container" {
+ name = "gspotter-backend"
+ container_app_environment_id = azurerm_container_app_environment.gspotter_env.id
+ resource_group_name = data.azurerm_resource_group.rg_gspotter.name
+ revision_mode = "Single"
+
+ identity {
+ type = "UserAssigned"
+ identity_ids = [azurerm_user_assigned_identity.acr_pull.id]
+ }
+
+ registry {
+ server = azurerm_container_registry.registry.login_server
+ identity = azurerm_user_assigned_identity.acr_pull.id
+ }
+
+ secret {
+ name = "database-url"
+ value = neon_project.gspotter_project.connection_uri
+ }
+
+ template {
+ min_replicas = 0 # cheapest; gives cold starts
+ max_replicas = 2
+
+ container {
+ name = "backend"
+ image = var.backend_docker_image # placeholder until CI deploys the real image
+ cpu = 0.25
+ memory = "0.5Gi"
+
+ env {
+ name = "DATABASE_URL"
+ secret_name = "database-url"
+ }
+
+ env {
+ name = "PORT"
+ value = "3001"
+ }
+
+ env {
+ name = "NODE_ENV"
+ value = "production"
+ }
+ }
+ }
+
+ ingress {
+ external_enabled = false # internal only, reachable solely from inside the environment
+
+ target_port = 3001
+ transport = "auto"
+
+ # Serve plain HTTP on the internal ingress. The Nitro proxy in the frontend
+ # talks to this app over http:///; with this false the ingress would
+ # 301-redirect to https and the internal request would die at the redirect.
+ allow_insecure_connections = true
+
+ # Required by the provider even in Single revision mode (only used in Multiple).
+ traffic_weight {
+ latest_revision = true
+ percentage = 100
+ }
+ }
+
+ # CI owns the image tag: `az containerapp update --image ...:`
+ lifecycle {
+ ignore_changes = [template[0].container[0].image]
+ }
+
+ depends_on = [time_sleep.acr_rbac]
+}
+
+# ------------------------------------------------------------------
+# Frontend - TanStack Start / Nitro on port 3000.
+# The only publicly exposed app; served at gspotter.graesberg.com through
+# Cloudflare. Nitro proxies /graphql to the internal backend and SSR
+# loaders fetch BACKEND_URL directly.
+# ------------------------------------------------------------------
+
+resource "azurerm_container_app" "frontend_container" {
+ name = "gspotter-frontend"
+ container_app_environment_id = azurerm_container_app_environment.gspotter_env.id
+ resource_group_name = data.azurerm_resource_group.rg_gspotter.name
+ revision_mode = "Single"
+
+ identity {
+ type = "UserAssigned"
+ identity_ids = [azurerm_user_assigned_identity.acr_pull.id]
+ }
+
+ registry {
+ server = azurerm_container_registry.registry.login_server
+ identity = azurerm_user_assigned_identity.acr_pull.id
+ }
+
+ template {
+ min_replicas = 0
+ max_replicas = 2
+
+ container {
+ name = "frontend"
+ image = var.frontend_docker_image # placeholder until CI deploys the real image
+ cpu = 0.25
+ memory = "0.5Gi"
+
+ env {
+ name = "PORT"
+ value = "3000"
+ }
+
+ env {
+ name = "NODE_ENV"
+ value = "production"
+ }
+
+ # Runtime env for SSR fetches to the internal backend. Not baked into
+ # the image, so moving the app to another domain needs no rebuild.
+ env {
+ name = "BACKEND_URL"
+ value = local.backend_internal_url
+ }
+ }
+ }
+
+ ingress {
+ external_enabled = true
+ target_port = 3000
+ transport = "auto"
+ allow_insecure_connections = false
+
+ traffic_weight {
+ latest_revision = true
+ percentage = 100
+ }
+ }
+
+ # CI owns the image tag: `az containerapp update --image ...:`
+ lifecycle {
+ ignore_changes = [template[0].container[0].image]
+ }
+
+ depends_on = [time_sleep.acr_rbac]
+}
+
+# ------------------------------------------------------------------
+# Migration job - runs `prisma migrate deploy` with the backend image.
+# CI starts it with the new image before updating the apps:
+# az containerapp job start -n gspotter-migrate -g rg-gspotter \
+# --image /gspotter-backend:
+# ------------------------------------------------------------------
+
+resource "azurerm_container_app_job" "migrate" {
+ name = "gspotter-migrate"
+ location = data.azurerm_resource_group.rg_gspotter.location
+ resource_group_name = data.azurerm_resource_group.rg_gspotter.name
+ container_app_environment_id = azurerm_container_app_environment.gspotter_env.id
+
+ replica_timeout_in_seconds = 600
+ replica_retry_limit = 0
+
+ identity {
+ type = "UserAssigned"
+ identity_ids = [azurerm_user_assigned_identity.acr_pull.id]
+ }
+
+ registry {
+ server = azurerm_container_registry.registry.login_server
+ identity = azurerm_user_assigned_identity.acr_pull.id
+ }
+
+ secret {
+ name = "database-url"
+ value = neon_project.gspotter_project.connection_uri
+ }
+
+ manual_trigger_config {
+ parallelism = 1
+ replica_completion_count = 1
+ }
+
+ template {
+ container {
+ name = "migrate"
+ image = var.backend_docker_image # placeholder until CI overrides it per run
+ cpu = 0.5
+ memory = "1Gi"
+
+ # No .env in the container; DATABASE_URL comes from the secret above.
+ # Requires the backend image to keep the prisma CLI installed.
+ command = ["pnpm", "-C", "packages/db", "exec", "prisma", "migrate", "deploy"]
+
+ env {
+ name = "DATABASE_URL"
+ secret_name = "database-url"
+ }
+ }
+ }
+
+ lifecycle {
+ ignore_changes = [template[0].container[0].image]
+ }
+
+ depends_on = [time_sleep.acr_rbac]
+}
diff --git a/infra/prod/db.tf b/infra/prod/db.tf
new file mode 100644
index 0000000..0bcc415
--- /dev/null
+++ b/infra/prod/db.tf
@@ -0,0 +1,27 @@
+/*
+ For a real-world deployment, using azure's postgresql servers would be preferable
+ But the annual cost of running just the database on the cheapest tier could easily surpass the
+ free annual student credits - so neon is used as a standin with a free tier and autoscaling
+
+ If the usage surpasses neon's free tier, the datbase should be migrated to azure, as the paid
+ tier of neon is more expensive then having an azure hosted datbase server
+*/
+resource "neon_project" "gspotter_project" {
+ name = "gspotter"
+ pg_version = 18
+ region_id = "aws-eu-central-1"
+
+ branch {
+ name = "prod"
+ database_name = "gspot"
+ role_name = "admin"
+ }
+
+ autoscaling_limit_min_cu = 0.25
+ autoscaling_limit_max_cu = 1.0
+
+ // PITR backup history. The provider defaults to 86400 (1 day), but the
+ // Neon free plan rejects anything above 21600 (6 hours):
+ // requested history retention seconds exceeds allowed maximum ... max:"21600"
+ history_retention_seconds = 21600
+}
diff --git a/infra/prod/dns.tf b/infra/prod/dns.tf
new file mode 100644
index 0000000..eb9001b
--- /dev/null
+++ b/infra/prod/dns.tf
@@ -0,0 +1,105 @@
+locals {
+ app_fqdn = "${var.app_subdomain}.${var.domain}"
+
+ backend_internal_url = "http://gspotter-backend.internal.${azurerm_container_app_environment.gspotter_env.default_domain}"
+}
+
+resource "cloudflare_dns_record" "app" {
+ zone_id = var.cloudflare_zone_id
+ name = local.app_fqdn
+ type = "CNAME"
+
+ // ingress[0].fqdn is the stable app URL (not latest_revision_fqdn, which
+ // changes on every revision), so DNS never needs updating on redeploys.
+ content = azurerm_container_app.frontend_container.ingress[0].fqdn
+ ttl = 1
+ proxied = true
+}
+
+resource "cloudflare_dns_record" "app_asuid" {
+ zone_id = var.cloudflare_zone_id
+ name = "asuid.${local.app_fqdn}"
+ type = "TXT"
+ content = azurerm_container_app.frontend_container.custom_domain_verification_id
+ ttl = 3600
+}
+
+resource "cloudflare_zone_setting" "ssl" {
+ zone_id = var.cloudflare_zone_id
+ setting_id = "ssl"
+ value = "strict"
+}
+
+resource "cloudflare_zone_setting" "min_tls" {
+ zone_id = var.cloudflare_zone_id
+ setting_id = "min_tls_version"
+ value = "1.2"
+}
+
+resource "cloudflare_zone_setting" "always_https" {
+ zone_id = var.cloudflare_zone_id
+ setting_id = "always_use_https"
+ value = "on"
+}
+
+// Origin cert
+
+resource "tls_private_key" "origin" {
+ algorithm = "RSA"
+ rsa_bits = 2048
+}
+
+resource "tls_cert_request" "origin" {
+ private_key_pem = tls_private_key.origin.private_key_pem
+
+ subject {
+ common_name = local.app_fqdn
+ }
+
+ dns_names = [local.app_fqdn, var.domain, "*.${var.domain}"]
+}
+
+resource "cloudflare_origin_ca_certificate" "origin" {
+ csr = tls_cert_request.origin.cert_request_pem
+ hostnames = [local.app_fqdn, var.domain, "*.${var.domain}"]
+ request_type = "origin-rsa"
+ requested_validity = 5475 // 15 years
+}
+
+resource "pkcs12_from_pem" "origin" {
+ password = random_password.origin_pfx.result
+ cert_pem = cloudflare_origin_ca_certificate.origin.certificate
+ private_key_pem = tls_private_key.origin.private_key_pem
+
+ // Azure's certificate validation rejects PFX archives encrypted
+ // with the modern AES-256-CBC/PBKDF2 default
+ // legacyDES matches that accepted legacy encoding.
+ encoding = "legacyDES"
+}
+
+resource "random_password" "origin_pfx" {
+ length = 32
+ special = false
+}
+
+resource "azurerm_container_app_environment_certificate" "origin" {
+ name = "gspotter-origin"
+ container_app_environment_id = azurerm_container_app_environment.gspotter_env.id
+ certificate_blob_base64 = pkcs12_from_pem.origin.result
+ certificate_password = random_password.origin_pfx.result
+}
+
+// Let the asuid TXT record propagate before Azure validates domain ownership.
+resource "time_sleep" "dns_propagation" {
+ depends_on = [cloudflare_dns_record.app_asuid]
+ create_duration = "60s"
+}
+
+resource "azurerm_container_app_custom_domain" "app" {
+ name = local.app_fqdn
+ container_app_id = azurerm_container_app.frontend_container.id
+ container_app_environment_certificate_id = azurerm_container_app_environment_certificate.origin.id
+ certificate_binding_type = "SniEnabled"
+
+ depends_on = [time_sleep.dns_propagation]
+}
diff --git a/infra/prod/main.tf b/infra/prod/main.tf
new file mode 100644
index 0000000..9759726
--- /dev/null
+++ b/infra/prod/main.tf
@@ -0,0 +1,66 @@
+terraform {
+ backend "azurerm" {
+ sas_token = ""
+
+ storage_account_name = "gspotterstorageacc"
+ container_name = "tfstate"
+ key = "prod.terraform.tfstate"
+ }
+
+ required_providers {
+ azurerm = {
+ source = "hashicorp/azurerm"
+ version = "=5.0.0"
+ }
+ neon = {
+ source = "kislerdm/neon"
+ }
+ cloudflare = {
+ source = "cloudflare/cloudflare"
+ version = "~> 5"
+ }
+ tls = {
+ source = "hashicorp/tls"
+ version = "~> 4"
+ }
+ time = {
+ source = "hashicorp/time"
+ version = "~> 0.13"
+ }
+ random = {
+ source = "hashicorp/random"
+ version = "~> 3.7"
+ }
+ pkcs12 = {
+ source = "chilicat/pkcs12"
+ version = "0.4.0"
+ }
+ }
+}
+
+provider "azurerm" {
+ features {}
+ subscription_id = var.subscription_id
+
+ resource_provider_registrations = "core"
+ resource_providers_to_register = [
+ "Microsoft.App", // Container Apps env, apps, jobs, custom domains, certs
+ "Microsoft.ContainerRegistry", // the ACR
+ "Microsoft.ManagedIdentity", // user-assigned identity for ACR pulls
+ "Microsoft.Authorization", // the AcrPull role assignment
+ "Microsoft.Network", // VNet + subnet for the container app environment
+ "Microsoft.OperationalInsights", // Log Analytics workspace for app/job logs
+ ]
+}
+
+provider "neon" {
+ api_key = var.neon_api_key
+}
+
+provider "cloudflare" {
+ api_token = var.cloudflare_api_token
+}
+
+data "azurerm_resource_group" "rg_gspotter" {
+ name = "rg-gspotter"
+}
diff --git a/infra/prod/network.tf b/infra/prod/network.tf
new file mode 100644
index 0000000..ee43918
--- /dev/null
+++ b/infra/prod/network.tf
@@ -0,0 +1,36 @@
+/*
+ Container Apps environments created WITHOUT a virtual network are "express"
+ environments, which do not support bring-your-own certificates:
+ ExpressEnvironmentResourceNotSupported: 'Certificate' resources are not
+ supported on express environments
+ Attaching an infrastructure subnet creates a "consumption" environment
+ instead - same serverless billing as express, no workload profiles - which
+ does support the Certificates API used to upload the Cloudflare Origin CA
+ certificate.
+
+ The subnet must be a /21 or larger (per the azurerm provider docs) and must
+ be delegated to Microsoft.App/environments - required even for consumption
+ environments (the Azure API rejects it with ManagedEnvironmentSubnetDelegationError
+ otherwise; the CLI docs saying not to delegate are outdated).
+*/
+
+resource "azurerm_virtual_network" "vnet" {
+ name = "gspotter-vnet"
+ location = data.azurerm_resource_group.rg_gspotter.location
+ resource_group_name = data.azurerm_resource_group.rg_gspotter.name
+ address_space = ["10.0.0.0/16"]
+}
+
+resource "azurerm_subnet" "infrastructure" {
+ name = "infrastructure-subnet"
+ resource_group_name = data.azurerm_resource_group.rg_gspotter.name
+ virtual_network_name = azurerm_virtual_network.vnet.name
+ address_prefixes = ["10.0.0.0/21"]
+
+ delegation {
+ name = "Microsoft.App-environments"
+ service_delegation {
+ name = "Microsoft.App/environments"
+ }
+ }
+}
diff --git a/infra/prod/outputs.tf b/infra/prod/outputs.tf
new file mode 100644
index 0000000..e6738b4
--- /dev/null
+++ b/infra/prod/outputs.tf
@@ -0,0 +1,17 @@
+output "acr_login_server" {
+ description = "ACR login server, e.g. for `az containerapp update --image`."
+ value = azurerm_container_registry.registry.login_server
+}
+
+output "resource_group_name" {
+ value = data.azurerm_resource_group.rg_gspotter.name
+}
+
+output "app_url" {
+ description = "Public URL of the app (frontend + /graphql API)."
+ value = "https://${local.app_fqdn}"
+}
+
+output "migrate_job_name" {
+ value = azurerm_container_app_job.migrate.name
+}
diff --git a/infra/prod/roles.tf b/infra/prod/roles.tf
new file mode 100644
index 0000000..daa3cfa
--- /dev/null
+++ b/infra/prod/roles.tf
@@ -0,0 +1,17 @@
+resource "azurerm_user_assigned_identity" "acr_pull" {
+ name = "id-gspotter-acr-pull"
+ resource_group_name = data.azurerm_resource_group.rg_gspotter.name
+ location = data.azurerm_resource_group.rg_gspotter.location
+}
+
+resource "azurerm_role_assignment" "acr_pull" {
+ scope = azurerm_container_registry.registry.id
+ role_definition_name = "AcrPull"
+ principal_id = azurerm_user_assigned_identity.acr_pull.principal_id
+ principal_type = "ServicePrincipal"
+}
+
+resource "time_sleep" "acr_rbac" {
+ depends_on = [azurerm_role_assignment.acr_pull]
+ create_duration = "60s"
+}
diff --git a/infra/prod/vars.tf b/infra/prod/vars.tf
new file mode 100644
index 0000000..27ffed0
--- /dev/null
+++ b/infra/prod/vars.tf
@@ -0,0 +1,42 @@
+variable "subscription_id" {
+ type = string
+}
+
+variable "cloudflare_api_token" {
+ description = "Cloudflare API token with Zone:Read, DNS:Edit, SSL and Certificates:Edit, Zone Settings:Edit."
+ type = string
+ sensitive = true
+}
+
+variable "cloudflare_zone_id" {
+ type = string
+}
+
+variable "neon_api_key" {
+ type = string
+
+}
+
+variable "domain" {
+ type = string
+ default = "graesberg.com"
+}
+
+variable "app_subdomain" {
+ type = string
+ default = "gspotter"
+}
+
+// CI owns the image tags (`az containerapp update --image ...:`) and the
+// image fields are ignored by Terraform via lifecycle blocks, so these only
+// apply to the initial apply, which must succeed while the registry is still
+// empty.
+variable "frontend_docker_image" {
+ type = string
+ default = "mcr.microsoft.com/k8se/quickstart:latest"
+}
+
+variable "backend_docker_image" {
+ type = string
+ default = "mcr.microsoft.com/azuredocs/containerapps-helloworld:latest"
+}
From e8e0ca5446d8c6a9c6f72a8dbd41fb8eeeace697 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Henry=20Gr=C3=A6sberg?=
Date: Tue, 10 Nov 2026 20:16:53 +0100
Subject: [PATCH 2/7] Add dockerfiles and deploy prerequisites
---
.dockerignore | 21 ++++
.github/workflows/deploy-azure.yaml | 144 ++++++++++++++++++++++++++++
apps/backend/Dockerfile | 30 ++++++
apps/backend/package.json | 2 +-
apps/frontend/Dockerfile | 45 +++++++++
apps/frontend/src/graphql/client.ts | 11 ++-
apps/frontend/vite.config.ts | 27 +++++-
package.json | 3 +-
scripts/register-azure-providers.sh | 54 +++++++++++
9 files changed, 329 insertions(+), 8 deletions(-)
create mode 100644 .dockerignore
create mode 100644 .github/workflows/deploy-azure.yaml
create mode 100644 apps/backend/Dockerfile
create mode 100644 apps/frontend/Dockerfile
create mode 100755 scripts/register-azure-providers.sh
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..39d4442
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,21 @@
+# Build context: repository root. Keeps the `az acr build` upload small.
+.git
+.github
+.agents
+.vscode
+docs
+node_modules
+**/node_modules
+**/.output
+**/.tanstack
+**/.nitro
+.env
+**/.env
+**/src/generated
+test-results
+**/test-results
+.DS_Store
+*.md
+lighthouse.json
+.cta.json
+todos.json
diff --git a/.github/workflows/deploy-azure.yaml b/.github/workflows/deploy-azure.yaml
new file mode 100644
index 0000000..0eee7ea
--- /dev/null
+++ b/.github/workflows/deploy-azure.yaml
@@ -0,0 +1,144 @@
+# Generated by GLM 5.3 NVFP4
+
+# Builds the Docker images in ACR and redeploys the Azure Container Apps.
+#
+# Images are built with `docker buildx build --push` rather than
+# `az acr build`: ACR Tasks (the service behind `az acr build`) is not
+# permitted on Azure for Students subscriptions (TasksOperationsNotAllowed).
+#
+# Prerequisites (one-time):
+# - terraform apply in infra/ (creates ACR, DB, apps with placeholder images,
+# the migrate job, and the Cloudflare DNS/certificate setup)
+# - Azure login secrets for the runner:
+# AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID
+# (federated OIDC; requires a user-assigned managed identity
+# or app registration with an added federated credential for
+# this repo - see azure/login@v2 docs)
+# On git.ntnu.no (no OIDC): store the JSON output of
+# `az ad sp create-for-rbac --name gspotter-ci` as AZURE_CREDENTIALS
+# and change the login step to: with: { creds: secrets.AZURE_CREDENTIALS }
+# - The CI principal must be able to push to the registry and update the
+# apps/job. One-time grants for its object ID:
+# az role assignment create --assignee --role AcrPush \
+# --scope $(az acr show -n --query id -o tsv)
+# az role assignment create --assignee --role Contributor \
+# --resource-group rg-gspotter
+# - The runner needs the Docker CLI (preinstalled on ubuntu-latest).
+#
+# Rollback: `az containerapp revision list` + `az containerapp revision
+# activate` for the previous revision, or re-run this workflow on an old
+# commit (image tags are immutable, per-commit SHAs).
+
+name: Deploy to Azure Container Apps
+
+on:
+ push:
+ branches:
+ - main
+ workflow_dispatch:
+
+permissions:
+ id-token: write # Azure OIDC login
+ contents: read
+
+env:
+ RESOURCE_GROUP: rg-gspotter
+ BACKEND_APP: gspotter-backend
+ FRONTEND_APP: gspotter-frontend
+ MIGRATE_JOB: gspotter-migrate
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+
+ # Never overlap production deploys; queue instead of cancelling.
+ concurrency:
+ group: production-azure
+ cancel-in-progress: false
+
+ steps:
+ - name: Check out source
+ uses: actions/checkout@v4
+
+ - name: Log in to Azure
+ uses: azure/login@v2
+ with:
+ client-id: ${{ secrets.AZURE_CLIENT_ID }}
+ tenant-id: ${{ secrets.AZURE_TENANT_ID }}
+ subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
+
+ - name: Resolve Azure resource names
+ run: |
+ set -euo pipefail
+ ACR_NAME="$(az acr list -g "$RESOURCE_GROUP" --query '[0].name' -o tsv)"
+ if [[ -z "$ACR_NAME" || "$ACR_NAME" == "None" ]]; then
+ echo "No container registry found in $RESOURCE_GROUP. Run terraform apply in infra/ first." >&2
+ exit 1
+ fi
+ echo "ACR_NAME=$ACR_NAME" >> "$GITHUB_ENV"
+ echo "ACR_SERVER=$ACR_NAME.azurecr.io" >> "$GITHUB_ENV"
+
+ - name: Resolve internal backend URL
+ run: |
+ set -euo pipefail
+ # The backend's ingress is internal-only; this is what the frontend
+ # proxies /graphql to and what SSR fetches use (BACKEND_URL).
+ BACKEND_FQDN="$(az containerapp show -n "$BACKEND_APP" -g "$RESOURCE_GROUP" \
+ --query 'properties.configuration.ingress.fqdn' -o tsv)"
+ echo "GRAPHQL_PROXY_TARGET=http://$BACKEND_FQDN" >> "$GITHUB_ENV"
+
+ - name: Log in to the container registry
+ run: az acr login --name "$ACR_NAME"
+
+ - name: Build and push backend image
+ run: |
+ docker buildx build \
+ --platform linux/amd64 \
+ --provenance=false \
+ -f apps/backend/Dockerfile \
+ -t "$ACR_SERVER/gspotter-backend:$GITHUB_SHA" \
+ --push .
+
+ - name: Build and push frontend image
+ run: |
+ docker buildx build \
+ --platform linux/amd64 \
+ --provenance=false \
+ -f apps/frontend/Dockerfile \
+ --build-arg "APP_BASE_PATH=/" \
+ --build-arg "VITE_GRAPHQL_URL=/graphql" \
+ --build-arg "GRAPHQL_PROXY_TARGET=$GRAPHQL_PROXY_TARGET" \
+ -t "$ACR_SERVER/gspotter-frontend:$GITHUB_SHA" \
+ --push .
+
+ - name: Run database migrations
+ run: |
+ set -euo pipefail
+ az containerapp job start -n "$MIGRATE_JOB" -g "$RESOURCE_GROUP" \
+ --image "$ACR_SERVER/gspotter-backend:$GITHUB_SHA"
+
+ # Poll the newest execution until it finishes.
+ for _ in $(seq 1 60); do
+ STATUS="$(az containerapp job execution list -n "$MIGRATE_JOB" -g "$RESOURCE_GROUP" \
+ --query 'sort_by(@, &startTime)[-1].status' -o tsv)"
+ case "$STATUS" in
+ Succeeded) echo "Migrations applied."; exit 0 ;;
+ Failed) echo "Migration job failed. See: az containerapp job execution logs" >&2; exit 1 ;;
+ *) sleep 10 ;;
+ esac
+ done
+ echo "Timed out waiting for the migration job." >&2
+ exit 1
+
+ # Backend first, so the frontend never points at an older GraphQL schema
+ # than the one the migrations just applied.
+ - name: Deploy backend
+ run: |
+ az containerapp update -n "$BACKEND_APP" -g "$RESOURCE_GROUP" \
+ --image "$ACR_SERVER/gspotter-backend:$GITHUB_SHA"
+
+ - name: Deploy frontend
+ run: |
+ az containerapp update -n "$FRONTEND_APP" -g "$RESOURCE_GROUP" \
+ --image "$ACR_SERVER/gspotter-frontend:$GITHUB_SHA"
diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile
new file mode 100644
index 0000000..de35ada
--- /dev/null
+++ b/apps/backend/Dockerfile
@@ -0,0 +1,30 @@
+# To bump Node: docker pull node:24-bookworm-slim && \
+# docker inspect --format '{{index .RepoDigests 0}}' node:24-bookworm-slim
+FROM node:24-bookworm-slim@sha256:01d566e8d94d8c619591d78652babfccd3c6992a3dffa9ed5ab39e11cf6c70e9
+
+WORKDIR /app
+
+RUN corepack enable
+
+# Copy all workspace manifests first so the apps/* and packages/* globs in
+# pnpm-workspace.yaml resolve during the (filtered) dependency install.
+COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
+COPY packages/db/package.json packages/db/
+COPY apps/frontend/package.json apps/frontend/
+COPY apps/backend/package.json apps/backend/
+RUN pnpm install --frozen-lockfile --filter @t09-project-2/backend...
+
+# prisma.config.ts calls env('DATABASE_URL') even during `prisma generate`,
+# so a placeholder value is required for the client generation step.
+COPY packages/db packages/db
+COPY apps/backend apps/backend
+RUN DATABASE_URL="postgresql://placeholder:placeholder@localhost:5432/placeholder" \
+ pnpm --filter @t09-project-2/db exec prisma generate
+
+# devDependencies (prisma, tsx) are intentionally kept: the Prisma migration
+# job runs `prisma migrate deploy` from this image, and `tsx` is the runtime.
+ENV NODE_ENV=production
+
+EXPOSE 3001
+
+CMD ["pnpm", "--filter", "@t09-project-2/backend", "start"]
diff --git a/apps/backend/package.json b/apps/backend/package.json
index a955de4..1899cee 100644
--- a/apps/backend/package.json
+++ b/apps/backend/package.json
@@ -4,7 +4,7 @@
"type": "module",
"scripts": {
"dev": "dotenv -e .env -- tsx watch src/index.ts",
- "start": "dotenv -e .env -- tsx src/index.ts",
+ "start": "tsx src/index.ts",
"test": "dotenv -e .env -- playwright test",
"typecheck": "tsc --noEmit"
},
diff --git a/apps/frontend/Dockerfile b/apps/frontend/Dockerfile
new file mode 100644
index 0000000..b63ee6e
--- /dev/null
+++ b/apps/frontend/Dockerfile
@@ -0,0 +1,45 @@
+# To bump Node: docker pull node:24-bookworm-slim && \
+# docker inspect --format '{{index .RepoDigests 0}}' node:24-bookworm-slim
+FROM node:24-bookworm-slim@sha256:01d566e8d94d8c619591d78652babfccd3c6992a3dffa9ed5ab39e11cf6c70e9 AS base
+FROM base AS builder
+
+WORKDIR /app
+
+RUN corepack enable
+
+# Copy all workspace manifests first so the apps/* and packages/* globs in
+# pnpm-workspace.yaml resolve during the (filtered) dependency install.
+COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
+COPY packages/db/package.json packages/db/
+COPY apps/frontend/package.json apps/frontend/
+COPY apps/backend/package.json apps/backend/
+RUN pnpm install --frozen-lockfile --filter @t09-project-2/frontend...
+
+# APP_BASE_PATH: base path the app is served from ('/' on Azure, '/project2'
+# on the VM). VITE_GRAPHQL_URL is baked into the client bundle ('/graphql'
+# on Azure = same origin, proxied to the backend by Nitro's route rules).
+# GRAPHQL_PROXY_TARGET is where the Nitro server forwards /graphql requests.
+ARG APP_BASE_PATH=/
+ARG VITE_GRAPHQL_URL=/graphql
+ARG GRAPHQL_PROXY_TARGET=http://localhost:3001/
+
+ENV APP_BASE_PATH=$APP_BASE_PATH
+ENV VITE_GRAPHQL_URL=$VITE_GRAPHQL_URL
+ENV GRAPHQL_PROXY_TARGET=$GRAPHQL_PROXY_TARGET
+
+COPY packages/db packages/db
+COPY apps/frontend apps/frontend
+RUN pnpm --filter @t09-project-2/frontend run build
+
+FROM base AS runner
+
+WORKDIR /app
+
+COPY --from=builder /app/apps/frontend/.output apps/frontend/.output
+
+ENV NODE_ENV=production
+ENV PORT=3000
+
+EXPOSE 3000
+
+CMD ["node", "apps/frontend/.output/server/index.mjs"]
diff --git a/apps/frontend/src/graphql/client.ts b/apps/frontend/src/graphql/client.ts
index 3772097..4ef813c 100644
--- a/apps/frontend/src/graphql/client.ts
+++ b/apps/frontend/src/graphql/client.ts
@@ -1,4 +1,13 @@
-const GRAPHQL_URL = import.meta.env.VITE_GRAPHQL_URL ?? 'http://localhost:3001/'
+// In the browser, requests go to the same origin (VITE_GRAPHQL_URL is baked as
+// '/graphql' for the Azure deployment) and the frontend's Nitro server proxies
+// them to the backend. During SSR, fetches go straight to the backend instead:
+// BACKEND_URL is a runtime env var on the container app and falls back to the
+// baked VITE_GRAPHQL_URL for local development and the VM deployment.
+const GRAPHQL_URL = import.meta.env.SSR
+ ? (process.env.BACKEND_URL ??
+ import.meta.env.VITE_GRAPHQL_URL ??
+ 'http://localhost:3001/')
+ : (import.meta.env.VITE_GRAPHQL_URL ?? 'http://localhost:3001/')
interface GraphQLResponse {
data?: T
diff --git a/apps/frontend/vite.config.ts b/apps/frontend/vite.config.ts
index 8bf0f56..3b20629 100644
--- a/apps/frontend/vite.config.ts
+++ b/apps/frontend/vite.config.ts
@@ -7,30 +7,47 @@ import viteReact from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { nitro } from 'nitro/vite'
+// The base path the app is served from, normalized without a trailing slash:
+// '' when served from the domain root (Azure), '/project2' on the group VM.
+// Deriving every route-rule key from this form avoids '//graphql'-style keys
+// when the base is '/'.
+const basePath = (process.env.APP_BASE_PATH ?? '/project2').replace(/\/+$/, '')
+
+// Vite's `base` option must start with a slash.
+const base = basePath === '' ? '/' : basePath
+
+// Apollo standalone serves GraphQL at the root path, so the proxy target is
+// the bare backend URL without a /graphql suffix. On Azure this points at the
+// backend container app's internal ingress (plain HTTP inside the environment).
+const graphqlProxyTarget =
+ process.env.GRAPHQL_PROXY_TARGET ?? 'http://localhost:3001/'
+
const config = defineConfig({
resolve: { tsconfigPaths: true },
plugins: [
devtools(),
nitro({
rollupConfig: { external: [/^@sentry\//] },
- baseURL: '/project2/',
+ baseURL: `${basePath}/`,
// Serve brotli/gzip versions of the built assets. Without this the
// JavaScript, CSS and poster go over the wire at their full size.
compressPublicAssets: { gzip: true, brotli: true },
routeRules: {
- '/project2/assets/**': {
+ // Proxy same-origin browser requests to the GraphQL backend.
+ [`${basePath}/graphql`]: { proxy: graphqlProxyTarget },
+ [`${basePath}/assets/**`]: {
headers: {
'cache-control': 'public, max-age=31536000, immutable',
},
},
// Public files are not content-hashed, but they rarely change and a
// long cache lifetime avoids a round trip on repeat visits.
- '/project2/favicon.ico': {
+ [`${basePath}/favicon.ico`]: {
headers: {
'cache-control': 'public, max-age=2592000',
},
},
- '/project2/*.svg': {
+ [`${basePath}/*.svg`]: {
headers: {
'cache-control': 'public, max-age=2592000',
},
@@ -41,7 +58,7 @@ const config = defineConfig({
tanstackStart(),
viteReact(),
],
- base: '/project2',
+ base,
})
export default config
diff --git a/package.json b/package.json
index aa1347d..3e050cb 100644
--- a/package.json
+++ b/package.json
@@ -22,7 +22,8 @@
"db:deploy": "pnpm --filter @t09-project-2/db run db:deploy",
"db:baseline": "bash scripts/baseline-db.sh",
"db:seed": "pnpm --filter @t09-project-2/db run db:seed",
- "db:studio": "pnpm --filter @t09-project-2/db run db:studio"
+ "db:studio": "pnpm --filter @t09-project-2/db run db:studio",
+ "azure:register-providers": "bash scripts/register-azure-providers.sh"
},
"devDependencies": {
"@tanstack/eslint-config": "latest",
diff --git a/scripts/register-azure-providers.sh b/scripts/register-azure-providers.sh
new file mode 100755
index 0000000..8773a92
--- /dev/null
+++ b/scripts/register-azure-providers.sh
@@ -0,0 +1,54 @@
+# Generated by GLM 5.3 NVFP4
+
+#!/usr/bin/env bash
+
+# Registers the Azure resource providers (namespaces) used by the Terraform
+# configuration in infra/.
+#
+# Why: azurerm 5.0 defaults to `resource_provider_registrations = "none"`,
+# meaning the Terraform provider no longer registers resource providers
+# automatically (v4 did). On a fresh subscription the first apply fails with
+# MissingSubscriptionRegistration ... 409 (409 Conflict)
+# The provider blocks in infra/ now list their namespaces explicitly, but this
+# script is the deterministic one-time setup for a new subscription: it uses
+# `--wait`, so registration has fully completed before Terraform runs.
+#
+# Permissions: registering a provider requires the `/register/action` action
+# at subscription scope. Owner and Contributor have it; on Azure for Students
+# your account is Owner of the subscription. Run once per subscription.
+
+set -euo pipefail
+
+if ! command -v az >/dev/null 2>&1; then
+ echo "The Azure CLI (az) is required: https://aka.ms/azure-cli" >&2
+ exit 1
+fi
+
+PROVIDERS=(
+ "Microsoft.Resources" # resource groups (bootstrap)
+ "Microsoft.Storage" # storage account for Terraform state (bootstrap)
+ "Microsoft.App" # Container Apps environment, apps, jobs, custom domains, certs
+ "Microsoft.ContainerRegistry" # Azure Container Registry
+ "Microsoft.ManagedIdentity" # user-assigned managed identity for ACR pulls
+ "Microsoft.Authorization" # role assignments (AcrPull)
+ "Microsoft.Network" # VNet + subnet for the container app environment
+ "Microsoft.OperationalInsights" # Log Analytics workspace for app/job logs
+ # Add when introduced:
+ # "Microsoft.DBforPostgreSQL" # Azure Database for PostgreSQL (if replacing Neon)
+ # "Microsoft.KeyVault" # Key Vault (secrets / certificates)
+)
+
+echo "Subscription: $(az account show --query name -o tsv)"
+echo
+
+for provider in "${PROVIDERS[@]}"; do
+ echo "Registering ${provider}..."
+ az provider register --namespace "${provider}" --wait
+done
+
+echo
+echo "Registration states:"
+for provider in "${PROVIDERS[@]}"; do
+ state="$(az provider show --namespace "${provider}" --query registrationState -o tsv)"
+ printf ' %-30s %s\n' "${provider}" "${state}"
+done
From 61048b0118d65546b817fd062b67e73b23f9736a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Henry=20Gr=C3=A6sberg?=
Date: Tue, 10 Nov 2026 20:25:05 +0100
Subject: [PATCH 3/7] Add prev handing commit hash to readme
---
README.md | 12 +++++++++++-
1 file changed, 11 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index d4d10a0..9e8632a 100644
--- a/README.md
+++ b/README.md
@@ -160,7 +160,7 @@ Most recent commit hash at time of hand-in: [`054801b`](https://git.ntnu.no/IT28
Handed in TDB
-Most recent commit hash at time of hand-in: [`TDB`](<>) (Will update if there is a more recent version in the future)
+Most recent commit hash at time of hand-in: [`6ed8255`](https://git.ntnu.no/IT2810-H26/T09-Project-2/tree/6ed82554ed10bd7797feb405d391db1cc6071e92)
### Final hand-in of project 2
@@ -177,3 +177,13 @@ The first hand-in was only our initial plan and consited of setting up tanstack
### 2nd hand-in:
We have mostly used AI as a sparring partner in the development of this project, mainly focused on deployment and debugging. For deployment to the VM, GLM 5.3 NVFP4 running on NTNU's LLM servers was used to help generate the script setting up the postgres instance, and to create the deploy pipeline. As can be seen in the `.agents` folder, AI was also used to create more descriptive PR descriptions, but this skill was used with heavy human supervision, and often updated manually afterwards. AI was not used for generating much code, but rather used to help debug issues we were running into, and as a supplement to documentation when it proved lacking/hard to navigate (e.g. when using Pothos to get a DateTime in the GraphQL schema, we could not find any mention of the "expose" function without the expose helper (such as "exposeString" or "exposeFloat"), and thus used AI to figure out how to expose a DateTime object in the schema).
+
+### 3rd hand-in
+
+Essentially the same as handin 2
+
+### 4th hand-in
+
+While outside the scope of the course, we did deploy to Azure. The IaC (terraform code) was initially developed without help from AI, but debugged and refined with AI. The dockerfiles are AI generated, though explicitly guided to use the human-written dockerfiles from monoweb (The student association Online's website [(github.com/dotkom/monoweb)](https://github.com/dotkom/monoweb)) as referance.
+
+cont...
From 37f45a0f0fd7195021276c80675a4c2b067ff6da Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Henry=20Gr=C3=A6sberg?=
Date: Tue, 10 Nov 2026 20:26:12 +0100
Subject: [PATCH 4/7] Fix migrate job run in CI: update image before starting
`az containerapp job start --image` creates an execution that drops the
job's command and env overrides, so the container ran the image's
default CMD and crashed without DATABASE_URL. Update the job's template
image first, then start it plainly so the full job template applies.
Verified working end-to-end in Azure (execution gspotter-migrate-rzw4u6i).
---
.github/workflows/deploy-azure.yaml | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/deploy-azure.yaml b/.github/workflows/deploy-azure.yaml
index 0eee7ea..ef4ad18 100644
--- a/.github/workflows/deploy-azure.yaml
+++ b/.github/workflows/deploy-azure.yaml
@@ -115,8 +115,13 @@ jobs:
- name: Run database migrations
run: |
set -euo pipefail
- az containerapp job start -n "$MIGRATE_JOB" -g "$RESOURCE_GROUP" \
+ # `job start --image` is broken: it creates an execution that drops the
+ # job's command and env (the container runs the image's default CMD and
+ # crashes without DATABASE_URL). Update the job's image first, then
+ # start it with no overrides so the full job template applies.
+ az containerapp job update -n "$MIGRATE_JOB" -g "$RESOURCE_GROUP" \
--image "$ACR_SERVER/gspotter-backend:$GITHUB_SHA"
+ az containerapp job start -n "$MIGRATE_JOB" -g "$RESOURCE_GROUP"
# Poll the newest execution until it finishes.
for _ in $(seq 1 60); do
From 40ff4b42611dc91d35638e319ffa08c117ce295c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Henry=20Gr=C3=A6sberg?=
Date: Wed, 11 Nov 2026 00:48:48 +0100
Subject: [PATCH 5/7] Add CI identity federated to git.ntnu.no GitHub Actions
The NTNU Entra tenant blocks app registrations, so a client-secret
service principal is not possible. Instead, a user-assigned managed
identity (id-gspotter-ci) gets a federated identity credential for
this repo's main branch via git.ntnu.no's Actions OIDC issuer, plus
Contributor on the resource group and AcrPush on the registry.
Also: document the required GitHub secrets and update the deploy
workflow's login to OIDC (client-id/tenant-id/subscription-id).
---
.github/workflows/deploy-azure.yaml | 24 +++++++----------
infra/prod/outputs.tf | 5 ++++
infra/prod/roles.tf | 41 +++++++++++++++++++++++++++++
3 files changed, 56 insertions(+), 14 deletions(-)
diff --git a/.github/workflows/deploy-azure.yaml b/.github/workflows/deploy-azure.yaml
index ef4ad18..9ccd869 100644
--- a/.github/workflows/deploy-azure.yaml
+++ b/.github/workflows/deploy-azure.yaml
@@ -9,20 +9,16 @@
# Prerequisites (one-time):
# - terraform apply in infra/ (creates ACR, DB, apps with placeholder images,
# the migrate job, and the Cloudflare DNS/certificate setup)
-# - Azure login secrets for the runner:
-# AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID
-# (federated OIDC; requires a user-assigned managed identity
-# or app registration with an added federated credential for
-# this repo - see azure/login@v2 docs)
-# On git.ntnu.no (no OIDC): store the JSON output of
-# `az ad sp create-for-rbac --name gspotter-ci` as AZURE_CREDENTIALS
-# and change the login step to: with: { creds: secrets.AZURE_CREDENTIALS }
-# - The CI principal must be able to push to the registry and update the
-# apps/job. One-time grants for its object ID:
-# az role assignment create --assignee --role AcrPush \
-# --scope $(az acr show -n --query id -o tsv)
-# az role assignment create --assignee --role Contributor \
-# --resource-group rg-gspotter
+# - Azure login secrets for the runner (repo or organization secrets):
+# AZURE_CLIENT_ID = output ci_identity_client_id of infra/prod (the
+# client ID of the id-gspotter-ci managed identity)
+# AZURE_TENANT_ID = the Entra tenant ID
+# AZURE_SUBSCRIPTION_ID = the subscription ID
+# The identity federates to this repo's main branch via git.ntnu.no's
+# Actions OIDC issuer, so no client secret exists or is needed.
+# - The identity's role grants (Contributor on the resource group,
+# AcrPush on the registry) are managed by the Terraform config, not
+# by hand.
# - The runner needs the Docker CLI (preinstalled on ubuntu-latest).
#
# Rollback: `az containerapp revision list` + `az containerapp revision
diff --git a/infra/prod/outputs.tf b/infra/prod/outputs.tf
index e6738b4..7581243 100644
--- a/infra/prod/outputs.tf
+++ b/infra/prod/outputs.tf
@@ -15,3 +15,8 @@ output "app_url" {
output "migrate_job_name" {
value = azurerm_container_app_job.migrate.name
}
+
+output "ci_identity_client_id" {
+ description = "Client ID of the CI managed identity (AZURE_CLIENT_ID secret in GitHub Actions)."
+ value = azurerm_user_assigned_identity.ci.client_id
+}
diff --git a/infra/prod/roles.tf b/infra/prod/roles.tf
index daa3cfa..b357128 100644
--- a/infra/prod/roles.tf
+++ b/infra/prod/roles.tf
@@ -15,3 +15,44 @@ resource "time_sleep" "acr_rbac" {
depends_on = [azurerm_role_assignment.acr_pull]
create_duration = "60s"
}
+
+# ------------------------------------------------------------------
+# CI identity. The NTNU Entra tenant blocks app registrations
+# (allowedToCreateApps = false), so a client-secret service principal is
+# not an option. A user-assigned managed identity with a federated
+# identity credential only needs ARM permissions, and git.ntnu.no
+# exposes a GitHub Actions OIDC issuer at
+# https://git.ntnu.no/_services/token (verified via its
+# .well-known/openid-configuration).
+resource "azurerm_user_assigned_identity" "ci" {
+ name = "id-gspotter-ci"
+ resource_group_name = data.azurerm_resource_group.rg_gspotter.name
+ location = data.azurerm_resource_group.rg_gspotter.location
+}
+
+# Federates the identity to workflow runs on the main branch of the
+# git.ntnu.no repository. The subject claim format matches GitHub's:
+# repo:/:ref:refs/heads/main.
+resource "azurerm_federated_identity_credential" "ci" {
+ name = "git-ntnu-no-main"
+ user_assigned_identity_id = azurerm_user_assigned_identity.ci.id
+ audience = ["api://AzureADTokenExchange"]
+ issuer = "https://git.ntnu.no/_services/token"
+ subject = "repo:IT2810-H26/T09-Project-2:ref:refs/heads/main"
+}
+
+# Deploy the container apps and the migrate job.
+resource "azurerm_role_assignment" "ci_contributor" {
+ scope = data.azurerm_resource_group.rg_gspotter.id
+ role_definition_name = "Contributor"
+ principal_id = azurerm_user_assigned_identity.ci.principal_id
+ principal_type = "ServicePrincipal"
+}
+
+# Push images to the registry (docker buildx --push / az acr login).
+resource "azurerm_role_assignment" "ci_acr_push" {
+ scope = azurerm_container_registry.registry.id
+ role_definition_name = "AcrPush"
+ principal_id = azurerm_user_assigned_identity.ci.principal_id
+ principal_type = "ServicePrincipal"
+}
From da877f1c9df7dabcd3a668d842fd3d21bd7c068b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Henry=20Gr=C3=A6sberg?=
Date: Wed, 11 Nov 2026 00:50:25 +0100
Subject: [PATCH 6/7] Update mock location notice to include link to public
https deployment
---
apps/frontend/src/components/MockLocationNotice.tsx | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/apps/frontend/src/components/MockLocationNotice.tsx b/apps/frontend/src/components/MockLocationNotice.tsx
index 83b6f84..1e1a750 100644
--- a/apps/frontend/src/components/MockLocationNotice.tsx
+++ b/apps/frontend/src/components/MockLocationNotice.tsx
@@ -27,6 +27,10 @@ export default function MockLocationNotice() {
the repository
{' '}
+ or the
+
+ public Azure deployment
+ {' '}
and run the website on localhost.
From 44f880c66dcba723818daa81b2f9dda284b644df Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Henry=20Gr=C3=A6sberg?=
Date: Wed, 11 Nov 2026 00:51:35 +0100
Subject: [PATCH 7/7] lint
---
apps/frontend/src/components/MockLocationNotice.tsx | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/apps/frontend/src/components/MockLocationNotice.tsx b/apps/frontend/src/components/MockLocationNotice.tsx
index 1e1a750..0bacbdd 100644
--- a/apps/frontend/src/components/MockLocationNotice.tsx
+++ b/apps/frontend/src/components/MockLocationNotice.tsx
@@ -28,8 +28,12 @@ export default function MockLocationNotice() {
the repository
{' '}
or the
-
- public Azure deployment
+
+ public Azure deployment
{' '}
and run the website on localhost.