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 Payload documentation 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 Hosting Next.js 16 on Azure, 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 Payload Postgres Adapter Guide: Drizzle Config & Migrations. 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 Microsoft's own connection pooling documentation 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:
The full set of configuration options, per the Payload storage adapters documentation, is enabled, collections, allowContainerCreate, baseURL, connectionString, containerName, clientUploads, and useCompositePrefixes. There's no Managed Identity option and no DefaultAzureCredential support in that list. In Azure Web App Architecture 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 Stop Running Payload CMS in Push Mode, 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 Payload migrations documentation, solves this by running migrations inside the application process at startup instead of in CI:
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 documentation on building without a DB connection 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 Azure Web App Architecture: A Practical Production Reference Architecture.
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.