I Built a Production-Level AI Resume Analyzer Using 9 Azure Services (Free Tier)

I Built a Production-Level AI Resume Analyzer Using 9 Azure Services (Free Tier)

calendar_todayschedule8 min read
— Originally published at dev.to

Event-Driven Resume Analysis on Azure — What I Built, How It Works, and What Broke Along the Way

Pravin Kshirsagar · Azure & .NET Developer · 2026 · 8 min read

This article is a companion to my YouTube walkthrough of ResumePulse.
Watch the full video here


Why I Built This

A year into working as a .NET developer, I kept hitting the same wall in interviews. I could talk about Azure — Service Bus, Event Grid, Key Vault — but I had nothing deployed. No project. Just theory.

That's a hard place to be when you're sitting across from someone with five years of real-world experience and a GitHub full of shipped projects.

So I stopped reading docs and started building. ResumePulse came out of that — an AI-powered resume analyzer running entirely on Azure's free tier. You upload a resume, paste a job description, and within 30 seconds you get a score, matched and missing skills, a hiring recommendation, and a detailed report straight to your inbox.

Simple problem. Interesting architecture underneath.


The Architecture — Why Event-Driven

The core decision I made early: the API should never wait for AI.

Gemini calls take 20–40 seconds depending on model load. If the API blocked on that, users would stare at a spinner. If the connection dropped mid-analysis, the result would vanish. Fragile.

Instead — the API does two quick things when you hit upload: stores the PDF, drops a message in a queue, then immediately returns a job ID. Your browser polls every 5 seconds. An Azure Function picks up that queue message, handles the AI processing, saves the report, and fires an event when done. The next poll finds it.

Nothing is coupled directly to anything else. The API never calls Gemini. The Function never sends emails. Each piece does one job and communicates through contracts — queue message format and event schema.

Full Flow, Step by Step

  1. Blazor WASM frontend loads from Azure Static Web Apps (global CDN)
  2. User submits email, job description, and PDF resume
  3. POST /api/resume/upload hits the ASP.NET Core API on Azure App Service
  4. API reads all secrets from Azure Key Vault via Managed Identity — zero passwords in code
  5. PDF uploads to Blob Storage (resumes container), message queued in Service Bus
  6. API returns { JobId, Status: "Queued" } — done in under a second
  7. Azure Function wakes on ServiceBusTrigger → downloads PDF → calls Gemini AI
  8. Gemini returns structured JSON: score, matched skills, missing skills, recommendation
  9. Function saves report.json to Blob Storage (reports container)
  10. Function publishes ResumeAnalysisCompleted event to Event Grid Topic
  11. Event Grid routes event to Logic App → Logic App sends formatted email via SendGrid
  12. Blazor's polling loop hits GET /api/resume/report/{jobId} and renders the result

The 9 Azure Services — Plain English

Service What It Does in This Project Free Tier
Blob Storage Stores uploaded PDFs and generated JSON reports 5 GB (12 months)
Service Bus Queues analysis jobs — decouples API from AI processing 10M messages/month
Key Vault Holds all 4 secrets — connection strings, API keys 10K ops/month
App Service Hosts the ASP.NET Core Web API F1 — always free
App Service Plan The compute resource App Service runs on F1 — zero cost
Azure Functions Background processor triggered by Service Bus 1M executions/month
Event Grid Routes "analysis complete" events to Logic App 100K ops/month
Logic Apps Sends the result email via SendGrid — no email code written 4K actions/month
Static Web Apps Hosts Blazor WASM on a global CDN Always free

The Code That Actually Matters

Skipping the boilerplate. Here are the four pieces worth understanding.

1. Key Vault + Managed Identity

var kvUrl = builder.Configuration["KeyVault__Url"];
builder.Configuration.AddAzureKeyVault(new Uri(kvUrl), new DefaultAzureCredential());

Three lines. Every secret in Key Vault is now accessible via Configuration["SecretName"] — exactly like appsettings.json, except nothing sensitive ever touches your code or repo.

DefaultAzureCredential uses your local Azure CLI in development and automatically switches to Managed Identity in Azure. Same code, both environments, no changes needed.

2. Why the Upload Endpoint Returns in Under a Second

var jobId = Guid.NewGuid().ToString();

// Store the PDF
var blobUrl = await _blob.UploadResumeAsync(request.ResumeFile, jobId);

// Queue the job — fire and move on
await _serviceBus.SendMessageAsync(new {
    JobId = jobId,
    BlobUrl = blobUrl,
    Email = request.Email,
    JobDescription = request.JobDescription
});

// Return immediately — no waiting for AI
return Ok(new { JobId = jobId, Status = "Queued" });

