IaC Azure

Terraform Module: Azure Spring Apps — a VNet-injected Spring runtime with app + deployment baked in

Quick take — A reusable hashicorp/azurerm ~> 4.0 Terraform module for azurerm_spring_cloud_service: VNet injection, Git config server, an app with managed identity and its Java deployment, all var-driven. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "azurerm" {
  features {}
}

module "spring_apps" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-spring-apps?ref=v1.0.0"

  name                = "..."  # Service instance name (4-32 chars, lowercase + hyphens,…
  resource_group_name = "..."  # Resource group hosting the service, app and deployment.
  location            = "..."  # Azure region for the instance.
  app_name            = "..."  # Name of the first Spring Cloud app.
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

Azure Spring Apps (the managed service still surfaced in Terraform as azurerm_spring_cloud_service) is a managed runtime for Spring Boot and polyglot JVM workloads. It gives you the things a Spring estate normally bolts on by hand — a Spring Cloud Config server backed by Git, a Service Registry for discovery, per-app autoscaling, blue/green deployment slots and log streaming — without you running Eureka, Config Server or a Kubernetes cluster yourself. You hand it a JAR or a container, a CPU/memory quota and some JVM options, and it schedules and routes the workload.

The catch is that a production Spring Apps instance is never just the azurerm_spring_cloud_service resource on its own. To be useful it needs at least one azurerm_spring_cloud_app with a managed identity (so the app can reach Key Vault and other Azure services), a azurerm_spring_cloud_java_deployment that actually defines the CPU/memory quota, instance count and jvm_options, and — when staging is in play — a azurerm_spring_cloud_active_deployment to flip a green slot live. Most real instances also live inside a VNet via the network block and pull centralized configuration from a Git config_server_git_setting. This module wraps all of that so every Spring Apps environment in the estate gets the same VNet injection, the same Git config-server wiring, the same identity-on-the-app pattern and the same deployment shape — driven entirely by variables.

When to use it

Module structure

terraform-module-azure-spring-apps/
├── versions.tf      # provider + version pins
├── main.tf          # service + app + java deployment + active deployment
├── variables.tf     # var-driven inputs with validation
└── outputs.tf       # service id, app fqdn/url, identity principal, egress IPs

versions.tf

