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.

FigureValueWhat it means
Source resources and units15 resources, 8 unitsThe 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.
Classes5 RECONFIGURE, 3 REPLATFORMNo REDESIGN and no MANUAL_REVIEW in this estate. The effort sits in settings, not in service classes.
Drafted7 units drafted, 1 needs no resourceThe virtual network needs no resource, because its subnets become STACKIT networks.
OpenTofu steps8 resource blocksTwo networks, a security group and one rule, a PostgreSQL Flex instance, a bucket, a Secrets Manager instance and a Cloud Foundry organization.
Manual steps30 steps in 18 work packagesEach has an owner role and a done-when condition.
Open critical settings33 settings of 8 source resourcesSettings the draft does not carry, listed property by property.
Refused2 stepsThe target has no equivalent: Key Vault deletion protection and the rule priority of the security group.
Decisions to make7, one of which gates stepsThe 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.

Example inputmain.tf
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"
}
STACKIT Terraform by StackToITterraform/data.tf
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.

Example inputmain.tf
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
}
STACKIT Terraform by StackToITterraform/security.tf
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.

Example inputmain.tf
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 = "*"
  }
}
STACKIT Terraform by StackToITterraform/security.tf
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.

Example inputmain.tf
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/)"
  }
}
STACKIT Terraform by StackToITterraform/apps.tf
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 resourceSTACKIT targetMigration class
Key Vault with its secretSecrets Manager instance (keys go to STACKIT KMS)Reconfigure
Storage account with a containerObject Storage bucketReplatform
Virtual networkNo resource; Network Area when networks must reach each otherReconfigure
Two subnetsTwo STACKIT networks with the same IPv4 prefixesReconfigure
Network security groupSecurity group and one ruleReconfigure
PostgreSQL Flexible Server with databasePostgreSQL Flex instanceReplatform
Linux web appCloud Foundry organizationReplatform

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.

First run, no tfvarsconsole
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)
Second run, tenant_id suppliedconsole
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

  1. 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.

  2. 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.

  3. Identity Architect: identities

    Decide how the web app authenticates to Secrets Manager and how the database users are created with stackit_postgresflex_user.

  4. 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.

  5. 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.