DEV Community

Cover image for Azure Cost Optimization: How I Cut a Production Bill by 41% with Cosmos DB Serverless
Prince Raj
Prince Raj

Posted on

Azure Cost Optimization: How I Cut a Production Bill by 41% with Cosmos DB Serverless

A production Azure workload I manage was heading toward a monthly cost of approximately ₹97,000, excluding tax.

Azure OpenAI was the largest individual expense, but changing models could affect product behavior. Instead, I focused on infrastructure that was clearly underused:

  • Cosmos DB provisioned throughput
  • An oversized virtual machine
  • A Premium SSD that did not need premium performance
  • Missing cost budgets and alerts

The result was a verified reduction to approximately ₹57,100 per month, saving about ₹39,900 per month—or 41%.

A further VM resize should bring the total close to ₹50,000 per month, but Azure capacity prevented that part of the change. This article covers what worked, what failed, and how I kept the migration reversible.

All prices in this article are workload-specific, pre-tax estimates. Azure pricing varies by region, currency, agreement and usage.

The original cost breakdown

I used Azure Cost Analysis for August 1–21 and projected the observed spending across a full month.

Resource Observed share Estimated monthly cost
Azure OpenAI 43.9% ₹42,100
Cosmos DB 40.5% ₹38,900
Production VM 13.3% ₹13,406
Premium OS disk 1.7% ₹1,714
Public IP and storage 0.6% ₹590
Total 100% ~₹97,000

azure-cost-before-after

I deliberately left Azure OpenAI deployments and model routing unchanged. The first optimization target was Cosmos DB because it represented more than 40% of the bill while consuming only about 4.45 million RUs per month.

Why serverless made sense

The production database used the Azure Cosmos DB API for MongoDB. It contained:

  • 24 collections
  • Approximately 109 MiB of data
  • 54,000 documents
  • A single Azure region
  • Low and irregular request volume

In provisioned mode, Cosmos DB charges for the throughput made available, even when the application does not use it. In serverless mode, charges are based on consumed request units and storage, with no minimum throughput charge. Microsoft explains the differences in its Cosmos DB cost-planning guidance.

At this workload’s observed consumption, serverless compute was estimated at approximately ₹100–₹110 per month, excluding negligible storage costs. That represented a potential Cosmos DB reduction of roughly 99.7%.

Serverless is not suitable for every application. Important constraints include:

  • It is designed for single-region accounts.
  • It provides up to 5,000 RU/s per physical partition.
  • Latency and throughput are less predictable than dedicated provisioned capacity.
  • Moving an existing provisioned account to serverless requires creating a new account.

cosmos-serverless-migration-sequence

These constraints are documented in the Azure Cosmos DB serverless overview.

Step 1: Define acceptance and rollback criteria

Before changing production, I defined binary acceptance criteria:

  • All seven application containers must be healthy.
  • All 24 collections must exist on the target.
  • Document counts and canonical content hashes must match.
  • Every collection must retain its _id index.
  • No unexpected HTTP 429 or Cosmos throttling errors may remain.
  • Database latency and application error rates must remain within the existing baseline.
  • VM memory must remain below 75%.
  • Sustained CPU must remain below 70%.
  • The application must connect only to the new database after cutover.

The rollback rules were equally important:

  • If parity failed, production would not reopen.
  • If application health regressed, MONGO_URI would be reverted.
  • If the VM or disk change caused a regression, the original SKU would be restored.
  • The old Cosmos account would remain available for at least 72 hours.

Step 2: Reduce waste before starting the migration

Five collections were configured with autoscale maximum throughput of 4,000 RU/s despite low utilization.

I first reduced each maximum to 1,000 RU/s:

az cosmosdb mongodb collection throughput update \
  --resource-group "$RESOURCE_GROUP" \
  --account-name "$SOURCE_ACCOUNT" \
  --database-name "$DATABASE_NAME" \
  --name "$COLLECTION_NAME" \
  --max-throughput 1000
Enter fullscreen mode Exit fullscreen mode

The rollback trigger was simple: restore 4,000 RU/s if any 429 responses appeared or normalized utilization remained above 90%.

This was a safe first step because it reduced spending immediately without moving data.

Step 3: Create a separate serverless account

Azure does not support converting a provisioned Cosmos DB account directly to serverless. I therefore created a new account with:

  • MongoDB API
  • MongoDB server version 7.0
  • Serverless capacity mode
  • The same Azure region
  • Session consistency
  • TLS 1.2
  • Periodic backup every four hours
  • Eight-hour backup retention
  • The same public-network behavior as the source