The frontend gets this in under a second. It starts polling in the background. The Function is doing the heavy work in parallel. When the report is ready, the next poll finds it.

3. The Blazor File Upload Bug That Cost Me an Hour

In Blazor WASM, file references go null after any await call. No clear error — just a silent crash.

// ❌ Wrong — crashes silently after first await
private async Task OnFileChanged(InputFileChangeEventArgs e) {
    await SomeOtherAsyncThing();
    var stream = e.File.OpenReadStream(); // NullReferenceException
}

// ✅ Correct — read into memory before any await
private async Task OnFileChanged(InputFileChangeEventArgs e) {
    using var ms = new MemoryStream();
    await e.File.OpenReadStream(10 * 1024 * 1024).CopyToAsync(ms);
    _fileBytes = ms.ToArray(); // safe — stored in memory now
}

Read the file into a byte array immediately when the input changes. Use those bytes later. Simple fix once you know the cause.

4. Getting Reliable JSON from Gemini

var config = new GenerateContentConfig {
    ResponseMimeType = "application/json", // critical
    Temperature = 0.1f                     // low = consistent, predictable output
};

Without ResponseMimeType, Gemini sometimes wraps output in markdown code blocks. Your JSON parser throws. You spend 20 minutes confused about why. Setting this tells the model to return raw JSON only.

Also added retry logic for free tier quota limits — up to 3 attempts with delay. All JSON parsing uses TryGetProperty instead of GetProperty to handle missing optional fields cleanly.


Azure Setup — 12 Resources in the Right Order

Step Resource Name Key Setting
1 Resource Group rg-resumepulse-pk Region: East US
2 Storage Account stresumepulsepk LRS redundancy
3 Blob Containers resumes + reports Access: Private
4 Service Bus Namespace sb-resumepulse-pk Basic tier
5 Service Bus Queue resume-analysis-queue Default settings
6 Key Vault kv-resumepulse-pk RBAC access
7 App Service Plan asp-resumepulse-pk F1 Free tier
8 App Service app-resumepulse-pk .NET 9, Linux
9 Function App func-resumepulse-pk Consumption, .NET 9 Isolated
10 Event Grid Topic egt-resumepulse-pk Schema: Event Grid
11 Logic App logic-resumepulse-pk Consumption plan
12 Static Web App swa-resumepulse-pk Free plan

Key Vault Secrets to Add

After creating Key Vault, add these four secrets manually:

  • StorageConnectionString — Storage Account → Access Keys → Connection String
  • ServiceBusConnectionString — Service Bus Namespace → Shared Access Policies → Primary
  • GeminiApiKeyaistudio.google.com → Get API Key
  • EventGridTopicKey — Event Grid Topic → Access Keys → Key 1

⚠️ Critical: After creating App Service and Function App, go to each one's Identity tab and turn on System Assigned Managed Identity. Then go to Key Vault → Access Control (IAM) → Add Role Assignment → Key Vault Secrets User → assign to both. Without this, both apps will fail silently with a vague 403.


Project Structure

ResumePulse/
├── ResumePulse.sln
├── ResumePulse.Api/               ← ASP.NET Core Web API
│   ├── Controllers/ResumeController.cs
│   ├── Services/
│   │   ├── BlobStorageService.cs
│   │   └── ServiceBusService.cs
│   └── Program.cs
├── ResumePulse.Client/            ← Blazor WASM Frontend
│   ├── Pages/Home.razor
│   ├── Services/ResumeApiService.cs
│   └── Program.cs
└── ResumePulse.Functions/         ← Azure Functions
    ├── Functions/ResumeAnalysisFunction.cs
    ├── Services/GeminiService.cs
    ├── host.json
    └── local.settings.json        ← ⚠️ Never commit this

NuGet Packages

API:

dotnet add package Azure.Storage.Blobs
dotnet add package Azure.Messaging.ServiceBus
dotnet add package Azure.Security.KeyVault.Secrets
dotnet add package Azure.Identity
dotnet add package Azure.Extensions.AspNetCore.Configuration.Secrets

Functions:

dotnet add package Microsoft.Azure.Functions.Worker
dotnet add package Microsoft.Azure.Functions.Worker.Extensions.ServiceBus
dotnet add package Azure.Storage.Blobs
dotnet add package Azure.Messaging.EventGrid
dotnet add package Google.Cloud.AIPlatform.V1
dotnet add package UglyToad.PdfPig

Deployment — Three Steps, This Order

Deploy API first, Functions second, Blazor third. The frontend needs the real API URL before publishing.

API to App Service:

cd ResumePulse.Api
dotnet publish -c Release -o ./publish
cd publish && tar -a -c -f ../api-deploy.zip *