terraform {
  required_version = ">= 1.6.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}

main.tf

# The managed Spring runtime. VNet injection and the Git config server are
# both optional blocks driven by inputs.
resource "azurerm_spring_cloud_service" "this" {
  name                = var.name
  resource_group_name = var.resource_group_name
  location            = var.location
  sku_name            = var.sku_name

  # Only valid on the Enterprise (E0) tier; null on S0/B0.
  build_agent_pool_size              = var.sku_name == "E0" ? var.build_agent_pool_size : null
  log_stream_public_endpoint_enabled = var.log_stream_public_endpoint_enabled
  service_registry_enabled           = var.service_registry_enabled

  # VNet injection: place app + runtime traffic on your own subnets.
  dynamic "network" {
    for_each = var.network == null ? [] : [var.network]
    content {
      app_subnet_id                   = network.value.app_subnet_id
      service_runtime_subnet_id       = network.value.service_runtime_subnet_id
      cidr_ranges                     = network.value.cidr_ranges
      app_network_resource_group      = network.value.app_network_resource_group
      service_runtime_network_resource_group = network.value.service_runtime_network_resource_group
    }
  }

  # Centralized Spring Cloud Config from a Git repo.
  dynamic "config_server_git_setting" {
    for_each = var.config_server_git == null ? [] : [var.config_server_git]
    content {
      uri          = config_server_git.value.uri
      label        = config_server_git.value.label
      search_paths = config_server_git.value.search_paths

      dynamic "http_basic_auth" {
        for_each = config_server_git.value.username == null ? [] : [1]
        content {
          username = config_server_git.value.username
          password = config_server_git.value.password
        }
      }
    }
  }

  tags = var.tags
}

# The first application. Carries a system-assigned identity for Key Vault/RBAC.
resource "azurerm_spring_cloud_app" "this" {
  name                = var.app_name
  resource_group_name = var.resource_group_name
  service_name        = azurerm_spring_cloud_service.this.name

  is_public               = var.app_is_public
  https_only              = var.app_https_only
  public_endpoint_enabled = var.app_public_endpoint_enabled

  dynamic "identity" {
    for_each = var.app_identity_enabled ? [1] : []
    content {
      type = "SystemAssigned"
    }
  }
}

# The Java deployment: where CPU/memory quota, instance count and JVM opts live.
resource "azurerm_spring_cloud_java_deployment" "this" {
  name                = var.deployment_name
  spring_cloud_app_id = azurerm_spring_cloud_app.this.id

  instance_count        = var.instance_count
  jvm_options           = var.jvm_options
  runtime_version       = var.runtime_version
  environment_variables = var.environment_variables

  quota {
    cpu    = var.cpu
    memory = var.memory
  }
}

# Promote this deployment to production (the blue/green "live" pointer).
resource "azurerm_spring_cloud_active_deployment" "this" {
  count                   = var.set_active_deployment ? 1 : 0
  spring_cloud_app_id     = azurerm_spring_cloud_app.this.id
  deployment_name         = azurerm_spring_cloud_java_deployment.this.name
}

variables.tf

variable "name" {
  type        = string
  description = "Name of the Spring Apps (Spring Cloud) service instance."

  validation {
    condition     = can(regex("^[a-z][a-z0-9-]{2,30}[a-z0-9]$", var.name))
    error_message = "name must be 4-32 chars, lowercase alphanumeric and hyphens, start with a letter and not end with a hyphen."
  }
}

variable "resource_group_name" {
  type        = string
  description = "Resource group that hosts the service, app and deployment."
}

variable "location" {
  type        = string
  description = "Azure region for the Spring Apps service instance."
}

variable "sku_name" {
  type        = string
  default     = "S0"
  description = "SKU: B0 (Basic), S0 (Standard) or E0 (Enterprise). Enterprise unlocks the managed build service."

  validation {
    condition     = contains(["B0", "S0", "E0"], var.sku_name)
    error_message = "sku_name must be one of: B0, S0, E0."
  }
}

variable "build_agent_pool_size" {
  type        = string
  default     = "S1"
  description = "Build service agent pool size (Enterprise/E0 only): S1, S2, S3, S4 or S5. Ignored on B0/S0."

  validation {
    condition     = contains(["S1", "S2", "S3", "S4", "S5"], var.build_agent_pool_size)
    error_message = "build_agent_pool_size must be one of: S1, S2, S3, S4, S5."
  }
}

variable "service_registry_enabled" {
  type        = bool
  default     = true
  description = "Enable the managed Service Registry (Eureka-compatible discovery) on the instance."
}

variable "log_stream_public_endpoint_enabled" {
  type        = bool
  default     = false
  description = "Expose the log-streaming endpoint publicly. Keep false for VNet-injected instances."
}

variable "network" {
  type = object({
    app_subnet_id                          = string
    service_runtime_subnet_id              = string
    cidr_ranges                            = list(string)
    app_network_resource_group             = optional(string)
    service_runtime_network_resource_group = optional(string)
  })
  default     = null
  description = "VNet injection. Two dedicated empty subnets plus three non-overlapping /16-ish CIDR ranges for internal services. Null deploys on the Azure-managed network."

  validation {
    condition     = var.network == null ? true : length(var.network.cidr_ranges) == 3
    error_message = "network.cidr_ranges must contain exactly three non-overlapping CIDR blocks (Spring Apps reserves three internal ranges)."
  }
}

variable "config_server_git" {
  type = object({
    uri          = string
    label        = optional(string, "main")
    search_paths = optional(list(string), [])
    username     = optional(string)
    password     = optional(string)
  })
  default     = null
  description = "Spring Cloud Config Git source. Provide username+password for private repos (use a PAT). Null disables the config server. Not supported on Enterprise/E0."

  validation {
    condition     = var.config_server_git == null ? true : can(regex("^(https://|git@)", var.config_server_git.uri))
    error_message = "config_server_git.uri must be an https:// or git@ Git URL."
  }
}

variable "app_name" {
  type        = string
  description = "Name of the first Spring Cloud app created on the instance."
}

variable "app_is_public" {
  type        = bool
  default     = false
  description = "Assign a public endpoint to the app via the built-in ingress. Prefer false behind App Gateway."
}

variable "app_https_only" {
  type        = bool
  default     = true
  description = "Force HTTPS on the app endpoint."
}

variable "app_public_endpoint_enabled" {
  type        = bool
  default     = false
  description = "Expose the app on a public endpoint even when the service is VNet-injected (Standard/Enterprise only)."
}

variable "app_identity_enabled" {
  type        = bool
  default     = true
  description = "Attach a system-assigned managed identity to the app (grant it Key Vault / RBAC roles via the output)."
}

variable "deployment_name" {
  type        = string
  default     = "default"
  description = "Name of the Java deployment slot (e.g. 'default', 'blue', 'green')."
}

variable "instance_count" {
  type        = number
  default     = 1
  description = "Number of app instances in the deployment."

  validation {
    condition     = var.instance_count >= 1 && var.instance_count <= 500
    error_message = "instance_count must be between 1 and 500."
  }
}

variable "cpu" {
  type        = string
  default     = "1"
  description = "CPU quota per instance, e.g. '500m' or '1' up to '4'."

  validation {
    condition     = can(regex("^([0-9]+m|[1-4])$", var.cpu))
    error_message = "cpu must be like '500m' or an integer 1-4."
  }
}

variable "memory" {
  type        = string
  default     = "2Gi"
  description = "Memory quota per instance, e.g. '512Mi' or '2Gi' up to '8Gi'."

  validation {
    condition     = can(regex("^([0-9]+Mi|[1-8]Gi)$", var.memory))
    error_message = "memory must be like '512Mi' or an integer-Gi value 1-8 ('1Gi'..'8Gi')."
  }
}

variable "runtime_version" {
  type        = string
  default     = "Java_17"
  description = "JVM runtime: Java_8, Java_11, Java_17 or Java_21."

  validation {
    condition     = contains(["Java_8", "Java_11", "Java_17", "Java_21"], var.runtime_version)
    error_message = "runtime_version must be one of: Java_8, Java_11, Java_17, Java_21."
  }
}

variable "jvm_options" {
  type        = string
  default     = "-Xms1024m -Xmx1024m"
  description = "JVM options passed to the deployment, e.g. heap sizing and GC flags."
}

variable "environment_variables" {
  type        = map(string)
  default     = {}
  description = "Non-secret environment variables for the deployment (e.g. SPRING_PROFILES_ACTIVE). Use Key Vault references for secrets."
}

variable "set_active_deployment" {
  type        = bool
  default     = true
  description = "Promote this deployment to the live/production slot. Set false when staging a green slot before cutover."
}

variable "tags" {
  type        = map(string)
  default     = {}
  description = "Tags applied to the Spring Apps service instance."
}

outputs.tf

output "id" {
  description = "Resource ID of the Spring Apps (Spring Cloud) service instance."
  value       = azurerm_spring_cloud_service.this.id
}

output "name" {
  description = "Name of the Spring Apps service instance."
  value       = azurerm_spring_cloud_service.this.name
}

output "outbound_public_ip_addresses" {
  description = "Egress public IPs of the instance — allowlist these on databases and downstream firewalls."
  value       = azurerm_spring_cloud_service.this.outbound_public_ip_addresses
}

output "required_network_traffic_rules" {
  description = "Network rules (ports/IPs/direction) the VNet-injected instance requires; feed into NSG/firewall config."
  value       = azurerm_spring_cloud_service.this.required_network_traffic_rules
}

output "app_id" {
  description = "Resource ID of the Spring Cloud app."
  value       = azurerm_spring_cloud_app.this.id
}

output "app_fqdn" {
  description = "Fully-qualified domain name of the app."
  value       = azurerm_spring_cloud_app.this.fqdn
}

output "app_url" {
  description = "Public URL of the app (populated only when the app has a public/public-endpoint assigned)."
  value       = azurerm_spring_cloud_app.this.url
}

output "app_identity_principal_id" {
  description = "Principal (object) ID of the app's system-assigned identity — grant it Key Vault and RBAC roles. Null when identity is disabled."
  value       = try(azurerm_spring_cloud_app.this.identity[0].principal_id, null)
}

output "app_identity_tenant_id" {
  description = "Tenant ID of the app's system-assigned identity. Null when identity is disabled."
  value       = try(azurerm_spring_cloud_app.this.identity[0].tenant_id, null)
}

output "deployment_name" {
  description = "Name of the Java deployment slot."
  value       = azurerm_spring_cloud_java_deployment.this.name
}

How to use it

module "spring_apps" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-spring-apps?ref=v1.0.0"

  name                = "kv-spring-prod"
  resource_group_name = azurerm_resource_group.platform.name
  location            = azurerm_resource_group.platform.location
  sku_name            = "S0"

  service_registry_enabled = true

  # VNet-inject onto two dedicated empty subnets.
  network = {
    app_subnet_id             = azurerm_subnet.spring_apps.id
    service_runtime_subnet_id = azurerm_subnet.spring_runtime.id
    cidr_ranges               = ["10.40.0.0/16", "10.41.0.0/16", "10.42.0.1/16"]
  }

  # Pull every app's config from a private Git repo of YAML profiles.
  config_server_git = {
    uri          = "https://dev.azure.com/teknohut/kloudvin/_git/spring-config"
    label        = "main"
    search_paths = ["orders", "shared"]
    username     = "git"
    password     = data.azurerm_key_vault_secret.config_pat.value
  }

  # First app + its deployment.
  app_name             = "orders-api"
  app_https_only       = true
  app_identity_enabled = true

  deployment_name = "blue"
  instance_count  = 3
  cpu             = "1"
  memory          = "2Gi"
  runtime_version = "Java_17"
  jvm_options     = "-Xms1536m -Xmx1536m -XX:+UseG1GC"

  environment_variables = {
    SPRING_PROFILES_ACTIVE = "production"
  }

  set_active_deployment = true

  tags = {
    workload = "orders"
    env      = "prod"
  }
}