The Azure portal exposes these settings when creating a new Cosmos DB account. For automation, the equivalent resource can be created through Azure CLI, Bicep, ARM or Terraform using the EnableServerless capability.

The reverse direction—serverless to provisioned—is supported, but Microsoft describes it as irreversible. See the capacity-mode migration guidance.

Step 4: Inventory and back up the source

Before the maintenance window, I recorded:

  • Database and collection names
  • Document count per collection
  • Indexes per collection
  • Total storage size
  • A canonical content hash for each collection
  • The active application connection target

Then I created a compressed logical dump:

mongodump \
  --uri="$SOURCE_MONGO_URI" \
  --db="$DATABASE_NAME" \
  --archive="$BACKUP_PATH" \
  --gzip
Enter fullscreen mode Exit fullscreen mode

The dump was encrypted using AES-256-CBC with PBKDF2:

openssl rand -base64 48 > "$KEY_PATH"

openssl enc \
  -aes-256-cbc \
  -salt \
  -pbkdf2 \
  -iter 200000 \
  -in "$BACKUP_PATH" \
  -out "$BACKUP_PATH.enc" \
  -pass file:"$KEY_PATH"
Enter fullscreen mode Exit fullscreen mode

Both the encrypted archive and its key were restricted to the operating-system owner. I also verified that the encrypted dump could be decrypted and read before trusting it as a recovery point.

Never print Cosmos connection strings or place them in shell history. Retrieve and inject them through a secure secret store such as Azure Key Vault.

Step 5: Rehearse the restore

I ran a complete restore rehearsal while production was still online.

This caught the most important problem in the migration: a normal mongorestore was too aggressive for the serverless account.

The restore encountered Cosmos DB error 16500, indicating throttling. One batch restored 1,595 documents while 405 failed.

Instead of discovering this during downtime, I built a controlled loader that used:

  • Four collection workers
  • Single-document inserts
  • Retries for Cosmos error codes 16500 and 50
  • RetryAfterMs when available
  • Exponential backoff with jitter
  • Idempotent duplicate-key handling
  • Final count and content-hash verification

The simplified retry behavior looked like this:

for attempt in range(MAX_ATTEMPTS):
    try:
        collection.insert_one(document)
        break
    except OperationFailure as error:
        if error.code not in {50, 16500}:
            raise

        retry_after_ms = error.details.get("RetryAfterMs", 0)
        exponential_delay = min(30, 2**attempt)
        delay = max(retry_after_ms / 1000, exponential_delay)
        time.sleep(delay + random.uniform(0, 0.5))
Enter fullscreen mode Exit fullscreen mode

A duplicate-key response after a timeout was treated as an ambiguous success. The final parity check—not the insert response—was the source of truth.

Step 6: Compare semantic content, not raw serialization

Two collections initially produced different raw hashes even though their documents were identical.

The cause was BSON and JSON field ordering. MongoDB objects can serialize equivalent data in different key orders.

I fixed the comparison by:

  1. Sorting documents by _id.
  2. Recursively sorting object keys.
  3. Converting BSON-specific values into deterministic extended JSON.
  4. Hashing the resulting canonical representation.

This produced a semantic comparison rather than a byte-for-byte serialization comparison.

Every collection had to satisfy:

source document count == target document count
source canonical hash == target canonical hash
Enter fullscreen mode Exit fullscreen mode

Step 7: Perform the production cutover

The final cutover sequence was:

  1. Stop services that could produce database writes.
  2. Record final source counts and hashes.
  3. Create the final encrypted logical dump.
  4. Restore the final snapshot into the serverless account.
  5. Verify all collection counts, hashes and _id indexes.
  6. Update only the application’s MONGO_URI.
  7. Keep MONGO_DATABASE and all application APIs unchanged.
  8. Restart the application stack.
  9. Run health, authentication and database-read smoke tests.
  10. Reopen traffic.
  11. Monitor both the old and new accounts for stale clients.

The complete write-maintenance window lasted 309 seconds, a little over five minutes.

The database grew from 54,189 documents during the initial assessment to 54,193 in the final snapshot. Because writes were stopped before the final dump, all 54,193 documents were migrated consistently.

Step 8: Reduce disk and VM costs

The production VM used Standard_D4s_v3, but historical utilization showed:

  • 1.09% average CPU
  • Approximately 1 GiB of 16 GiB memory in use

The intended replacement was Standard_D2as_v5, which would reduce estimated compute cost from approximately ₹13,406 to ₹6,005 per month.

The normal sequence is:

