Cost Optimization Patterns in Terraform: Dev vs Prod SKU Strategies
Cut Azure infrastructure costs by 40%+ using environment-based SKU selection, shared resources, and smart tier strategies in Terraform.
Your development environment costs as much as production. Sound familiar?
Many teams waste thousands of dollars running oversized resources in dev environments that barely see traffic. A production-grade PostgreSQL instance running 24/7 for a single developer. A premium Redis cache for testing. Multiple AI service accounts across environments when one would do.
The solution isn't cutting corners on infrastructure quality. It's using Terraform to implement intelligent resource sizing strategies that maintain performance where it matters while dramatically reducing costs where it doesn't.
After implementing these patterns across multiple Azure projects, we've seen consistent 40-50% cost reductions in development environments without sacrificing developer experience. Let's explore how.
The Real Cost of Environment Parity
The traditional DevOps wisdom of "prod-dev parity" has merit for deployment pipelines and application code. But infrastructure resources? That's where the advice breaks down.
Consider a typical Azure setup:
PostgreSQL Flexible Server(GP_Standard_D2s_v3): ~$100/monthAzure Cache for Redis(Standard C1): ~$70/monthContainer Apps Environment: ~$40/monthAI Servicesaccount: ~$50/month
Multiply by three environments (dev, staging, prod), and you're at ~$780/month before you've deployed a single line of code.
The insight: Development traffic patterns are nothing like production. Dev environments sit idle 60-80% of the time. They don't need premium SKUs, high-availability configurations, or production-scale capacity.
Strategy 1: Environment-Based SKU Selection
The cornerstone of cost optimization is matching resource tiers to actual usage patterns. Terraform locals make this pattern elegant:
locals {
# PostgreSQL SKU configuration:
# Prod: GP_Standard_D2s_v3 (General Purpose, 2 vCores, 8GB RAM) - ~$100/mo
# Dev: B_Standard_B2s (Burstable, 2 vCores, 4GB RAM) - ~$60/mo
# Savings: ~$40/month while maintaining 2 vCore performance
postgres_sku = var.env == "prod" ? "GP_Standard_D2s_v3" : "B_Standard_B2s"
}
resource "azurerm_postgresql_flexible_server" "database" {
name = "${var.prefix}-${var.env}-postgres"
resource_group_name = azurerm_resource_group.main.name
location = var.location
# Dynamic SKU based on environment
sku_name = local.postgres_sku
# Same configuration otherwise
storage_mb = var.env == "prod" ? 32768 : 16384
tags = {
Environment = var.env
}
}
Why This Works
Burstable (B-series) for Dev:
- Accumulates CPU credits during idle time
- Bursts to full performance when needed
- Perfect for intermittent dev workloads
- ~60% cost reduction vs General Purpose
General Purpose (GP-series) for Prod:
- Consistent baseline performance
- Higher memory for production workloads
- Better suited for 24/7 traffic
The key insight: Development databases spend most of their time idle. B-series instances accumulate credits during quiet periods and burst to full speed when developers actually run queries. You get the performance when you need it at a fraction of the cost.
Strategy 2: Shared Resource Architecture
Not every resource needs to be duplicated across environments. Some services are perfect candidates for cross-environment sharing, especially when traffic is minimal.
Case Study: Bing Grounding API
Consider Bing API integration. You're making maybe 100 calls/day in dev, 200 in staging, and 10,000 in production. Why pay for three separate Bing accounts?
# Bing resource group - created once by dev, shared with prod
resource "azurerm_resource_group" "bing_grounding" {
count = var.env == "dev" ? 1 : 0
provider = azurerm.bing
name = "${var.prefix}-shared-bing-rg"
location = "westeurope"
}
# Prod imports existing resource
data "azurerm_resource_group" "bing_grounding_existing" {
count = var.env == "prod" ? 1 : 0
provider = azurerm.bing
name = "${var.prefix}-shared-bing-rg"
}
# Local to access resource regardless of environment
locals {
bing_rg_id = one(azurerm_resource_group.bing_grounding[*].id) != null ?
one(azurerm_resource_group.bing_grounding[*].id) :
one(data.azurerm_resource_group.bing_grounding_existing[*].id)
}
# Bing Grounding resource - created once, shared by all
resource "azapi_resource" "bing_grounding" {
count = var.env == "dev" ? 1 : 0
type = "Microsoft.Bing/accounts@2020-06-10"
name = "${var.prefix}-shared-bing-grounding"
parent_id = local.bing_rg_id
location = "global"
body = {
kind = "Bing.Grounding"
sku = {
name = "G1"
}
}
tags = {
purpose = "shared-grounding"
note = "Shared between dev and prod for cost optimization"
}
}
The Pattern
- Dev environment creates the shared resource
- Other environments import via data sources
- Both reference the same
localvariable - Tag clearly to prevent accidental deletion
Savings: ~$50-100/month depending on the service.
When to share:
- Low-volume APIs (
Bing, weather services, geolocation) - External service integrations with generous quotas
- Monitoring and logging services (with proper filtering)
When NOT to share:
- Databases (isolation is critical)
- Authentication services (security boundary)
- High-traffic APIs (quota contention)
Strategy 3: Redis Tier Selection for Use Case
Azure Cache for Redis offers multiple tiers, but most teams default to Standard or Premium without considering their actual requirements.
locals {
# Redis configuration - Basic tier for job queues
# No data persistence needed (jobs can be re-queued from database)
redis_config = {
sku_name = "Basic"
family = "C"
capacity = 0 # C0 = 250MB (~$16/month)
minimum_tls_version = "1.2"
enable_aof_backup = false # No persistence on Basic tier
}
}
resource "azurerm_redis_cache" "queue" {
name = "${var.prefix}-${var.env}-redis"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
sku_name = local.redis_config.sku_name
family = local.redis_config.family
capacity = local.redis_config.capacity
redis_configuration {
# Critical for job queues - prevent data loss by returning errors
# instead of evicting keys when memory is full
maxmemory_policy = "noeviction"
}
tags = {
UseCase = "job-queue"
}
}
Understanding Redis Tiers
Basic C0 (~$16/month):
- Perfect for: Job queues, rate limiting, simple caching
- Limitations: No replication, no persistence, 250MB
- Reality check: Most job queues use less than 100MB
Standard C1 (~$70/month):
- Perfect for: Session storage, application caching with HA
- Benefits: Replication for availability, persistence options
- When needed: Production data that can't be regenerated
Premium (~$300+/month):
- Perfect for: Critical systems requiring clustering and geo-replication
- Benefits: Multi-region,
VNetinjection, larger sizes - When needed: High-throughput production systems
The Key Question: Can You Regenerate the Data?
For job queues (BullMQ, Celery, etc.), the answer is yes. Jobs are already persisted in your database. Redis is just the work distribution mechanism. If Redis restarts, jobs get re-queued from the source.
This means:
- No need for
AOFpersistence - No need for replication
Basictier is perfect
Savings: ~$54/month per environment (Basic vs Standard C1)
Strategy 4: Container Apps vs AKS Cost Analysis
Kubernetes (AKS) has become the default for container orchestration, but Azure Container Apps offers significant cost advantages for many workloads.
The Cost Comparison
AKS (Kubernetes):
- Cluster management: Free
- Nodes: 3x
Standard_D2s_v3(~$70/month each) = ~$210/month - Load balancer: ~$20/month
- Public IP: ~$3/month
- Total: ~$233/month (before any workloads)
Container Apps:
- No idle cost in Consumption mode
- Pay only for CPU/memory usage: ~$30-60/month typical dev workload
- Managed ingress, scaling, certificates included
- Total: ~$30-60/month
# Container Apps Environment - Consumption plan
resource "azurerm_container_app_environment" "main" {
name = "${var.prefix}-container-environment"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
log_analytics_workspace_id = azurerm_log_analytics_workspace.main.id
# VNet integration - environment-specific
# Dev: Disabled (public endpoints for simplicity)
# Prod: Enabled (private endpoints for security)
infrastructure_subnet_id = var.env == "prod" ? azurerm_subnet.container_apps.id : null
}
resource "azurerm_container_app" "api" {
name = "${var.prefix}-${var.env}-api"
container_app_environment_id = azurerm_container_app_environment.main.id
resource_group_name = azurerm_resource_group.main.name
revision_mode = "Multiple"
template {
container {
name = "api"
image = "${azurerm_container_registry.main.login_server}/api:latest"
cpu = 0.25
memory = "0.5Gi"
env {
name = "DATABASE_URL"
secret_name = "database-url"
}
}
min_replicas = 0 # Scale to zero in dev
max_replicas = var.env == "prod" ? 10 : 2
}
ingress {
external_enabled = true
target_port = 3000
traffic_weight {
latest_revision = true
percentage = 100
}
}
}
When to Choose Container Apps
Perfect for:
- API backends without complex service mesh needs
- Web applications with variable traffic
- Microservices that can scale independently
- Development environments (scale to zero)
Stick with AKS when:
- You need advanced
K8sfeatures (StatefulSets,DaemonSets) - You're already running
K8selsewhere (skill reuse) - You need multi-cloud portability
- You require custom
CNIor networking
Dev environment savings: ~$170-200/month per environment
Strategy 5: PostgreSQL Flexible Server Optimization
PostgreSQL Flexible Server offers better price/performance than older Single Server, but tier selection matters enormously.
locals {
# PostgreSQL configuration with intelligent sizing
postgres_config = {
prod = {
sku_name = "GP_Standard_D2s_v3" # General Purpose, 2 vCore
storage_mb = 32768 # 32 GB
backup_days = 35 # Max retention
geo_backup = true # Geo-redundant
}
dev = {
sku_name = "B_Standard_B2s" # Burstable, 2 vCore
storage_mb = 16384 # 16 GB (plenty for dev)
backup_days = 7 # Minimum retention
geo_backup = false # Local backup only
}
}
}
resource "azurerm_postgresql_flexible_server" "main" {
name = "${var.prefix}-${var.env}-postgres"
resource_group_name = azurerm_resource_group.main.name
location = var.location
version = "14"
# Use environment-specific configuration
sku_name = local.postgres_config[var.env].sku_name
storage_mb = local.postgres_config[var.env].storage_mb
backup_retention_days = local.postgres_config[var.env].backup_days
geo_redundant_backup_enabled = local.postgres_config[var.env].geo_backup
# Authentication
administrator_login = "dbadmin"
administrator_password = random_password.postgres.result
# High availability - prod only
dynamic "high_availability" {
for_each = var.env == "prod" ? [1] : []
content {
mode = "ZoneRedundant"
}
}
}
Cost Breakdown
Development (B_Standard_B2s):
- Base cost: ~$60/month
- Storage (16GB): ~$2/month
- Backups (7 days): ~$1/month
- Total: ~$63/month
Production (GP_Standard_D2s_v3 with HA):
- Base cost: ~$100/month
- Storage (32GB): ~$4/month
- Backups (35 days, geo-redundant): ~$8/month
- High Availability: ~$100/month (doubles base cost)
- Total: ~$212/month
Key optimizations:
- Burstable tier for dev - matches usage pattern
- Minimal backup retention in dev - 7 days vs 35
- No geo-redundancy in dev - local backups sufficient
- No HA in dev - acceptable downtime for development
Savings: ~$149/month per dev environment
Strategy 6: Conditional Resource Creation
Some resources simply aren't needed in all environments. Use Terraform's count and for_each to create resources conditionally.
# Private endpoints - prod only
resource "azurerm_private_endpoint" "redis" {
count = var.env == "prod" ? 1 : 0
name = "${var.prefix}-redis-private-endpoint"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
subnet_id = azurerm_subnet.redis.id
private_service_connection {
name = "${var.prefix}-redis-private-connection"
private_connection_resource_id = azurerm_redis_cache.main.id
subresource_names = ["redisCache"]
is_manual_connection = false
}
private_dns_zone_group {
name = "redis-dns-zone-group"
private_dns_zone_ids = [azurerm_private_dns_zone.redis[0].id]
}
}
# Private DNS zones - prod only
resource "azurerm_private_dns_zone" "redis" {
count = var.env == "prod" ? 1 : 0
name = "privatelink.redis.cache.windows.net"
resource_group_name = azurerm_resource_group.main.name
}
# VNet integration - prod only
resource "azurerm_container_app_environment" "main" {
name = "${var.prefix}-container-environment"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
# Dev: Public endpoints (simpler, cheaper)
# Prod: VNet integration (secure, private endpoints)
infrastructure_subnet_id = var.env == "prod" ? azurerm_subnet.container_apps.id : null
}
# Init containers - dev only
resource "azurerm_container_app" "web" {
# ... main configuration ...
template {
# Database seeding - dev only
dynamic "init_container" {
for_each = var.env == "dev" ? [1] : []
content {
name = "seed"
image = "${azurerm_container_registry.main.login_server}/seed:latest"
cpu = "0.25"
memory = "0.5Gi"
command = ["npm", "run", "db:seed"]
env {
name = "DATABASE_URL"
secret_name = "database-url"
}
}
}
# Main container
container {
# ... configuration ...
}
}
}
Resources to Skip in Dev
Private Endpoints (~$10-15 each/month):
- Dev can use public endpoints with IP restrictions
- Saves network complexity and cost
Storage Accounts for Backups:
BasicRedistier doesn't support persistence- Don't create unused storage accounts
Multiple Regions:
- Dev runs in single region
- Prod might need geo-replication
Load Balancers:
Container Appsincludes ingressAKSdev clusters can useNodePort
Data Seeding:
- Use init containers in dev only
- Production data comes from migrations
Savings: ~$30-50/month per environment
Strategy 7: Real Cost Savings Examples
Let's calculate actual savings from implementing these patterns across a typical Azure application.
Before Optimization: Everything Production-Grade
Development Environment:
PostgreSQLGP_Standard_D2s_v3: ~$100/monthRedisStandard C1: ~$70/monthAKS(3 nodes): ~$210/monthBing APIaccount: ~$50/monthPrivate endpoints(3): ~$30/monthStorage accounts: ~$15/month- Total: ~$475/month
Production Environment:
PostgreSQLGP_Standard_D2s_v3+ HA: ~$212/monthRedisStandard C1: ~$70/monthAKS(3 nodes): ~$210/monthBing APIaccount: ~$50/monthPrivate endpoints(3): ~$30/monthStorage accounts: ~$15/month- Total: ~$587/month
Combined: ~$1,062/month
After Optimization: Right-Sized Infrastructure
Development Environment:
PostgreSQLB_Standard_B2s: ~$63/monthRedisBasic C0: ~$16/monthContainer Apps: ~$50/monthBing API(shared): $0/monthPrivate endpoints: $0/monthStorage accounts: $0/month- Total: ~$129/month
Production Environment:
PostgreSQLGP_Standard_D2s_v3+ HA: ~$212/monthRedisBasic C0: ~$16/month (queue use case)Container Apps: ~$120/monthBing API(shared): ~$50/monthPrivate endpoints(2): ~$20/month- Total: ~$418/month
Combined: ~$547/month
The Numbers
- Total savings: ~$515/month (~$6,180/year)
- Dev environment reduction: 73% (~$346/month saved)
- Prod environment optimization: 29% (~$169/month saved)
- ROI:
Terraformrefactoring pays for itself in week one
Implementation Guidelines
Start with Variables
Create an environment-agnostic configuration:
variable "env" {
description = "Environment (dev, staging, prod)"
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.env)
error_message = "Environment must be dev, staging, or prod."
}
}
variable "prefix" {
description = "Resource prefix for naming"
type = string
}
variable "location" {
description = "Azure region"
type = string
default = "westeurope"
}
Use Locals for Configuration Maps
locals {
# Centralized configuration by environment
env_config = {
dev = {
postgres_sku = "B_Standard_B2s"
postgres_storage = 16384
redis_sku = "Basic"
redis_capacity = 0
container_cpu = 0.25
container_memory = "0.5Gi"
enable_ha = false
enable_vnet = false
min_replicas = 0
max_replicas = 2
}
prod = {
postgres_sku = "GP_Standard_D2s_v3"
postgres_storage = 32768
redis_sku = "Basic"
redis_capacity = 0
container_cpu = 0.5
container_memory = "1Gi"
enable_ha = true
enable_vnet = true
min_replicas = 2
max_replicas = 10
}
}
# Easy access to current environment config
config = local.env_config[var.env]
}
# Use in resources
resource "azurerm_postgresql_flexible_server" "main" {
sku_name = local.config.postgres_sku
storage_mb = local.config.postgres_storage
# ... rest of configuration
}
Tag Everything
locals {
common_tags = {
Environment = var.env
ManagedBy = "terraform"
Project = var.prefix
CostCenter = var.env == "prod" ? "production" : "development"
}
}
# Apply to all resources
resource "azurerm_resource_group" "main" {
tags = local.common_tags
}
Document Your Decisions
locals {
# PostgreSQL SKU configuration:
# Prod: GP_Standard_D2s_v3 (General Purpose, 2 vCores, 8GB RAM) - ~$100/mo
# Dev: B_Standard_B2s (Burstable, 2 vCores, 4GB RAM) - ~$60/mo
# Savings: ~$40/month while maintaining 2 vCore performance
postgres_sku = var.env == "prod" ? "GP_Standard_D2s_v3" : "B_Standard_B2s"
}
Comments explain the "why" behind cost decisions. Future you will appreciate it.
Test in Dev First
# Plan changes in dev
terraform plan -var-file="environments/dev.tfvars"
# Apply to dev
terraform apply -var-file="environments/dev.tfvars"
# Monitor costs for a week
az consumption usage list --start-date 2026-01-03 --end-date 2026-01-10
# If successful, promote to prod
terraform plan -var-file="environments/prod.tfvars"
terraform apply -var-file="environments/prod.tfvars"
Monitor Cost Impact
Use Azure Cost Management to track savings:
# Create cost analysis view
az costmanagement query \
--type Actual \
--dataset-grouping name="ResourceGroup" type="Dimension" \
--timeframe MonthToDate
Tag resources consistently to enable cost tracking by environment, team, or project.
Key Takeaways
Cost optimization isn't about cutting corners. It's about matching resources to actual usage patterns:
- Environment-based SKU selection reduces database costs by 40-60% in dev without impacting performance
- Shared resources for low-volume APIs eliminate duplicate subscription costs
Redistier selection based on use case (job queues don't need persistence) cuts caching costs by 75%Container AppsvsAKSfor simple workloads saves ~$200+/month per environmentPostgreSQLBurstable tier perfect for intermittent dev workloads, accumulates credits during idle time- Conditional resources via
count/for_eachprevents creating unnecessary infrastructure - Real savings of 40-70% in development, 20-30% in production are achievable
The patterns in this guide have been tested in production across multiple Azure projects. They maintain infrastructure quality where it matters while dramatically reducing wasted spend.
Ready to optimize your infrastructure costs? Start with the Azure Pricing Calculator to model your current and optimized architecture. Then explore the Terraform Azure Provider documentation for implementation details.
Your cloud bill will thank you.