Building a Multitenant Cloud Application With Azure Functions and Neon Postgres
A beginner-friendly guide to building a multitenant cloud app with Azure Functions and Neon serverless Postgres, using a database-per-tenant design in .NET.
A hostel with private rooms
Think about a big student hostel in your city. One building, one front gate, one warden. But inside, every student has their own locked room with their own key. Ravi cannot open Priya's room, and Priya cannot open Ravi's room. They share the building, the water tank, and the stairs, but their stuff stays private.
A multitenant cloud application works the same way. You build one app. Many different companies (we call each one a tenant) use it. They share the same code and the same servers, but each tenant's data must stay locked away from the others. Tenant A must never see Tenant B's customers, orders, or secrets.
In this article we will build that hostel, but for software. We will give each tenant their own private database using Neon serverless Postgres, and we will use Azure Functions as the front gate and the warden. By the end you will understand how the pieces fit, and you will have real .NET code to copy.
The three ways to keep tenants apart
Before we build, let us look at the three common ways people separate tenants. This choice matters a lot, so we will spend a minute here.
| Approach | What it means | Isolation | Cost with many tenants |
|---|---|---|---|
| Discriminator column | One database, one table, a TenantId column on every row | Weakest | Cheapest |
| Schema-per-tenant | One database, a separate schema (folder of tables) per tenant | Medium | Medium |
| Database-per-tenant | Every tenant gets a whole separate database | Strongest | Higher, but flexible |
The discriminator column is like one shared notebook where every line starts with the owner's name. Simple, but one mistake in a query and you leak data.
The schema-per-tenant is like one notebook with a labelled section for each person.
The database-per-tenant is our hostel with private rooms: one full database per tenant. This is the safest, and it is what we will build. Neon makes it cheap because idle databases scale to zero, and Neon gives us an API to create them on demand.
Why Neon fits database-per-tenant so well
Plain Postgres can also hold many databases. So why pick Neon? Three reasons make Neon a great match here.
- Scale to zero. When a tenant's database is not being used, Neon pauses its compute. You stop paying for idle databases. With normal servers, a thousand sleepy tenants still cost money. With Neon, they cost almost nothing.
- A real API to create databases. Neon lets you create a project, a branch, and a database with a simple HTTP call. So when a new tenant signs up, your code can make their database in seconds, no human needed.
- Built-in connection pooling. Serverless functions can open a new connection on every call. Without help, that floods Postgres. Neon ships PgBouncer pooling that can handle up to 10,000 connections, so your functions do not exhaust the limit.
Here is how Neon organises things. A project holds one or more branches, and each branch holds one or more databases. For our design, each tenant gets their own database inside a branch.
The shape of our system
Let us name the parts before we code. We will have a small set of Azure Functions, each doing one job:
- A Create Tenant function. It calls the Neon API to make a new database, then runs the table setup.
- A Get Products function. It reads products for a given tenant from that tenant's own database.
- A Add Product function. It writes a product into the tenant's own database.
We also need a way to know which database belongs to which tenant. We keep a tiny lookup table in a shared "catalog" database: tenant name maps to its Neon database name and connection string.
A new tenant signs up
Steps
Sign up
User posts tenant name
Create DB
Call Neon API
Run migrations
Make the tables
Save mapping
Store name to DB
Ready
Tenant can log in
A tenant reads their data
Steps
Request
GET /products?tenant=acme
Find tenant
Look up in catalog
Pick DB
Get connection string
Query
Read from tenant DB
Return
Send JSON back
Setting up the Neon side
First, create a free Neon account and a project. In the Neon console you will find:
- A Project ID (in project settings).
- A Branch ID (in the branch overview).
- An API key (in Account settings, then API keys).
Keep these three values safe. Your Azure Functions will use them to talk to Neon. Store them as settings, never hard-code them in your source.
Now let us write the code. We will use the .NET isolated worker model for Azure Functions and a tiny typed HTTP client to call Neon.
Step 1: Talk to the Neon API to create a database
When a tenant signs up, we ask Neon to create a fresh database. The Neon REST API has an endpoint to create a database inside a branch. Here is a small client that does it.
using System.Net.Http.Json;
public class NeonClient
{
private readonly HttpClient _http;
public NeonClient(HttpClient http)
{
_http = http;
// The API key is sent as a Bearer token.
// Set BaseAddress to https://console.neon.tech/api/v2/
}
public async Task CreateDatabaseAsync(
string projectId, string branchId, string dbName, string ownerName)
{
var url = $"projects/{projectId}/branches/{branchId}/databases";
var body = new
{
database = new { name = dbName, owner_name = ownerName }
};
var response = await _http.PostAsJsonAsync(url, body);
response.EnsureSuccessStatusCode();
}
}Notice how small this is. We post the tenant's database name to Neon, and Neon builds it. The EnsureSuccessStatusCode call throws if anything goes wrong, so a failure does not pass silently.
Step 2: The Create Tenant function
Now we wire that client into an Azure Function with an HTTP trigger. This function is the front gate for new tenants.
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using System.Net;
public class CreateTenantFunction
{
private readonly NeonClient _neon;
private readonly TenantCatalog _catalog;
public CreateTenantFunction(NeonClient neon, TenantCatalog catalog)
{
_neon = neon;
_catalog = catalog;
}
[Function("CreateTenant")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequestData req)
{
var input = await req.ReadFromJsonAsync<CreateTenantRequest>();
var dbName = $"tenant_{input!.TenantName.ToLowerInvariant()}";
// 1. Ask Neon to make the database.
await _neon.CreateDatabaseAsync(
Settings.ProjectId, Settings.BranchId, dbName, "app_owner");
// 2. Create the tables inside that new database.
await _catalog.RunMigrationsAsync(dbName);
// 3. Remember which DB belongs to this tenant.
await _catalog.SaveMappingAsync(input.TenantName, dbName);
var res = req.CreateResponse(HttpStatusCode.Created);
await res.WriteStringAsync($"Tenant {input.TenantName} is ready.");
return res;
}
}
public record CreateTenantRequest(string TenantName);Read the three numbered steps. We make the database, we create its tables, and we save the mapping so we can find it later. Each step is plain and easy to follow.
Step 3: Finding the right database for each request
Every other function must first answer one question: which database do I use for this tenant? That is the job of the catalog. It holds a small mapping table in a shared database.
| Column | Example value | Why it exists |
|---|---|---|
tenant_name | acme | The friendly name the user types |
database_name | tenant_acme | The real Neon database |
connection_string | Host=...;Database=tenant_acme | How .NET connects |
created_at | 2026-06-10 | When the tenant joined |
When a request arrives, we read this table, get the connection string, and open a connection to that tenant's database. Because the data physically lives in separate databases, one tenant can never see another's rows, even if our query has a bug.
Step 4: Reading a tenant's products
Here is the read function. It uses the catalog to pick the database, then runs a simple query with Npgsql, the Postgres driver for .NET.
using Npgsql;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
public class GetProductsFunction
{
private readonly TenantCatalog _catalog;
public GetProductsFunction(TenantCatalog catalog) => _catalog = catalog;
[Function("GetProducts")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Function, "get")] HttpRequestData req)
{
var tenant = req.Query["tenant"]
?? throw new ArgumentException("tenant is required");
// Find this tenant's own connection string.
var connString = await _catalog.GetConnectionStringAsync(tenant);
var products = new List<string>();
await using var conn = new NpgsqlConnection(connString);
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand(
"SELECT name FROM products ORDER BY name", conn);
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
products.Add(reader.GetString(0));
}
var res = req.CreateResponse();
await res.WriteAsJsonAsync(products);
return res;
}
}The important line is connString. It comes from the catalog, so it always points at the right tenant database. The query itself has no TenantId filter, because it does not need one. The database boundary is the filter.
A note on connection strings and pooling
Serverless functions can start and stop many times. Each call might want a database connection. If you open a brand new raw connection every time, you can run out fast.
Use the pooled Neon connection string. In the Neon console, pick the "Pooled connection" option. Its hostname contains -pooler. This routes through PgBouncer, which keeps a warm pool of connections and hands them out quickly. Your functions stay fast, and Postgres stays calm.
Keeping tenant databases up to date
When you add a new table, every tenant database needs that change. With database-per-tenant, you cannot run one migration and be done, because each tenant has a separate database.
A common pattern is a small migration runner function. It loops over every tenant in the catalog and applies the latest schema to each one. You can trigger it on a timer or run it by hand after a deployment.
public async Task MigrateAllTenantsAsync()
{
var tenants = await _catalog.GetAllTenantsAsync();
foreach (var tenant in tenants)
{
var connString = await _catalog.GetConnectionStringAsync(tenant.Name);
await _catalog.RunMigrationsAsync(connString);
// Log progress so you can see which tenant is done.
}
}Run migrations in small, safe steps. If one tenant fails, log it and continue, then fix that tenant on its own. Do not let one bad tenant block the rest.
Costs, limits, and honest trade-offs
Database-per-tenant is lovely for isolation, but be honest about the cost. If you expect tens of thousands of tenants, count the compute hours. Even with scale-to-zero, very active tenants run compute, and that adds up. The Neon docs and Microsoft's multitenancy guidance both say the same thing: measure before you commit at large scale.
A simple rule of thumb:
- A few hundred tenants, strong isolation needed? Database-per-tenant with Neon is a great fit.
- Hundreds of thousands of tiny tenants? Consider schema-per-tenant or a discriminator column to share compute, and read the Neon multitenancy guide carefully first.
A quick word on libraries
You may have read about message libraries like MediatR and MassTransit. Heads up: both moved to a commercial license in their recent versions. For the small functions in this article you do not need them at all. Plain dependency injection and HttpClient are enough. Only reach for paid libraries when a real problem demands them.
Quick recap
- A multitenant app is one app serving many separate customers, like a hostel with private rooms.
- There are three ways to separate tenants: discriminator column, schema-per-tenant, and database-per-tenant. The last gives the strongest isolation.
- Neon serverless Postgres suits database-per-tenant because it scales to zero, offers an API to create databases on demand, and ships built-in pooling.
- Azure Functions act as the front gate. Each function does one job: create a tenant, read products, or add a product.
- A small catalog maps each tenant to its own database connection string, so every request goes to the correct, private database.
- Use the pooled Neon connection string so serverless calls do not exhaust connections.
- Run migrations per tenant, looping over the catalog, and handle failures one tenant at a time.
- Database-per-tenant is great for a few hundred isolated tenants. Measure costs before scaling to many thousands.
References and further reading
- Multitenancy with Neon (Neon Docs)
- Neon for Database-per-user (Neon Docs)
- Connection pooling (Neon Docs)
- Connecting .NET Applications to Neon (Neon Guides)
- Azure Database for PostgreSQL in a Multitenant Solution (Microsoft Learn)
- Building a Multitenant Cloud Application With Azure Functions and Neon Postgres (Anton Martyniuk)
Related Posts
Best Practices When Working With MongoDB in .NET
Learn simple, proven MongoDB best practices in .NET: singleton client, connection pooling, indexes, projections, and safe writes explained for beginners.
Zero-Downtime Migrations: A Practical Demo with Password Hashing
Learn zero-downtime migrations with a real password hashing demo in ASP.NET Core. Upgrade old hashes safely as users log in, with diagrams and code.
Get Started with SQL Transactions in PostgreSQL
Learn SQL transactions in PostgreSQL the easy way: BEGIN, COMMIT, ROLLBACK, ACID, savepoints, and isolation levels with simple diagrams and C# examples.
Boost Your EF Core Productivity in PostgreSQL With Entity Developer
Learn how Entity Developer gives EF Core a visual ORM designer for PostgreSQL, with drag-and-drop modeling, model-first and database-first, and T4 code generation.
Build a Multi-Model AI Chat Bot in .NET with ChatGPT and Neon Postgres Branching
Learn to build a multi-model AI chat bot in .NET 10 using ChatGPT and Neon serverless Postgres branching, with simple steps a beginner can follow.
From Transaction Scripts to Domain Models: A Refactoring Journey
Learn how to refactor messy transaction script code into a clean domain model in .NET 10. Simple examples, diagrams, tables, and EF Core code to guide your journey.