az webapp deploy \
  --resource-group rg-resumepulse-pk \
  --name app-resumepulse-pk \
  --src-path ../api-deploy.zip \
  --type zip

Azure Functions:

cd ResumePulse.Functions
dotnet publish -c Release -o ./publish
cd publish && tar -a -c -f ../func-deploy.zip *

az functionapp deployment source config-zip \
  --resource-group rg-resumepulse-pk \
  --name func-resumepulse-pk \
  --src ../func-deploy.zip

Blazor WASM to Static Web Apps:

Update Program.cs with the real API URL first:

var apiBaseUrl = builder.HostEnvironment.IsProduction()
    ? "https://app-resumepulse-pk.azurewebsites.net/"
    : "http://localhost:5257/";

Then deploy:

cd ResumePulse.Client
dotnet publish -c Release -o ./publish
npm install -g @azure/static-web-apps-cli
swa deploy ./publish/wwwroot \
  --deployment-token YOUR-TOKEN-HERE \
  --env production

⚠️ After getting your Static Web App URL, update the CORS policy in your API's Program.cs to include it, then redeploy the API. Without this, the browser blocks every API call — and the console error is easy to misread if you're not expecting it.


What It Actually Costs

For 100 resume analyses: roughly ₹8, almost entirely Gemini (~₹0.08 per call). Every Azure service stays within free tier limits.

Service Free Limit Cost
Static Web Apps 100 GB bandwidth/month ₹0
App Service F1 60 CPU min/day ₹0
Blob Storage 5 GB (12 months) ₹0
Azure Functions 1M executions/month ₹0
Service Bus 10M messages/month ₹0
Key Vault 10K ops/month ₹0
Event Grid 100K ops/month ₹0
Logic Apps 4K actions/month ₹0
Gemini AI Free credits on signup ~₹0.08/analysis

One caveat — the F1 App Service plan has no Always-On. First request after inactivity takes ~5 seconds to wake up. Fine for a portfolio project. Upgrade to B1 (~$13/month) if you need Always-On for production.


What I Actually Learned

Managed Identity is trickier than the docs suggest. The concept is clean — no passwords, just an Azure AD identity. The reality: enable it on the resource, copy the Object ID, assign the right IAM role on each target service, then wait a few minutes for propagation. Miss any step, you get a vague 403 with no helpful message. I hit this twice.

Event-driven forces a different mental model. Synchronous thinking is linear — request in, process, response out. Event-driven isn't. The API has no idea what the Function is doing. The Function doesn't know the Logic App exists. Once that mental model clicks, the architecture feels natural. But it takes a moment to actually click.

The bugs are the most valuable part. The Blazor file null reference. The Managed Identity propagation delay. The Gemini markdown wrapping. Every one of them frustrated me at the time. Every one of them is now something I can explain clearly in an interview — because I actually lived through it, not just read about it.


How This Helps in Interviews

There are two types of developers in any Azure conversation. One says "I know Service Bus and Event Grid." The other says "I used Service Bus to decouple my API from AI processing — I needed the endpoint to return in under a second without blocking on a 30-second Gemini call."

After building ResumePulse, you're the second developer. You can speak to real decisions and trade-offs, not just service names.


About the Author

Pravin Kshirsagar — .NET developer at YASH Technologies, focused on Azure-native development and working toward AZ-204. I write about the real implementation experience — the messy parts included — so other developers can skip what I wasted time on.

Full source code and CLI commands are in the YouTube video description.


#Azure #DotNET #Blazor #AzureFunctions #GeminiAI #EventDriven #AZ204 #CSharp

1 Comment

0 votes
🔥 Join developers growing publicly
Share your knowledge, build in public, and grow your developer presence with a global community.

More Posts

How I Built a React Portfolio in 7 Days That Landed ₹1.2L in Freelance Work

Dharanidharan - Feb 9

I’m a Senior Dev and I’ve Forgotten How to Think Without a Prompt

Karol Modelskiverified - Mar 19

Implementing Cellular Redundancy: Cross-Cloud Failover with AWS Transit Gateway and Azure ExpressRou

Cláudio Raposo - May 5

The Audit Trail of Things: Using Hashgraph as a Digital Caliper for Provenance

Ken W. Algerverified - Apr 28

TypeScript Complexity Has Finally Reached the Point of Total Absurdity

Karol Modelskiverified - Apr 23
chevron_left
129 Points4 Badges
Pune
1Posts
0Comments
Pravin Kshirsagar
Full-Stack Web & .NET Developer | 3× Hackathon Champion | Trainee Programmer @ Yash Technologies | Ex-Intern @ Persist Ventures

Related Jobs

View all jobs →

Commenters (This Week)

4 comments
3 comments
3 comments

Contribute meaningful comments to climb the leaderboard and earn badges!