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
- You are lifting Spring Boot / JVM microservices onto a managed runtime and want Config Server, Service Registry and blue/green slots without operating them.
- You need the service VNet-injected (
networkblock) so apps sit on your own subnets behind a firewall or Application Gateway, not on a public Azure-managed network. - You want centralized configuration from Git (
config_server_git_setting) — a single repo ofapplication.ymlprofiles consumed by every app — provisioned identically across dev/test/prod. - You want each app to carry a system-assigned identity so it can read secrets from Key Vault by RBAC instead of baking connection strings into env vars.
- You do not want this module to own the VNet, the Key Vault or the Git repo — those are shared platform resources passed in by ID/URI. Use it strictly for the Spring runtime plus its first app and deployment.
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 config — live/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 config — live/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
- VNet-inject and keep apps internal. Provide the
networkblock with two dedicated empty subnets and three non-overlapping CIDR ranges, leaveapp_is_public = falseandlog_stream_public_endpoint_enabled = false, and front the instance with Application Gateway + WAF so the public attack surface is one audited edge — not every microservice. - Let the app carry an identity; reference secrets from Key Vault. Keep
app_identity_enabled = trueand grant the exportedapp_identity_principal_idaKey Vault Secrets Userrole, so connection strings and API keys are read at runtime instead of being inlined intoenvironment_variables(which land in plaintext in state). - Allowlist egress, don’t open the world. Wire
outbound_public_ip_addressesinto your database firewall rules (as in the example) so downstream Postgres/SQL only accepts traffic from the instance’s known egress IPs. - Right-size CPU/memory quota and JVM heap together. Match
jvm_optionsheap (-Xmx) to thememoryquota with headroom for metaspace and off-heap — e.g.-Xmx1536magainst2Gi— and prefer scalinginstance_countover oversized single instances; Spring Apps bills on vCPU/GB-hour, so idle over-provisioning is pure waste. - Use blue/green slots for customer-facing apps. Stage a
greendeployment withset_active_deployment = false, validate it, then promote —Standard(S0) is required for staging slots, whileBasic(B0) suits dev/test where a hard restart is acceptable. - Standardize naming and pin the module. Name instances
<org>-spring-<env>and apps<workload>-<role>(e.g.orders-api) so the estate stays legible, and always consume the module by an immutable?ref=vX.Y.Ztag so a runtime or quota change ships through a reviewed version bump.