AZURE → STACKIT · CASE STUDY
Azure web app, database and Key Vault on STACKIT
One estate of 15 Azure resources: a Node.js web app on App Service, a private PostgreSQL server, a Key Vault, a storage account for uploads and a network with a security group. StackToIT assessed it on 4 November 2026 and drafted 7 of 8 units. This case study shows the figures, the excerpts that matter and the decisions the architects own.
The run in numbers
A small estate written for this page, assessed offline. The numbers come from the console summary, assessment.json and the execution package of the run.
| Figure | Value | What it means |
|---|---|---|
| Source resources and units | 15 resources, 8 units | The subnet association of the security group, the database, the secret and the blob container fold into the unit they belong to; each subnet is a unit of its own. Resource group, role assignment and App Service plan are checked as supporting settings. |
| Classes | 5 RECONFIGURE, 3 REPLATFORM | No REDESIGN and no MANUAL_REVIEW in this estate. The effort sits in settings, not in service classes. |
| Drafted | 7 units drafted, 1 needs no resource | The virtual network needs no resource, because its subnets become STACKIT networks. |
| OpenTofu steps | 8 resource blocks | Two networks, a security group and one rule, a PostgreSQL Flex instance, a bucket, a Secrets Manager instance and a Cloud Foundry organization. |
| Manual steps | 30 steps in 18 work packages | Each has an owner role and a done-when condition. |
| Open critical settings | 33 settings of 8 source resources | Settings the draft does not carry, listed property by property. |
| Refused | 2 steps | The target has no equivalent: Key Vault deletion protection and the rule priority of the security group. |
| Decisions to make | 7, one of which gates steps | The IPv4 default egress rule of the new security group. |
The migration package is partially executable and not approved: the run itself says that none of the OpenTofu steps can run as written, because values such as project_id and the database access list are still missing.
The database: a private server becomes an access list
Left: the PostgreSQL Flexible Server of the input, private through a delegated subnet. Right: the instance block the engine wrote. The review comments above the block are left out here.
resource "azurerm_postgresql_flexible_server" "shop" {
name = "psql-shop-gwc"
resource_group_name = azurerm_resource_group.shop.name
location = azurerm_resource_group.shop.location
version = "16"
sku_name = "GP_Standard_D4s_v3"
storage_mb = 131072
backup_retention_days = 14
administrator_login = "shopadmin"
administrator_password = var.db_admin_password
delegated_subnet_id = azurerm_subnet.data.id
public_network_access_enabled = false
zone = "1"
}resource "stackit_postgresflex_instance" "shop" {
project_id = var.project_id
name = "psql-shop-gwc"
version = "16"
flavor_id = "4.32"
storage = {
class = var.postgresflex_storage_class
size = 128
}
backup_schedule = "0 2 * * *"
network = {
acl = var.shop_allowed_cidrs
}
retention_days = 32
}Flavor from the source size
The source SKU has 4 vCPU and 16 GiB. The draft takes flavor 4.32, the smallest single-node PostgreSQL Flex flavor in eu01 that covers it, with 4 vCPU and 32 GiB. Size it again from measured load if memory matters for the cost.
Backups
The source keeps backups for 14 days. The draft sets retention_days to 32, which the run records as TRANSFORMED, and a daily backup at 02:00 as a default to confirm.
Private becomes an access list
The delegated subnet, the public-network switch, the zone and the administrator login are not carried. Clients reach the instance through its ACL, listed in the variable shop_allowed_cidrs, which has no default. The network exposure is a REVIEW item: decide the endpoint scope before you apply.
The vault: secrets move, the access model changes
Left: the Key Vault and its secret. Right: the Secrets Manager instance the engine wrote. The secret itself is not in the draft.
resource "azurerm_key_vault" "shop" {
name = "kv-shop-gwc"
location = azurerm_resource_group.shop.location
resource_group_name = azurerm_resource_group.shop.name
tenant_id = var.tenant_id
sku_name = "standard"
rbac_authorization_enabled = true
purge_protection_enabled = true
soft_delete_retention_days = 30
}
resource "azurerm_key_vault_secret" "db_password" {
name = "db-password"
value = var.db_admin_password
key_vault_id = azurerm_key_vault.shop.id
}resource "stackit_secretsmanager_instance" "migrated_secrets" {
project_id = var.project_id
name = "migrated-secrets"
}One instance holds all secrets
You recreate each secret as a key in the instance. Name, tenant, SKU, access policies, network rules and retention of the vault are not generated.
Every consumer gets new credentials
Key Vault grants access to Microsoft Entra principals through access policies or Azure RBAC. On STACKIT, access goes to Secrets Manager users or service accounts, so the web app needs new credentials and a code change.
Deletion protection is refused
The vault has purge protection and 30 days of soft delete. The run quotes the STACKIT statement that individual secrets cannot be restored and records the setting as refused. Plan your own protection, for example versioned secrets and a backup routine.
The network rule: allow-only, and open egress
Left: the security group of the data subnet with one rule. Right: the group and the rule the engine wrote. The comments in the file list what Azure added silently.
resource "azurerm_network_security_group" "data" {
name = "nsg-data"
location = azurerm_resource_group.shop.location
resource_group_name = azurerm_resource_group.shop.name
security_rule {
name = "allow-postgres-from-app"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "5432"
source_address_prefix = "10.40.1.0/24"
destination_address_prefix = "*"
}
}resource "stackit_security_group" "data" {
project_id = var.project_id
name = "nsg-data"
}
resource "stackit_security_group_rule" "data_allow_postgres_from_app" {
project_id = var.project_id
security_group_id = stackit_security_group.data.security_group_id
direction = "ingress"
ether_type = "IPv4"
ip_range = "10.40.1.0/24"
port_range = {
max = 5432
min = 5432
}
protocol = {
name = "tcp"
}
}Priorities are gone
STACKIT security groups only allow, and the rules are evaluated together. The priority of the source rule is not carried, and the run refuses it. No deny rule of the scanned estate outranked this allow, but a rule outside the scan might.
Azure default rules are explicit work
Azure adds default rules for traffic inside the virtual network, from the load balancer and to the internet. The run notes that a new STACKIT group has none of these defaults, so each one you still need is a rule you write.
Egress is wider than the source
STACKIT creates an allow-all IPv4 egress rule on every new group, and the draft does not manage it. The Security Architect decides whether to delete it after the apply. This is the one decision that holds plan steps.
The web app: a Cloud Foundry organization
Left: the Linux web app with system-assigned identity, a Key Vault reference and VNet integration. Right: the organization block. The app itself is deployed with cf push.
resource "azurerm_linux_web_app" "shop" {
name = "app-shop-gwc"
resource_group_name = azurerm_resource_group.shop.name
location = azurerm_resource_group.shop.location
service_plan_id = azurerm_service_plan.shop.id
https_only = true
virtual_network_subnet_id = azurerm_subnet.app.id
identity {
type = "SystemAssigned"
}
site_config {
always_on = true
application_stack {
node_version = "20-lts"
}
}
app_settings = {
"DB_HOST" = azurerm_postgresql_flexible_server.shop.fqdn
"DB_NAME" = "shop"
"DB_PASSWORD" = "@Microsoft.KeyVault(SecretUri=https://kv-shop-gwc.vault.azure.net/secrets/db-password/)"
}
}resource "stackit_scf_organization" "migrated_apps" {
project_id = var.project_id
name = "migrated-apps"
}Settings become manifest and bindings
App settings, HTTPS-only, the runtime version and the plan are not generated. They move into the Cloud Foundry manifest and service bindings that you write.
Identity is a decision
The managed identity that read the vault becomes explicit credentials. The run asks the Identity Architect how the app gets its identity; no service account is attached by the draft.
Reaching the database
The VNet integration reached the private database. On STACKIT the app reaches the PostgreSQL instance through its ACL or, where enabled, a Network Area, and you decide which.
How the estate maps
| Azure resource | STACKIT target | Migration class |
|---|---|---|
| Key Vault with its secret | Secrets Manager instance (keys go to STACKIT KMS) | Reconfigure |
| Storage account with a container | Object Storage bucket | Replatform |
| Virtual network | No resource; Network Area when networks must reach each other | Reconfigure |
| Two subnets | Two STACKIT networks with the same IPv4 prefixes | Reconfigure |
| Network security group | Security group and one rule | Reconfigure |
| PostgreSQL Flexible Server with database | PostgreSQL Flex instance | Replatform |
| Linux web app | Cloud Foundry organization | Replatform |
Classes as the run decided them. The run places Key Vault, storage and the virtual network in wave 1, the subnets in wave 2, the security group and the database in wave 3 and the web app in wave 4.
What a missing variable does
The first run left out terraform.tfvars, so var.tenant_id of the Key Vault had no value. The second run supplied it. Both console summaries are copied from the runs.
Source 15 resources · 8 units · visibility: Required input missing
Migration 6 drafted · 1 need no resource · 1 has no draft because an input the source needs is not in the package (of 8 units: 7 the draft considered, 1 has no draft)
Attention 1 variable without a value (var.tenant_id) · 7 decisions to make (1 of the 7 holds plan steps) · 8 units to review · 8 open requests (3 block the draft, marked in inputs-template.yaml)Source 15 resources · 8 units · visibility: Variable unresolved
Migration 7 drafted · 1 need no resource (of 8 units the draft considered)
Attention 7 decisions to make (1 of the 7 holds plan steps) · 8 units to review · 7 open requests (2 block the draft, marked in inputs-template.yaml)The run names what it needs
Without the value, the Key Vault had no draft and the summary said an input the source needs was not in the package. With it, 7 units were drafted.
One variable stays open on purpose
The second run still reads “Variable unresolved”. Its architect report names the variable: db_admin_password, which is sensitive and has no default; the demo’s terraform.tfvars supplies only tenant_id. It does not block a draft here; supply it with --var-file if the units that read it should be assessed completely.
Hand over the variable files
The second run read terraform.tfvars from the input folder without any option; further files are passed with --var-file. Give the engine the same variable files your pipeline uses, so that no unit is left without a draft for want of a value.
The decisions the architects own
Security Architect: default egress
Delete the allow-all IPv4 egress rule of the security group after apply, or accept it. This decision gates steps of the plan.
Network Architect: database exposure
Choose the endpoint scope and list the client prefixes in shop_allowed_cidrs. Left unset, the STACKIT API makes the endpoint public.
Identity Architect: identities
Decide how the web app authenticates to Secrets Manager and how the database users are created with stackit_postgresflex_user.
Platform Architect: data and cutover
Choose a data-movement mechanism for the database and the bucket. The run lists both with the mechanism UNKNOWN and no size, rate or downtime tolerance, because the source does not state them. For the database, the usual routes are pg_dump with pg_restore or logical replication.
Everyone: ownership of the open settings
Close or accept the 33 open settings through the review file, one record per owner role.
Questions architects ask about this estate
Why did the run draft only 7 of 8 units at first?
The Key Vault used a variable, tenant_id, with no value in the package. The unit then had no draft and the summary named the missing input. Supplying the value drafted it.
Does the draft move the secrets?
No. It creates the Secrets Manager instance only. You recreate each secret in it and give the web app credentials to read them.
Is the private database still private?
Not in the same way. The draft restricts the instance with an access list; whether the endpoint is public or scoped to a Network Area is a decision for you, and it must be made before apply.
Can the draft be applied as it stands?
No. The run says none of the steps can run as written, because values such as project_id and the allowed CIDRs are missing and decisions are open. It is a starting point for review, not an approved plan.
Sources
Your next move: STACKIT.
Tell us about your estate. We show you what StackToIT delivers for it: target architecture, Terraform artifacts and a structured migration plan.