---
title: "Payload CMS on Azure: Production Architecture Guide"
slug: "payload-cms-azure-production-architecture"
published: "2026-08-06"
updated: "2026-08-11"
categories:
  - "Payload"
tags:
  - "Payload CMS on Azure"
  - "Payload CMS Azure"
  - "Container Apps Payload"
  - "Azure Database for PostgreSQL"
  - "@payloadcms/storage-azure"
  - "Drizzle Postgres adapter"
  - "PgBouncer transaction pooling"
  - "prodMigrations private network"
  - "build-time DB issues"
  - "Azure Blob Storage uploads"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "payload cms"
  - "next.js"
  - "azure container apps"
  - "azure database for postgresql flexible server"
  - "azure blob storage"
status: "stable"
llm-purpose: "Payload CMS on Azure: production guide for Container Apps, PostgreSQL, and Blob Storage — fix pooling, storage auth, migrations and build-time DB issues."
llm-prereqs:
  - "Access to Payload CMS"
  - "Access to Next.js"
  - "Access to Azure Container Apps"
  - "Access to Azure Database for PostgreSQL Flexible Server"
  - "Access to Azure Blob Storage"
llm-outputs:
  - "Completed outcome: Payload CMS on Azure: production guide for Container Apps, PostgreSQL, and Blob Storage — fix pooling, storage auth, migrations and build-time DB issues."
---

**Summary Triples**
- (Payload CMS on Azure: Production Architecture Guide, focuses-on, Payload CMS on Azure: production guide for Container Apps, PostgreSQL, and Blob Storage — fix pooling, storage auth, migrations and build-time DB issues.)
- (Payload CMS on Azure: Production Architecture Guide, category, general)

### {GOAL}
Payload CMS on Azure: production guide for Container Apps, PostgreSQL, and Blob Storage — fix pooling, storage auth, migrations and build-time DB issues.

### {PREREQS}
- Access to Payload CMS
- Access to Next.js
- Access to Azure Container Apps
- Access to Azure Database for PostgreSQL Flexible Server
- Access to Azure Blob Storage

### {STEPS}
1. Assess Payload runtime requirements
2. Choose Container Apps for compute
3. Validate PostgreSQL pooling behavior
4. Configure Azure Blob storage adapter
5. Plan migrations in private networks
6. Handle builds that touch the DB
7. Secure secrets and deploy architecture

<!-- llm:goal="Payload CMS on Azure: production guide for Container Apps, PostgreSQL, and Blob Storage — fix pooling, storage auth, migrations and build-time DB issues." -->
<!-- llm:prereq="Access to Payload CMS" -->
<!-- llm:prereq="Access to Next.js" -->
<!-- llm:prereq="Access to Azure Container Apps" -->
<!-- llm:prereq="Access to Azure Database for PostgreSQL Flexible Server" -->
<!-- llm:prereq="Access to Azure Blob Storage" -->
<!-- llm:output="Completed outcome: Payload CMS on Azure: production guide for Container Apps, PostgreSQL, and Blob Storage — fix pooling, storage auth, migrations and build-time DB issues." -->

# Payload CMS on Azure: Production Architecture Guide
> Payload CMS on Azure: production guide for Container Apps, PostgreSQL, and Blob Storage — fix pooling, storage auth, migrations and build-time DB issues.
Matija Žiberna · 2026-08-06

For a production Payload CMS deployment on Azure, my reference architecture is Azure Container Apps running Payload's Next.js build, Azure Database for PostgreSQL Flexible Server as the database, and Azure Blob Storage through Payload's official `@payloadcms/storage-azure` adapter for uploads. That covers the runtime and the two services Payload leans on hardest. The part that actually needs care is everywhere Payload's specific requirements meet Azure's specific defaults: the built-in connection pooler's pooling mode, the storage adapter's authentication model, migrations against a privately networked database, and builds that touch the database before a network path to it exists. This article covers those four points directly, and links out to the runtime decision and the general Azure architecture I've already written up separately.

I recently mapped this out for a Payload deployment going onto Azure, and most of what's already written about "Payload on Azure" stops at a CLI walkthrough: create the Container App, point it at a database, done. That's a fine starting point, but it skips the parts that actually bite in production — how Payload's Drizzle-based Postgres adapter behaves against Azure's default connection pooling, what Payload's Azure storage adapter can and can't authenticate with, and what happens to a `next build` or a `payload migrate` step when the database sits behind Private Link. This article is the architecture-level version of that question.

