Skip to main content
SEMastery
Data Accessintermediate

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.

12 min readUpdated November 23, 2025

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.

ApproachWhat it meansIsolationCost with many tenants
Discriminator columnOne database, one table, a TenantId column on every rowWeakestCheapest
Schema-per-tenantOne database, a separate schema (folder of tables) per tenantMediumMedium
Database-per-tenantEvery tenant gets a whole separate databaseStrongestHigher, 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.

The three isolation models, from least isolated on the left to most isolated on the right.

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.

  1. 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.
  2. 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.
  3. 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.

How Neon structures a project, branch, and the per-tenant databases we create.

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

Sign up
Create DB
Run migrations
Save mapping
Ready

Steps

1

Sign up

User posts tenant name

2

Create DB

Call Neon API

3

Run migrations

Make the tables

4

Save mapping

Store name to DB

5

Ready

Tenant can log in

The journey from sign-up to a ready, private database.

A tenant reads their data

Request
Find tenant
Pick DB
Query
Return

Steps

1

Request

GET /products?tenant=acme

2

Find tenant

Look up in catalog

3

Pick DB

Get connection string

4

Query

Read from tenant DB

5

Return

Send JSON back

Every request first finds the right database, then runs the query there.

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.

ColumnExample valueWhy it exists
tenant_nameacmeThe friendly name the user types
database_nametenant_acmeThe real Neon database
connection_stringHost=...;Database=tenant_acmeHow .NET connects
created_at2026-06-10When 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.

A read request being routed to the correct tenant database at runtime.

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.

Pooling sits between your many function calls and the database, reusing warm connections.

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

Related Posts