az vm deallocate \
  --resource-group "$RESOURCE_GROUP" \
  --name "$VM_NAME"

az vm resize \
  --resource-group "$RESOURCE_GROUP" \
  --name "$VM_NAME" \
  --size Standard_D2as_v5

az vm start \
  --resource-group "$RESOURCE_GROUP" \
  --name "$VM_NAME"
Enter fullscreen mode Exit fullscreen mode

However, Azure returned SkuNotAvailable. A second suitable SKU also failed because the region did not have live capacity.

I therefore restored the original VM size and restarted all application containers. A SKU appearing in the available resize list does not guarantee that Azure has capacity when the resize is attempted.

The OS disk conversion did succeed. I changed the 128-GB disk from Premium SSD P10 to Standard SSD E10:

az vm deallocate \
  --resource-group "$RESOURCE_GROUP" \
  --name "$VM_NAME"

az disk update \
  --resource-group "$RESOURCE_GROUP" \
  --name "$OS_DISK_NAME" \
  --sku StandardSSD_LRS

az vm start \
  --resource-group "$RESOURCE_GROUP" \
  --name "$VM_NAME"
Enter fullscreen mode Exit fullscreen mode

This retained the same nominal 500 IOPS and 100 MB/s tier while reducing estimated disk cost from ₹1,714 to approximately ₹918 per month. Microsoft documents the required stop-update-start process in its managed disk type conversion guide.

Step 9: Add cost governance

Optimization without monitoring is temporary.

I added consistent tags to the resources:

Environment = production
Project     = application-name
CostCenter  = engineering
Enter fullscreen mode Exit fullscreen mode

I also created a ₹55,000 monthly resource-group budget with:

  • Actual-cost alert at 50%
  • Actual-cost alert at 75%
  • Actual-cost alert at 90%
  • Actual-cost alert at 100%
  • Forecast-cost alert at 90%

The budget does not stop resources automatically. It provides early warning so usage changes can be investigated before the invoice arrives.

Verification results

The final checks produced the following results:

Check Result
Seven application containers healthy Passed
Application connected to serverless target Passed
24 collections present Passed
54,193 documents migrated Passed
Canonical content parity Passed
All _id indexes present Passed
Cosmos 429s during final observation Zero
Requests reaching old account Zero
VM memory utilization 6.22%
CPU after restart 24.7% average, 32% maximum
Public IP unchanged Passed
Full production workflow write tests Unavailable

The workflow write tests were unavailable because I did not have safe production fixtures and credentials for creating real candidates, interviews and scorecards. I reported those tests as unavailable rather than treating health checks as equivalent evidence.

Final cost result

Resource Before After
Azure OpenAI ₹42,100 ₹42,100
Cosmos DB ₹38,900 ~₹109
Production VM ₹13,406 ₹13,406
OS disk ₹1,714 ~₹918
Public IP and storage ₹590 ₹590
Total ~₹97,000 ~₹57,100

The verified reduction was approximately:

Monthly saving: ₹39,900
Percentage saving: 41%
Enter fullscreen mode Exit fullscreen mode

If the VM can later be resized to Standard_D2as_v5, the expected total becomes approximately ₹49,700 per month, bringing the saving close to 49%.

What I learned

Rehearse migrations before requesting downtime

The native restore throttling would have extended the maintenance window significantly. A rehearsal turned it into a solved problem before production writes were stopped.

Treat parity as an acceptance gate

A successful restore command does not prove that all data was migrated. Counts, indexes and canonical hashes provide much stronger evidence.

Cloud SKU availability is dynamic

A VM size can be valid for a region and still be unavailable during a particular maintenance window. Always define the rollback path before deallocating the VM.

Serverless is a workload decision, not a universal upgrade

Serverless was effective because the database was small, single-region and lightly used. A high-volume workload requiring predictable latency may be better served by shared autoscale or provisioned throughput.

Keep the old database temporarily

The old account remained available for 72 hours so stale clients could be detected. Deletion required separate approval, and the encrypted migration backup was retained for 30 days.

Conclusion

The largest savings did not require changing application APIs, database names or AI behavior.

The successful strategy was:

  1. Measure actual usage.
  2. Reduce obviously oversized throughput.
  3. Rehearse the database migration.
  4. Require exact data parity.
  5. Make the connection-string change reversible.
  6. Right-size compute and storage independently.
  7. Add budgets so costs cannot drift unnoticed.

The most important part was not moving to serverless. It was building enough verification and rollback evidence to make an aggressive cost reduction safe for production.

Top comments (0)