## What Payload actually needs in production

Payload 3 is a full Next.js application. The <a href="https://payloadcms.com/docs/getting-started/what-is-payload">Payload documentation</a> describes the Admin UI, REST and GraphQL APIs, authentication, and the Local API as all running inside that same Next.js app, and your own frontend can live in the same deployment too. That has one direct consequence for Azure: you're not choosing infrastructure for a lightweight API, you're choosing infrastructure for a long-running Node process that holds open database connections, serves an admin panel, and potentially renders your public site.

Stripped down, Payload's production requirements are:

* a long-running Node.js runtime (Payload doesn't target edge or serverless execution models)
* a database — Postgres, in this article
* permanent storage for uploads, separate from the application's own filesystem
* a handful of secrets: `PAYLOAD_SECRET`, the database connection string, and storage credentials
* optionally, an email provider for auth flows

None of that is unusual for a Next.js application. What's specific to Payload is how the database and storage requirements interact with Azure's defaults, which is where the rest of this article lives.

## Why Container Apps fits Payload

I go through the general Azure compute decision — Container Apps against App Service, AKS, and Static Web Apps — separately in <a href="https://www.buildwithmatija.com/blog/hosting-nextjs-16-on-azure">Hosting Next.js 16 on Azure</a>, and that reasoning applies to Payload directly, since Payload's runtime is a Next.js app. The one thing worth adding here: Payload's Local API and its Postgres adapter both expect a persistent process holding a connection pool open, which fits Container Apps' long-running container model more naturally than it would fit a scale-to-zero or per-request execution model.

## Azure PostgreSQL: where Payload's adapter meets Azure's pooler

Payload's official `@payloadcms/db-postgres` adapter is a Drizzle-based adapter, and I've covered its configuration, schema hooks, and migration workflow in general terms in <a href="https://www.buildwithmatija.com/blog/payload-postgres-drizzle-adapter-config-queries-migrations">Payload Postgres Adapter Guide: Drizzle Config & Migrations</a>. What's Azure-specific is the connection pooler sitting in front of the database.

Azure Database for PostgreSQL Flexible Server ships with a built-in PgBouncer, and <a href="https://learn.microsoft.com/en-us/azure/postgresql/connectivity/concepts-connection-pooling-best-practices">Microsoft's own connection pooling documentation</a> states that transaction pooling is the default mode for that built-in pooler. Transaction-mode pooling returns the server connection to the pool as soon as a transaction commits, which is good for scaling connection counts and creates two known constraints: protocol-level prepared statements need `max_prepared_statements` raised above its default of `0`, and session-level `SET` commands don't persist between transactions.

That matters for Payload because its Postgres adapter runs through Drizzle's Postgres driver, which can issue protocol-level prepared statements depending on configuration. Before pointing a production Payload deployment at the built-in pooler's port, I'd verify whether the driver Payload uses is issuing prepared statements against that connection, and either raise `max_prepared_statements` on the Flexible Server instance or route Payload's connection through the direct Postgres port instead of the pooler port for the application's main pool. Migration runs are worth the same check — a migration step that opens a session-level `SET` and expects it to hold across statements is a workflow that has to run against a connection that behaves like a normal Postgres connection, not one that gets recycled mid-transaction.

None of this is a reason to avoid Azure's built-in pooler. It's a reason to test the actual connection behavior before assuming pooling is free.

## Azure Blob Storage: the adapter, and two things it doesn't do

Payload's official `@payloadcms/storage-azure` adapter handles uploads, and its current configuration is intentionally narrow:

```ts
import { azureStorage } from '@payloadcms/storage-azure'
import { Media } from './collections/Media'

export default buildConfig({
  collections: [Media],
  plugins: [
    azureStorage({
      collections: {
        media: true,
      },
      allowContainerCreate: process.env.AZURE_STORAGE_ALLOW_CONTAINER_CREATE === 'true',
      baseURL: process.env.AZURE_STORAGE_ACCOUNT_BASEURL,
      connectionString: process.env.AZURE_STORAGE_CONNECTION_STRING,
      containerName: process.env.AZURE_STORAGE_CONTAINER_NAME,
    }),
  ],
})
```

The full set of configuration options, per the <a href="https://payloadcms.com/docs/upload/storage-adapters">Payload storage adapters documentation</a>, is `enabled`, `collections`, `allowContainerCreate`, `baseURL`, `connectionString`, `containerName`, `clientUploads`, and `useCompositePrefixes`. There's no Managed Identity option and no `DefaultAzureCredential` support in that list. In <a href="https://www.buildwithmatija.com/blog/azure-web-app-architecture-production-reference">Azure Web App Architecture</a> I recommend Managed Identity over distributed credentials wherever a service supports it, and Payload's Azure storage adapter is a place where that recommendation doesn't apply: the connection string is the only supported path, so it goes into Key Vault as a literal secret regardless of what identity model the rest of the deployment uses.

The second thing worth knowing sits in Payload's access control behavior. Payload's documentation on storage adapters is explicit that uploaded files are served through Payload's own `/collectionSlug/staticURL/filename` proxy path by default, specifically so the collection's `read` access control still applies to files hosted on external storage. If you want Azure Front Door to cache media directly at the edge, you need `disablePayloadAccessControl: true` and a publicly readable container, which means Payload's per-document access control on that collection no longer applies. That's a real tradeoff between edge caching and file-level access control, and it's specific to how Payload serves uploads, not a general Azure CDN consideration.

One more Azure-specific limit worth knowing: client uploads send a file in a single request by default, and Azure caps that at roughly 5GB. For larger media, `clientUploads: { chunkLargeFiles: true }` routes the upload through the Azure Blob SDK instead, which also means your container's CORS configuration needs to allow the additional `x-ms-*` headers that SDK sends.

## Migrations against a privately networked database

I've written about the push-versus-migrate decision and the mechanics of Payload's migration workflow in <a href="https://www.buildwithmatija.com/blog/payloadcms-postgres-push-to-migrations">Stop Running Payload CMS in Push Mode</a>, and that reasoning is provider-agnostic. The Azure-specific wrinkle is narrower: if the Postgres instance sits behind Private Link with no public endpoint, your CI runner typically has no network path to it, which means running `payload migrate` as a CI step before deployment isn't an option.

Payload's `prodMigrations` option, documented in the <a href="https://payloadcms.com/docs/database/migrations">Payload migrations documentation</a>, solves this by running migrations inside the application process at startup instead of in CI:

```ts
import { migrations } from './migrations'
import { buildConfig } from 'payload'
import { postgresAdapter } from '@payloadcms/db-postgres'

export default buildConfig({
  db: postgresAdapter({
    prodMigrations: migrations,
  }),
})
```

Since the container running Payload already sits inside the same private network as the database, it can reach Postgres directly at startup, which sidesteps the CI-can't-reach-the-database problem entirely.

Worth checking before relying on this at scale: `prodMigrations` runs on Payload's initialization, and Container Apps can start more than one replica at once during a scale event. I haven't seen Payload's documentation address what happens when several replicas attempt the same migration simultaneously on a cold start. Until you've confirmed that behavior against your own migration set, I'd roll out a new revision at a single replica first and let it finish initializing before scaling out, rather than assume concurrent startups are automatically safe.

## Builds that touch the database

This is the part a generic Azure deployment guide is least likely to mention, and it's a real problem specific to Payload's build behavior. If any page in your Next.js frontend uses Payload's Local API during static generation, `next build` needs a working database connection to complete — which fails outright if the build runs somewhere without a network path to a privately networked Postgres instance.

Payload's <a href="https://payloadcms.com/docs/production/building-without-a-db-connection">documentation on building without a DB connection</a> addresses this directly, and it's worth reading before you hit the failure rather than after. The two practical options are giving the build environment a real network path to the database — running the build step inside the same VNet, or through a self-hosted CI runner peered into it — or restructuring the build so nothing in it depends on a live database connection. Which one makes sense depends on how much of your frontend actually needs build-time data versus data fetched at request time.

## Secrets: what actually needs to be in Key Vault

Given the two adapters above, Payload's real secret surface on Azure is short and specific:

* `PAYLOAD_SECRET` — Payload's own signing secret
* the Postgres connection string — `DATABASE_URI` or equivalent
* the Azure Blob Storage connection string — `AZURE_STORAGE_CONNECTION_STRING`
* an email provider credential, if you're using one for auth flows

The general Managed Identity guidance in my Azure architecture article still applies to everything else in the deployment — access to Key Vault itself, for instance. It just doesn't extend to these two connection strings, since neither the Postgres adapter's standard connection nor the storage adapter supports an identity-based alternative today. Both belong in Key Vault as plain secrets, injected into the container at startup rather than committed anywhere.

## A concrete reference architecture

Putting the pieces together:

```text
                 Azure Front Door (optional)
                         |
                         v
                 Azure Container Apps
                 Next.js + Payload
              Admin UI / REST / GraphQL
                         |
             +-----------+-----------+
             |                       |
             v                       v
 Azure Database for            Azure Blob Storage
 PostgreSQL Flexible Server    @payloadcms/storage-azure
        |                              |
        v                       connection string
 prodMigrations at startup     (Key Vault, no
 (private network reachable)   Managed Identity path)
             |
             v
      Key Vault: PAYLOAD_SECRET,
      DATABASE_URI, storage
      connection string
```

If you're adding Front Door for edge delivery, decide up front whether media needs to be publicly cacheable at the edge or needs Payload's per-document access control preserved — that decision determines whether `disablePayloadAccessControl` is on the table at all. Everything around this diagram — database HA, backups, observability, private networking, and the enterprise version of this architecture — is the same regardless of which CMS is running inside the container, and I cover it in full in <a href="https://www.buildwithmatija.com/blog/azure-web-app-architecture-production-reference">Azure Web App Architecture: A Practical Production Reference Architecture</a>.

## FAQ

**Does Payload CMS officially support Azure?**
Yes, through two official packages: `@payloadcms/db-postgres` for the database and `@payloadcms/storage-azure` for file uploads. Neither is a community adapter.

**Can I use Managed Identity for Payload's Azure Blob Storage uploads?**
Not with the current `@payloadcms/storage-azure` adapter. Its configuration only accepts a connection string, so that value needs to live in Key Vault as a secret rather than through an identity-based, passwordless connection.

**Why would migrations fail in CI on Azure but work locally?**
Usually because the Postgres instance has no public endpoint and your CI runner has no network path to it. Payload's `prodMigrations` option runs migrations from inside the application container at startup instead, which works because the container already sits inside the same private network as the database.

**Does Azure's built-in PgBouncer work with Payload out of the box?**
It works, but it defaults to transaction-mode pooling, which doesn't support protocol-level prepared statements unless `max_prepared_statements` is raised above its default of `0`, and doesn't persist session-level `SET` commands across transactions. I'd verify your Postgres driver's behavior against that before assuming the pooler needs no configuration.

**Can I use Azure Front Door as a CDN for Payload's uploaded media?**
Yes, but by default Payload serves files through its own access-control proxy path rather than a direct storage URL. Caching media directly at Front Door requires `disablePayloadAccessControl: true` and a publicly readable container, which removes Payload's per-document read access control on that collection.

## Wrapping up

Payload's actual production requirements on Azure come down to four specific interactions: how its Drizzle-based Postgres adapter behaves against Azure's default transaction-pooling PgBouncer, what its official Blob Storage adapter can and can't authenticate with, how migrations reach a database that has no public endpoint, and what happens when a build needs data before the build environment has a network path to get it. Container Apps, Azure Database for PostgreSQL, and Blob Storage handle the infrastructure side of all four without much friction. The friction sits in the specific defaults on both sides, and that's the part worth checking before a deployment goes to production rather than after.

Thanks,
Matija

## LLM Response Snippet
```json
{
  "goal": "Payload CMS on Azure: production guide for Container Apps, PostgreSQL, and Blob Storage — fix pooling, storage auth, migrations and build-time DB issues.",
  "responses": [
    {
      "question": "What does the article \"Payload CMS on Azure: Production Architecture Guide\" cover?",
      "answer": "Payload CMS on Azure: production guide for Container Apps, PostgreSQL, and Blob Storage — fix pooling, storage auth, migrations and build-time DB issues."
    }
  ]
}
```