Clone, run, and explore this sample
Upload images to Azure Blob Storage with queue-triggered thumbnail generation. Demonstrates event-driven Container Apps Jobs with queue-based autoscaling, managed identity authentication, and Azure SQL free tier - can run entirely within Azure free tier limits.
The entry point that composes every resource and dependency in this sample's distributed application.
#pragma warning disable ASPIRECSHARPAPPS001#pragma warning disable ASPIREAZURE002#pragma warning disable ASPIREBROWSERLOGS001#pragma warning disable ASPIREJAVASCRIPT001
#:sdk Aspire.AppHost.Sdk@13.4.0#:package Aspire.Hosting.Azure.Storage@13.4.0#:package Aspire.Hosting.Azure.Sql@13.4.0#:package Aspire.Hosting.JavaScript@13.4.0#:package Aspire.Hosting.Browsers@13.4.0-preview.1.26281.18#:package Aspire.Hosting.Azure.AppContainers@13.4.0
using Aspire.Hosting.Azure;using Azure.Provisioning.AppContainers;using Azure.Provisioning.Expressions;
var builder = DistributedApplication.CreateBuilder(args);
var env = builder.AddAzureContainerAppEnvironment("env");
// Storage: Use Azurite emulator in run mode, real Azure in publish modevar storage = builder.AddAzureStorage("storage") .RunAsEmulator();
var blobs = storage.AddBlobContainer("images");var queues = storage.AddQueues("queues");
// Azure SQL Databasevar sql = builder.AddAzureSqlServer("sql") .RunAsContainer(c => c.WithLifetime(ContainerLifetime.Persistent)) .AddDatabase("imagedb");
// API: Upload images, queue thumbnail jobs, serve metadatavar api = builder.AddCSharpApp("api", "./api") .WithComputeEnvironment(env) .WithHttpHealthCheck("/health") .WithExternalHttpEndpoints() .WaitFor(sql) .WithReference(blobs) .WithReference(queues) .WithReference(sql) .WithUrls(context => { foreach (var u in context.Urls) { u.DisplayLocation = UrlDisplayLocation.DetailsOnly; }
context.Urls.Add(new() { Url = "/scalar", DisplayText = "API Reference", Endpoint = context.GetEndpoint("https") }); }) .PublishAsAzureContainerApp((infra, app) => { // Scale to zero when idle app.Template.Scale.MinReplicas = 0; });
// Worker: Container Apps Job for queue-triggered thumbnail generation// Event-driven: starts when messages arrive, exits within ~5 seconds when queue is emptyvar worker = builder.AddCSharpApp("worker", "./worker") .WithComputeEnvironment(env) .WithReference(blobs) .WithReference(queues) .WithReference(sql) .WaitFor(sql) .WaitFor(queues);
if (builder.ExecutionContext.IsRunMode){ // In run mode, keep worker running continuously for fast local development worker = worker.WithEnvironment("WORKER_RUN_CONTINUOUSLY", "true");}else{ // In publish mode, use event-driven scaling based on queue depth worker.PublishAsAzureContainerAppJob((infra, job) => { var accountNameParameter = queues.Resource.Parent.NameOutputReference.AsProvisioningParameter(infra);
// Resolve the identity annotation added to the worker app if (!worker.Resource.TryGetLastAnnotation<AppIdentityAnnotation>(out var identityAnnotation)) { throw new InvalidOperationException("Identity annotation not found."); }
job.Configuration.TriggerType = ContainerAppJobTriggerType.Event; job.Configuration.EventTriggerConfig.Scale.PollingIntervalInSeconds = 1; job.Configuration.EventTriggerConfig.Scale.Rules.Add(new ContainerAppJobScaleRule { Name = "queue-rule", JobScaleRuleType = "azure-queue", Metadata = new ObjectExpression( new PropertyExpression("accountName", new IdentifierExpression(accountNameParameter.BicepIdentifier)), new PropertyExpression("queueName", new StringLiteralExpression("thumbnails")), new PropertyExpression("queueLength", new IntLiteralExpression(1)) ), Identity = identityAnnotation.IdentityResource.Id.AsProvisioningParameter(infra) }); });}
// Frontend: Vite+React for upload and gallery UIbuilder.AddViteApp("frontend", "./frontend") .WithEndpoint("http", e => e.Port = 9080) .WithReference(api) .WithUrl("", "Image Gallery") .WithBrowserLogs() .WithComputeEnvironment(env) .PublishAsStaticWebsite("/api", api);
builder.Build().Run();Architecture
Section titled ArchitectureRun Mode:
flowchart LR
Browser --> Vite[Vite Dev Server<br/>HMR enabled]
Vite -->|Proxy /api| API[C# API]
API --> Azurite[Azurite Emulator<br/>Blobs + Queues]
API --> SQL[SQL Server]
Worker[Background Worker<br/>Runs continuously] --> Azurite
Worker --> SQL
Azurite -.Queue Message.-> WorkerPublish Mode:
flowchart LR
Browser --> Frontend[Static website serving<br/>Vite build output<br/>'npm run build']
Frontend -->|Proxy /api| API[C# API]
API --> Blobs[Azure Blob Storage]
API --> Queue[Azure Storage Queue]
API --> SQL[Azure SQL]
Job[Container Apps Job<br/>Event-triggered<br/>Scales on queue depth] --> Blobs
Job --> SQL
Queue -.Queue Trigger.-> JobHow it works
Section titled How it worksEvent-Driven thumbnail processing
Section titled Event-Driven thumbnail processing- Upload: User uploads image → API saves to Azure Blob Storage and metadata to Azure SQL
- Queue: API enqueues thumbnail generation message to Azure Storage Queue
- Trigger: Azure monitors queue depth and automatically starts a Container Apps Job instance
- Process: Job processes messages in batches (up to 10), generates thumbnails using SkiaSharp
- Scale Down: After 2 empty polls (~5 seconds), job exits; new instances start automatically when messages arrive
Local development vs production
Section titled Local development vs productionProduction (Event-Triggered):
- Job starts when queue depth > 0, exits within ~5 seconds when empty
- API and worker both scale to zero when idle
Local Development (Continuous):
- Worker runs continuously, polls every 5 seconds
- Instant feedback with Azurite emulator and SQL Server container
What this demonstrates
Section titled What this demonstrates- Event-Driven Jobs: Container Apps Jobs with queue-based autoscaling rules using Azure.Provisioning APIs
- Dual-Mode Resources: Azurite/SQL Server containers locally, Azure services in production (
.RunAsEmulator(),.RunAsContainer()) - Free Tier Deployment: Azure SQL free tier with serverless auto-pause, Container Apps scale-to-zero
- Managed Identity: Password-less authentication to all Azure resources (Storage, SQL, Queues)
- Polyglot Stack: Vite+React frontend published as a static website, SkiaSharp for image processing
- OpenTelemetry: Distributed tracing across upload → queue → worker pipeline
Running locally
Section titled Running locallyaspire runNo Azure resources required - uses Azurite emulator and SQL Server container.
Deploying to azure
Section titled Deploying to azurePrerequisites for deployment:
Commands:
aspire run # Run locally with Azuriteaspire publish # Generate Bicep files to explore deployment artifacts (output in ./aspire-output)aspire deploy # Deploy to Azure Container AppsSecurity notes
Section titled Security notesImplemented:
- ✅ Managed Identity: Password-less authentication to all Azure resources (no connection strings or secrets)
- ✅ XSRF Protection: Antiforgery tokens protect upload/delete endpoints from cross-site request forgery attacks (docs)
- ✅ Input Validation: 10 MB file size limit, extension allowlist (.jpg, .jpeg, .png, .gif, .webp), and server-side image byte validation before saving or queueing uploads (file upload security)
- ✅ Filename Sanitization: Path traversal prevention, 255 char limit
- ✅ Resource Limits: Pagination (max 100 items), retry limits (3 attempts), size checks (20 MB max)
Not Implemented (Required for Production):
- ❌ Authentication & Authorization: Endpoints are public - anyone can upload/delete
- ❌ Rate Limiting: No protection against abuse or DoS (docs)
- ❌ Malware Scanning: Image byte validation rejects unsupported or malformed images, but production upload workflows should consider malware scanning and the broader ASP.NET Core security guidance
Key aspire patterns
Section titled Key aspire patternsAzure Storage Emulation - Automatic Azurite in run mode, real Azure in publish:
var storage = builder.AddAzureStorage("storage").RunAsEmulator();var blobs = storage.AddBlobContainer("images");var queues = storage.AddQueues("queues");Azure SQL Dual Mode - SQL Server container locally, Azure SQL free tier in production:
var sql = builder.AddAzureSqlServer("sql") .RunAsContainer() .AddDatabase("imagedb");Defaults to Azure SQL free tier with serverless auto-pause (SKU: GP_S_Gen5_2).
Scale to Zero - API only runs when handling requests:
api.PublishAsAzureContainerApp((infra, app) =>{ app.Template.Scale.MinReplicas = 0;});Event-Triggered Container App Job - Direct control over Azure resources with Azure.Provisioning libraries:
This example demonstrates using the Azure.Provisioning libraries to directly configure low-level Azure resources (Bicep/ARM) from the AppHost. This provides complete flexibility and control when Aspire's higher-level APIs don't expose specific features.
worker.PublishAsAzureContainerAppJob((infra, job) =>{ // Direct access to Azure Provisioning APIs - full control over Bicep/ARM templates
// Get storage account name for queue authentication var accountNameParameter = queues.Resource.Parent.NameOutputReference.AsProvisioningParameter(infra);
// Configure event-driven trigger using Container Apps Job APIs job.Configuration.TriggerType = ContainerAppJobTriggerType.Event; job.Configuration.EventTriggerConfig.Scale.Rules.Add(new ContainerAppJobScaleRule { Name = "queue-rule", JobScaleRuleType = "azure-queue", Metadata = new ObjectExpression( // Bicep expressions - referencing other resources dynamically new PropertyExpression("accountName", new IdentifierExpression(accountNameParameter.BicepIdentifier)), new PropertyExpression("queueName", new StringLiteralExpression("thumbnails")), new PropertyExpression("queueLength", new IntLiteralExpression(1)) // Start job when 1+ messages ), Identity = identityAnnotation.IdentityResource.Id.AsProvisioningParameter(infra) // Use managed identity });});This approach gives you full control over Azure resource configuration without waiting for Aspire abstractions to expose every feature. The C# strongly-typed APIs generate correct Bicep/ARM templates with automatic dependency tracking and parameter passing between resources. You can seamlessly mix high-level Aspire APIs with low-level Azure Provisioning when you need fine-grained control.
Dual-Mode Worker - Continuous in run mode, event-triggered in publish mode:
// AppHost: Set environment variable only in run modeif (builder.ExecutionContext.IsRunMode){ worker = worker.WithEnvironment("WORKER_RUN_CONTINUOUSLY", "true");}
// Worker: Adapt behavior based on modeif (_configuration.GetValue<bool>("WORKER_RUN_CONTINUOUSLY")){ // Local dev: poll every 5 seconds, run forever await ExecuteContinuousAsync(stoppingToken);}else{ // Production: poll up to 2 times (5s intervals), exit when empty // MaxEmptyPolls = 2, EmptyPollWaitSeconds = 5 await ExecuteScheduledAsync(stoppingToken);}Graceful Shutdown - Event-triggered mode always stops, exceptions crash naturally:
if (_configuration.GetValue<bool>("WORKER_RUN_CONTINUOUSLY")){ // Continuous mode: run forever, let exceptions crash the app await ExecuteContinuousAsync(stoppingToken);}else{ try { // Event-triggered mode: process messages until queue empty, then shutdown await ExecuteScheduledAsync(stoppingToken); } finally { // Always stop application when done (success or exception) // New instances will start automatically when queue has messages _hostApplicationLifetime.StopApplication(); }}Static Website Publishing - Publish the Vite app as a static website with API proxying:
frontend.PublishAsStaticWebsite("/api", api);Performance & cost characteristics
Section titled Performance & cost characteristicsResponse Times:
- Thumbnail generation: typically ready within seconds of upload
- Local development: instant feedback with continuous polling
Cost Optimization:
- Compute: API and worker scale to zero when idle (~5 seconds), only pay for active processing
- SQL: Free tier with serverless auto-pause (GP_S_Gen5_2), free up to monthly limits
- Storage: Pay only for blob storage used, queues/blobs have minimal costs at low volumes
- Result: Can run entirely within Azure free tier limits
Further optimization: Using SAS URLs instead of API blob streaming would eliminate compute/egress costs for serving images
Scalability:
- Parallel job instances spawn automatically for high queue depth
- Batch processing (up to 10 messages per poll)
- Managed identity eliminates secrets management overhead