# Grant the app's identity read access to Key Vault using the exported principal ID.
resource "azurerm_role_assignment" "kv_secrets" {
  scope                = azurerm_key_vault.platform.id
  role_definition_name = "Key Vault Secrets User"
  principal_id         = module.spring_apps.app_identity_principal_id
}

# Downstream: allowlist the instance's egress IPs on the Postgres firewall.
resource "azurerm_postgresql_flexible_server_firewall_rule" "spring_egress" {
  for_each = toset(module.spring_apps.outbound_public_ip_addresses)

  name             = "spring-egress-${replace(each.value, ".", "-")}"
  server_id        = azurerm_postgresql_flexible_server.orders.id
  start_ip_address = each.value
  end_ip_address   = each.value
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "azurerm"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...azurerm state bucket/container + key per path...
  }
}

2. Module configlive/prod/spring_apps/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-spring-apps?ref=v1.0.0"
}

inputs = {
  name = "..."
  resource_group_name = "..."
  location = "..."
  app_name = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/spring_apps && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
name string Yes Service instance name (4-32 chars, lowercase + hyphens, starts with a letter).
resource_group_name string Yes Resource group hosting the service, app and deployment.
location string Yes Azure region for the instance.
sku_name string "S0" No B0 (Basic), S0 (Standard) or E0 (Enterprise).
build_agent_pool_size string "S1" No Build service pool size (S1-S5); applied only on E0.
service_registry_enabled bool true No Enable the managed Service Registry (Eureka discovery).
log_stream_public_endpoint_enabled bool false No Expose log streaming publicly; keep false when VNet-injected.
network object null No VNet injection: two subnets + exactly three non-overlapping CIDR ranges. Null = Azure-managed network.
config_server_git object null No Git config-server source (uri, label, search_paths, optional username/password). Not for E0.
app_name string Yes Name of the first Spring Cloud app.
app_is_public bool false No Assign a public endpoint via built-in ingress.
app_https_only bool true No Force HTTPS on the app endpoint.
app_public_endpoint_enabled bool false No Public endpoint even when VNet-injected (Standard/Enterprise).
app_identity_enabled bool true No Attach a system-assigned identity to the app.
deployment_name string "default" No Java deployment slot name (e.g. blue, green).
instance_count number 1 No Number of app instances (1-500).
cpu string "1" No CPU quota per instance (500m or 1-4).
memory string "2Gi" No Memory quota per instance (512Mi or 1Gi-8Gi).
runtime_version string "Java_17" No JVM runtime: Java_8, Java_11, Java_17, Java_21.
jvm_options string "-Xms1024m -Xmx1024m" No JVM options for the deployment.
environment_variables map(string) {} No Non-secret env vars (e.g. SPRING_PROFILES_ACTIVE).
set_active_deployment bool true No Promote this deployment to the live slot.
tags map(string) {} No Tags applied to the service instance.

Outputs

Name Description
id Resource ID of the Spring Apps service instance.
name Name of the service instance.
outbound_public_ip_addresses Egress public IPs — allowlist on databases/firewalls.
required_network_traffic_rules Network rules the VNet-injected instance needs (for NSG/firewall).
app_id Resource ID of the Spring Cloud app.
app_fqdn FQDN of the app.
app_url Public URL of the app (only when a public endpoint is assigned).
app_identity_principal_id Principal ID of the app’s system-assigned identity (grant it Key Vault/RBAC).
app_identity_tenant_id Tenant ID of the app’s system-assigned identity.
deployment_name Name of the Java deployment slot.

Enterprise scenario

A logistics company is migrating a fleet of Spring Boot microservices off self-managed Eureka + Config Server. They stand up one module "spring_apps" per service in a shared VNet: the network block puts every app on internal subnets behind Application Gateway + WAF, config_server_git points all of them at a single Git repo of application.yml profiles (with the PAT pulled from Key Vault), and each app’s exported app_identity_principal_id gets a least-privilege Key Vault Secrets User grant so database credentials never touch Terraform state or env vars. Releases use the deployment_name = "green" / set_active_deployment = false pattern to stage a new build, smoke-test it, then flip it live by promoting the green slot — giving zero-downtime cutover without touching the running blue deployment.

Best practices

TerraformAzureSpring AppsModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading