
**Quick answer:** Build a multi-stage Docker image using Next.js **standalone** output, push it to Azure Container Registry, and run `az containerapp up`. For a production stack, set `--min-replicas 0` for scale-to-zero, enable **Easy Auth** for security, and use **Managed Identities** to connect to Postgres and Blob Storage without passwords. Use Azure Front Door for global routing and OpenTelemetry for observability.

If you find a step that no longer works, leave a comment with your CLI version (`az --version`) and I will update the post.

## 1. What you will build

A complete production grade Next.js 15 application running on Azure Container Apps with every piece a real product needs.

- A homepage rendered on the server on every request
- A dynamic product route that fetches data per request
- A JSON API route used as a health probe
- A Server Action that processes form submissions
- A protected dashboard guarded by built in authentication
- A multi stage Docker image based on Next.js standalone output, around 180 MB compressed
- An Azure Container App that scales from 0 to 5 replicas based on HTTP concurrency
- A custom domain with a free managed TLS certificate that auto renews
- Preview environments for every pull request, exposed on stable label URLs
- A GitHub Actions pipeline that authenticates to Azure via OIDC, with no client secret stored in the repo
- A Postgres database on Azure Database for PostgreSQL Flexible Server, accessed using a managed identity
- File uploads stored in Azure Blob Storage, also using managed identity
- A scheduled Container Apps Job that runs background work without keeping a web replica warm
- Azure Front Door in front for global routing, caching, and a WAF policy
- Distributed traces sent through OpenTelemetry into Application Insights

## 2. Why Azure Container Apps for dynamic Next.js

| Service                | Scale to zero   | SSR support | Built in auth   | Cold start       | Best for                       |
| ---------------------- | --------------- | ----------- | --------------- | ---------------- | ------------------------------ |
| Container Apps         | Yes             | Full        | Yes (Easy Auth) | 1 to 3 s         | Dynamic apps with idle periods |
| App Service Linux      | No              | Full        | Yes (Easy Auth) | None (always on) | Steady traffic apps            |
| Static Web Apps Hybrid | Partial         | Preview     | Yes             | Sub second       | Mostly static sites            |
| AKS                    | Yes (with KEDA) | Full        | Bring your own  | Variable         | Teams already on Kubernetes    |

Container Apps gives you the container model without a Kubernetes cluster to manage. You get scale to zero, KEDA based autoscaling, free managed TLS, Easy Auth, revisions for previews, native Dapr, Container Apps Jobs for batch work, and per second billing with a free monthly allowance.

## 3. Prerequisites

- Node.js 22 or 24 (`node --version`)
- pnpm 9 or higher
- Docker Desktop or Podman
- Azure CLI 2.66 or newer (`az --version`)
- Git and a GitHub account
- An Azure subscription with permission to create resource groups

```bash
az login
az account set --subscription 'Your Subscription Name'

az extension add --name containerapp --upgrade
az provider register --namespace Microsoft.App
az provider register --namespace Microsoft.OperationalInsights
az provider register --namespace Microsoft.DBforPostgreSQL
az provider register --namespace Microsoft.Storage
az provider register --namespace Microsoft.Cdn
az provider register --namespace Microsoft.Insights
```

## 4. Bootstrapping the Next.js 15 project

```bash
pnpm create next-app@latest nextjs-aca-demo
cd nextjs-aca-demo
```

`next.config.ts`:

```ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "standalone",
  experimental: {
    serverActions: { bodySizeLimit: "5mb" },
  },
};

export default nextConfig;
```

## 5. Adding SSR, API routes, and Server Actions

```tsx
// app/page.tsx
import { headers } from "next/headers";

export default async function HomePage() {
  const h = await headers();
  return (
    <main className="mx-auto max-w-2xl p-8">
      <h1 className="text-3xl font-bold">Next.js 15 on Azure Container Apps</h1>
      <p className="mt-4 text-gray-600">Rendered at {new Date().toISOString()}</p>
      <p className="mt-2 text-sm text-gray-500">UA: {h.get("user-agent")}</p>
    </main>
  );
}
```

```ts
// app/api/health/route.ts
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
export async function GET() {
  return NextResponse.json({ status: "ok", timestamp: new Date().toISOString() });
}
```

## 6. Writing a production Dockerfile

```dockerfile
# syntax=docker/dockerfile:1.7

FROM node:22-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
RUN corepack enable && pnpm install --frozen-lockfile

FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN corepack enable && pnpm build

FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ['node', 'server.js']
```

## 7. Testing the container locally

```bash
docker build -t nextjs-aca-demo:local .
docker run --rm -p 3000:3000 nextjs-aca-demo:local
```

## 8. Provisioning Azure resources

```bash
RG='rg-nextjs-aca'
LOCATION='eastus'
ACR='acrnextjsaca'$RANDOM
ENV='cae-nextjs-aca'
APP='ca-nextjs-aca'

az group create --name $RG --location $LOCATION
az acr create --resource-group $RG --name $ACR --sku Basic --admin-enabled false
az containerapp env create --name $ENV --resource-group $RG --location $LOCATION
```

## 9. Deploying with az containerapp up

```bash
az containerapp up \
  --name $APP \
  --resource-group $RG \
  --location $LOCATION \
  --environment $ENV \
  --registry-server $ACR.azurecr.io \
  --source . \
  --ingress external \
  --target-port 3000
```

## 10. Turning on scale to zero

```bash
az containerapp update \
  --name $APP \
  --resource-group $RG \
  --min-replicas 0 \
  --max-replicas 5 \
  --scale-rule-name http-rule \
  --scale-rule-type http \
  --scale-rule-http-concurrency 50
```

## 11. Environment variables and secrets

```bash
az containerapp secret set \
  --name $APP --resource-group $RG \
  --secrets session-secret='replace-me'

az containerapp update \
  --name $APP --resource-group $RG \
  --set-env-vars SESSION_SECRET=secretref:session-secret NODE_ENV=production
```

## 12. Built in authentication with Easy Auth

```bash
APP_ID=$(az ad app create --display-name 'nextjs-aca-demo-auth' --query appId -o tsv)

az containerapp auth microsoft update \
  --name $APP --resource-group $RG \
  --client-id $APP_ID \
  --tenant-id $(az account show --query tenantId -o tsv)

az containerapp auth update \
  --name $APP --resource-group $RG \
  --enabled true \
  --action RedirectToLoginPage \
  --redirect-provider azureactivedirectory
```

```ts
// lib/user.ts
import { headers } from "next/headers";

export async function getCurrentUser() {
  const h = await headers();
  const id = h.get("x-ms-client-principal-id");
  const name = h.get("x-ms-client-principal-name");
  if (!id || !name) return null;
  return { id, name };
}
```

## 13. Mapping a custom domain with managed TLS

```bash
az containerapp hostname add --hostname 'app.yourdomain.com' --name $APP --resource-group $RG
az containerapp hostname bind --hostname 'app.yourdomain.com' --name $APP --resource-group $RG --environment $ENV --validation-method CNAME
```

## 14. Preview environments using revisions

```bash
az containerapp revision set-mode --name $APP --resource-group $RG --mode multiple

az containerapp update \
  --name $APP --resource-group $RG \
  --image $ACR.azurecr.io/nextjs-aca-demo:pr-42 \
  --revision-suffix pr42
```

## 15. GitHub Actions CI/CD with OIDC

```yaml
# .github/workflows/deploy.yml
name: Deploy to Azure Container Apps
on:
  push: { branches: [main] }
  pull_request: { branches: [main] }

permissions:
  id-token: write
  contents: read
  pull-requests: write

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: azure/login@v2
        with:
          client-id: ${{ vars.AZURE_CLIENT_ID }}
          tenant-id: ${{ vars.AZURE_TENANT_ID }}
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
      - run: az acr build --registry acrnextjsacaXXXX --image nextjs-aca-demo:${{ github.sha }} .
      - run: |
          az containerapp update \
            --name ca-nextjs-aca \
            --resource-group rg-nextjs-aca \
            --image acrnextjsacaXXXX.azurecr.io/nextjs-aca-demo:${{ github.sha }}
```

## 16. Logging and observability basics

```bash
az containerapp logs show --name $APP --resource-group $RG --follow
```

For deep tracing, see section 21 where we wire OpenTelemetry into Application Insights.

## 17. Add Postgres on Azure Database for PostgreSQL Flexible Server

Most real apps need a relational database. Azure Database for PostgreSQL Flexible Server is the right fit for Container Apps because it supports Microsoft Entra authentication, which means your container can connect using a managed identity and you never store a database password anywhere.

### 17.1 Create the database

```bash
PG='pg-nextjs-aca'
PG_ADMIN='pgadmin'
PG_DB='appdb'

az postgres flexible-server create \
  --name $PG \
  --resource-group $RG \
  --location $LOCATION \
  --tier Burstable \
  --sku-name Standard_B1ms \
  --version 16 \
  --storage-size 32 \
  --public-access 0.0.0.0 \
  --admin-user $PG_ADMIN \
  --admin-password 'TempBootstrapPassword!23' \
  --yes

az postgres flexible-server db create \
  --resource-group $RG \
  --server-name $PG \
  --database-name $PG_DB
```

### 17.2 Enable Microsoft Entra authentication

```bash
ME=$(az ad signed-in-user show --query id -o tsv)
ME_NAME=$(az ad signed-in-user show --query userPrincipalName -o tsv)

az postgres flexible-server ad-admin create \
  --resource-group $RG \
  --server-name $PG \
  --display-name $ME_NAME \
  --object-id $ME
```

### 17.3 Assign a managed identity to the Container App

```bash
az containerapp identity assign \
  --name $APP --resource-group $RG \
  --system-assigned

PRINCIPAL_ID=$(az containerapp show --name $APP --resource-group $RG \
  --query identity.principalId -o tsv)
```

### 17.4 Grant the managed identity database access

In any psql client signed in as the Entra admin:

```sql
SELECT * FROM pgaadauth_create_principal_with_oid('app-identity', '<PRINCIPAL_ID>', 'service', false, false);
GRANT CONNECT ON DATABASE appdb TO "app-identity";
GRANT USAGE ON SCHEMA public TO "app-identity";
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO "app-identity";
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO "app-identity";
```

### 17.5 Connect from Next.js using a fresh access token

```bash
pnpm add pg @azure/identity
pnpm add -D @types/pg
```

```ts
// lib/db.ts
import { Pool } from "pg";
import { DefaultAzureCredential } from "@azure/identity";

const credential = new DefaultAzureCredential();
let pool: Pool | null = null;
let tokenExpiresAt = 0;

async function getPool() {
  const now = Date.now();
  if (!pool || now > tokenExpiresAt - 60_000) {
    const token = await credential.getToken("https://ossrdbms-aad.database.windows.net/.default");
    if (!token) throw new Error("Could not acquire Postgres token");
    tokenExpiresAt = token.expiresOnTimestamp;

    if (pool) await pool.end();
    pool = new Pool({
      host: process.env.PGHOST,
      port: 5432,
      user: "app-identity",
      database: process.env.PGDATABASE,
      password: token.token,
      ssl: { rejectUnauthorized: true },
      max: 5,
    });
  }
  return pool;
}

export async function query<T = unknown>(text: string, params?: unknown[]) {
  const p = await getPool();
  return p.query<T>(text, params as never);
}
```

### 17.6 Wire env vars and use it in a Server Component

```bash
az containerapp update --name $APP --resource-group $RG \
  --set-env-vars PGHOST=$PG.postgres.database.azure.com PGDATABASE=$PG_DB
```

```tsx
// app/posts/page.tsx
import { query } from "@/lib/db";

type Post = { id: number; title: string };

export default async function PostsPage() {
  const { rows } = await query<Post>("select id, title from posts order by id desc limit 20");
  return (
    <ul className="mx-auto max-w-2xl p-8 list-disc pl-6">
      {rows.map((p) => (
        <li key={p.id}>{p.title}</li>
      ))}
    </ul>
  );
}
```

No password lives in your code, your env vars, or your repo. Tokens are fetched on demand and cached by lifetime.

## 18. Wire Azure Blob Storage with managed identity

Use Blob Storage for user uploads, generated reports, and any file that does not belong in the database.

### 18.1 Create the storage account and a container

```bash
SA='sanextjsaca'$RANDOM
CONTAINER='uploads'

az storage account create \
  --name $SA \
  --resource-group $RG \
  --location $LOCATION \
  --sku Standard_LRS \
  --kind StorageV2 \
  --allow-blob-public-access false \
  --min-tls-version TLS1_2

az storage container create \
  --account-name $SA \
  --name $CONTAINER \
  --auth-mode login
```

### 18.2 Grant the Container App's managed identity access

```bash
SA_ID=$(az storage account show --name $SA --resource-group $RG --query id -o tsv)

az role assignment create \
  --assignee $PRINCIPAL_ID \
  --role 'Storage Blob Data Contributor' \
  --scope $SA_ID
```

### 18.3 Upload from a Server Action

```bash
pnpm add @azure/storage-blob @azure/identity
```

```ts
// lib/blob.ts
import { BlobServiceClient } from "@azure/storage-blob";
import { DefaultAzureCredential } from "@azure/identity";

const account = process.env.AZURE_STORAGE_ACCOUNT!;
const container = process.env.AZURE_STORAGE_CONTAINER!;

const service = new BlobServiceClient(
  `https://${account}.blob.core.windows.net`,
  new DefaultAzureCredential()
);

export async function uploadBlob(name: string, data: Buffer, contentType: string) {
  const containerClient = service.getContainerClient(container);
  const block = containerClient.getBlockBlobClient(name);
  await block.uploadData(data, { blobHTTPHeaders: { blobContentType: contentType } });
  return block.url;
}
```

```tsx
// app/upload/page.tsx
import { uploadBlob } from "@/lib/blob";
import { randomUUID } from "crypto";

async function uploadAction(formData: FormData) {
  "use server";
  const file = formData.get("file") as File;
  if (!file) return;
  const name = `${randomUUID()}-${file.name}`;
  const buffer = Buffer.from(await file.arrayBuffer());
  await uploadBlob(name, buffer, file.type);
}

export default function UploadPage() {
  return (
    <main className="mx-auto max-w-2xl p-8">
      <h1 className="text-2xl font-bold">Upload</h1>
      <form action={uploadAction} className="mt-4 flex flex-col gap-3">
        <input type="file" name="file" required />
        <button type="submit" className="rounded bg-black px-4 py-2 text-white">
          Upload
        </button>
      </form>
    </main>
  );
}
```

```bash
az containerapp update --name $APP --resource-group $RG \
  --set-env-vars AZURE_STORAGE_ACCOUNT=$SA AZURE_STORAGE_CONTAINER=$CONTAINER
```

For private downloads, generate user delegation SAS URLs server side and stream them through a route handler. Never expose the storage account key.

## 19. Add a Container Apps Job for background work

A web replica is the wrong place to run long imports, nightly cleanups, or queue workers. Container Apps Jobs run the same image on a schedule or on demand, and they pay only while the job runs.

### 19.1 Add a job entry point in the codebase

```ts
// jobs/cleanup.ts
import { query } from "../lib/db";

async function main() {
  console.log("[job] cleanup started");
  const { rowCount } = await query("delete from sessions where expires_at < now()");
  console.log("[job] removed", rowCount, "expired sessions");
}

main().catch((err) => {
  console.error("[job] failed", err);
  process.exit(1);
});
```

```json
{
  "scripts": {
    "job:cleanup": "tsx jobs/cleanup.ts"
  }
}
```

### 19.2 Create a scheduled job that reuses the same image

```bash
JOB='caj-cleanup'

az containerapp job create \
  --name $JOB \
  --resource-group $RG \
  --environment $ENV \
  --trigger-type Schedule \
  --cron-expression '0 3 * * *' \
  --replica-timeout 600 \
  --replica-retry-limit 1 \
  --image $ACR.azurecr.io/nextjs-aca-demo:latest \
  --registry-server $ACR.azurecr.io \
  --registry-identity system \
  --mi-system-assigned \
  --command '/bin/sh' '-lc' 'pnpm job:cleanup'
```

### 19.3 Give the job the same database and storage access

```bash
JOB_PRINCIPAL=$(az containerapp job show --name $JOB --resource-group $RG \
  --query identity.principalId -o tsv)

az role assignment create \
  --assignee $JOB_PRINCIPAL \
  --role 'Storage Blob Data Contributor' \
  --scope $SA_ID
```

For Postgres, run the same `pgaadauth_create_principal_with_oid` you ran for the web app, this time with the job's principal id.

### 19.4 Trigger an event driven job

```bash
az containerapp job create \
  --name caj-worker \
  --resource-group $RG \
  --environment $ENV \
  --trigger-type Event \
  --min-executions 0 \
  --max-executions 10 \
  --polling-interval 30 \
  --image $ACR.azurecr.io/nextjs-aca-demo:latest \
  --registry-server $ACR.azurecr.io \
  --registry-identity system \
  --mi-system-assigned \
  --scale-rule-name sb-rule \
  --scale-rule-type azure-servicebus \
  --scale-rule-metadata 'queueName=jobs' 'namespace=YOUR_SB_NAMESPACE' 'messageCount=5' \
  --scale-rule-identity system \
  --command '/bin/sh' '-lc' 'pnpm job:worker'
```

## 20. Put Azure Front Door in front for global routing and WAF

Azure Front Door adds a global anycast network in front of your Container App, plus a managed WAF, caching, and HTTP/3.

### 20.1 Create the profile and endpoint

```bash
FD='afd-nextjs-aca'
FD_EP='afd-nextjs-aca-ep'

az afd profile create \
  --resource-group $RG \
  --profile-name $FD \
  --sku Standard_AzureFrontDoor

az afd endpoint create \
  --resource-group $RG \
  --profile-name $FD \
  --endpoint-name $FD_EP \
  --enabled-state Enabled
```

### 20.2 Point Front Door at the Container App

```bash
ORIGIN_HOST=$(az containerapp show --name $APP --resource-group $RG \
  --query 'properties.configuration.ingress.fqdn' -o tsv)

az afd origin-group create \
  --resource-group $RG \
  --profile-name $FD \
  --origin-group-name og-aca \
  --probe-request-type GET \
  --probe-protocol Https \
  --probe-interval-in-seconds 60 \
  --probe-path /api/health \
  --sample-size 4 \
  --successful-samples-required 3 \
  --additional-latency-in-milliseconds 50

az afd origin create \
  --resource-group $RG \
  --profile-name $FD \
  --origin-group-name og-aca \
  --origin-name origin-aca \
  --host-name $ORIGIN_HOST \
  --origin-host-header $ORIGIN_HOST \
  --priority 1 \
  --weight 1000 \
  --enabled-state Enabled \
  --http-port 80 \
  --https-port 443

az afd route create \
  --resource-group $RG \
  --profile-name $FD \
  --endpoint-name $FD_EP \
  --route-name default \
  --origin-group og-aca \
  --supported-protocols Http Https \
  --link-to-default-domain Enabled \
  --forwarding-protocol HttpsOnly \
  --https-redirect Enabled
```

### 20.3 Restrict Container Apps ingress to Front Door

```bash
FD_ID=$(az afd profile show --resource-group $RG --profile-name $FD \
  --query frontDoorId -o tsv)

az containerapp ingress access-restriction set \
  --name $APP --resource-group $RG \
  --rule-name allow-frontdoor \
  --ip-address AzureFrontDoor.Backend \
  --action Allow
```

```ts
// middleware.ts
import { NextResponse, type NextRequest } from "next/server";

const FD_ID = process.env.FRONT_DOOR_ID;

export function middleware(req: NextRequest) {
  if (!FD_ID) return NextResponse.next();
  const header = req.headers.get("x-azure-fdid");
  if (header !== FD_ID) {
    return new NextResponse("Forbidden", { status: 403 });
  }
  return NextResponse.next();
}

export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"] };
```

### 20.4 Add a WAF policy

```bash
WAF='wafnextjsaca'

az network front-door waf-policy create \
  --resource-group $RG \
  --name $WAF \
  --sku Standard_AzureFrontDoor \
  --mode Prevention

az network front-door waf-policy managed-rules add \
  --resource-group $RG \
  --policy-name $WAF \
  --type Microsoft_DefaultRuleSet \
  --version 2.1

WAF_ID=$(az network front-door waf-policy show --resource-group $RG --name $WAF --query id -o tsv)

az afd security-policy create \
  --resource-group $RG \
  --profile-name $FD \
  --security-policy-name sp-default \
  --domains /subscriptions/$(az account show --query id -o tsv)/resourceGroups/$RG/providers/Microsoft.Cdn/profiles/$FD/afdEndpoints/$FD_EP \
  --waf-policy $WAF_ID
```

### 20.5 Enable caching for static assets

```bash
az afd rule-set create --resource-group $RG --profile-name $FD --rule-set-name rs-cache

az afd rule create \
  --resource-group $RG --profile-name $FD --rule-set-name rs-cache \
  --rule-name cacheNextStatic --order 1 \
  --match-variable UrlPath --operator BeginsWith --match-values '/_next/static/' \
  --action-name CacheExpiration --cache-behavior Override --cache-duration 365.00:00:00

az afd route update \
  --resource-group $RG --profile-name $FD --endpoint-name $FD_EP --route-name default \
  --rule-sets rs-cache
```

Now point your custom domain DNS at the Front Door endpoint instead of the Container App.

## 21. Push OpenTelemetry traces into Application Insights

Application Insights gives you distributed traces across server components, server actions, database calls, and outbound HTTP.

### 21.1 Create the Application Insights resource

```bash
WS_ID=$(az containerapp env show --name $ENV --resource-group $RG \
  --query 'properties.appLogsConfiguration.logAnalyticsConfiguration.customerId' -o tsv)

WS_RID=$(az monitor log-analytics workspace list --resource-group $RG \
  --query "[?customerId=='$WS_ID'].id" -o tsv)

AI='ai-nextjs-aca'

az monitor app-insights component create \
  --app $AI \
  --location $LOCATION \
  --resource-group $RG \
  --workspace $WS_RID \
  --kind web

CONN=$(az monitor app-insights component show \
  --app $AI --resource-group $RG \
  --query connectionString -o tsv)
```

### 21.2 Install the SDK

```bash
pnpm add @opentelemetry/api @opentelemetry/sdk-node \
  @opentelemetry/auto-instrumentations-node \
  @azure/monitor-opentelemetry-exporter
```

### 21.3 Wire instrumentation.ts

```ts
// instrumentation.ts
export async function register() {
  if (process.env.NEXT_RUNTIME !== "nodejs") return;

  const { NodeSDK } = await import("@opentelemetry/sdk-node");
  const { getNodeAutoInstrumentations } = await import("@opentelemetry/auto-instrumentations-node");
  const { AzureMonitorTraceExporter } = await import("@azure/monitor-opentelemetry-exporter");
  const { Resource } = await import("@opentelemetry/resources");
  const { SemanticResourceAttributes } = await import("@opentelemetry/semantic-conventions");

  const sdk = new NodeSDK({
    resource: new Resource({
      [SemanticResourceAttributes.SERVICE_NAME]: "nextjs-aca-demo",
      [SemanticResourceAttributes.SERVICE_VERSION]: process.env.IMAGE_TAG ?? "dev",
      [SemanticResourceAttributes.DEPLOYMENT_ENVIRONMENT]: process.env.NODE_ENV ?? "development",
    }),
    traceExporter: new AzureMonitorTraceExporter({
      connectionString: process.env.APPLICATIONINSIGHTS_CONNECTION_STRING,
    }),
    instrumentations: [
      getNodeAutoInstrumentations({
        "@opentelemetry/instrumentation-fs": { enabled: false },
      }),
    ],
  });

  sdk.start();
}
```

```ts
// next.config.ts
const nextConfig = {
  output: "standalone",
  experimental: { instrumentationHook: true },
};
export default nextConfig;
```

### 21.4 Push the connection string as a secret

```bash
az containerapp secret set \
  --name $APP --resource-group $RG \
  --secrets ai-conn="$CONN"

az containerapp update \
  --name $APP --resource-group $RG \
  --set-env-vars APPLICATIONINSIGHTS_CONNECTION_STRING=secretref:ai-conn
```

### 21.5 Add a custom span for a server action

```ts
import { trace } from "@opentelemetry/api";

const tracer = trace.getTracer("nextjs-aca-demo");

async function submitFeedback(formData: FormData) {
  "use server";
  await tracer.startActiveSpan("submitFeedback", async (span) => {
    try {
      const message = formData.get("message");
      span.setAttribute("feedback.length", String(message).length);
      // persist to db here
    } finally {
      span.end();
    }
  });
}
```

Open Application Insights, go to Transaction Search, filter by `cloud_RoleName == nextjs-aca-demo`, and you will see traces that span middleware, server components, the database client, and outbound fetches.

## 22. Cost compared to Vercel, App Service, and Static Web Apps

| Platform                 | Monthly cost      | Scale to zero | Built in auth | Previews        |
| ------------------------ | ----------------- | ------------- | ------------- | --------------- |
| Azure Container Apps     | $5 to $15         | Yes           | Yes           | Yes (revisions) |
| App Service Linux B1     | $13 fixed         | No            | Yes           | Yes (slots)     |
| Static Web Apps Standard | $9 plus functions | Partial       | Yes           | Yes             |
| Vercel Pro               | $20 per seat      | Yes           | No            | Yes             |

Add roughly $13 for a Burstable B1ms Postgres Flexible Server, a few cents for storage, and pennies for Front Door Standard plus Application Insights ingestion. A small but real production stack lands in the $25 to $40 per month range.

## 23. Common errors and fixes

- **Container exits immediately:** wrong port. Set `ENV HOSTNAME=0.0.0.0` and match `--target-port`.
- **504 on first request:** cold start. Set `--min-replicas 1` or shrink the image.
- **Auth headers missing:** request did not pass through the auth gateway. Test through the public hostname.
- **Postgres token errors:** the principal name in `pgaadauth_create_principal_with_oid` must match the role you set in the connection.
- **Blob 403 from the container:** the Storage Blob Data Contributor role takes a minute or two to propagate.
- **Job replica timeout:** raise `--replica-timeout`, and confirm the job actually exits with code 0.
- **Front Door returns 502:** the origin host header must match the Container App FQDN exactly.
- **Application Insights shows no traces:** make sure `experimental.instrumentationHook` is enabled and the connection string is set in the running revision.

## 24. FAQ

### Is Azure Container Apps the same as App Service?

No. App Service is always on PaaS. Container Apps is serverless containers with scale to zero, KEDA, Dapr, revisions, and per second billing.

### How fast is the cold start for Next.js on Container Apps?

About 1 to 3 seconds for a 180 MB standalone image.

### Can I deploy from GitHub without storing a secret?

Yes, with OIDC federated credentials.

### Do I have to use a managed identity for Postgres and Blob?

No, but it removes a class of secrets from your environment.

### Should I use Container Apps Jobs or a worker container in the same app?

Jobs are better when work is bursty or scheduled. A worker container is fine for steady streams of small messages.

### Does Front Door replace the Container App custom domain?

Yes. Once Front Door is healthy, point your DNS at the Front Door endpoint and remove the hostname from the Container App.

### Will OpenTelemetry slow down my app?

The auto instrumentations add a small overhead, usually under 5 percent.

### How do I tear everything down?

`az group delete --name $RG --yes --no-wait`

## 25. Conclusion

You now have a Next.js 15 application running on Azure Container Apps with standalone Docker, scale to zero, env vars and secrets, Easy Auth, custom domain with managed TLS, preview environments via revisions, and a CI/CD pipeline using OIDC. On top of that, the stack has a passwordless Postgres database, secure Blob Storage uploads, a Container Apps Job for background work, Azure Front Door with WAF in front, and OpenTelemetry traces flowing into Application Insights.

That is a real production deployment, not a demo. From here you can split services, add Dapr building blocks for pub sub and state, or move to multi region with paired Front Door origin groups.

Happy shipping.


---

<!-- METADATA_START -->
## Metadata & Citations

### Further Reading
- [How I Made My Next.js Portfolio Actually Production-Ready (For $0)](https://www.ranti.dev/blog/production-ready-nextjs-ci-cd-edge-firewall.md)
- [I'm Officially an AWS Community Builder! The Complete Guide to What It Is, What You Get, and How to Make the Most of It](https://www.ranti.dev/blog/aws-community-builder.md)
- [FinOps 101: How to Stop AWS From Bankrupting You](https://www.ranti.dev/blog/finops-101-cost-optimization.md)

### Navigation
- [Back to Bio Hub](https://www.ranti.dev/.md)
- [Full Site Manifest](https://www.ranti.dev/llms.txt)

---
title: Next.js 15 on Azure Container Apps: A Production-Ready Deployment Guide
author: Rantideb Howlader
date: 2026-04-29T00:00:00.000Z
canonical_url: https://www.ranti.dev/blog/nextjs-15-azure-container-apps-guide
license: CC-BY-4.0
---
```json
{
  "@context": "https://schema.org",
  "@type": "TechArticle",
  "headline": "Next.js 15 on Azure Container Apps: A Production-Ready Deployment Guide",
  "author": {
    "@type": "Person",
    "name": "Rantideb Howlader"
  },
  "datePublished": "2026-04-29T00:00:00.000Z",
  "url": "https://www.ranti.dev/blog/nextjs-15-azure-container-apps-guide",
  "license": "https://creativecommons.org/licenses/by/4.0/",
  "isAccessibleForFree": true
}
```

### BibTeX
```bibtex
@article{nextjs-15-azure-container-apps-guide_2026,
  author = {Rantideb Howlader},
  title = {Next.js 15 on Azure Container Apps: A Production-Ready Deployment Guide},
  journal = {Rantideb Howlader Portfolio},
  year = {2026},
  url = {https://www.ranti.dev/blog/nextjs-15-azure-container-apps-guide},
  note = {Accessed: 2026-05-01}
}
```

### IEEE
Rantideb Howlader, "Next.js 15 on Azure Container Apps: A Production-Ready Deployment Guide," Rantideb Howlader Portfolio, 2026. [Online]. Available: https://www.ranti.dev/blog/nextjs-15-azure-container-apps-guide. [Accessed: 2026-05-01].

### APA
Rantideb Howlader. (2026). Next.js 15 on Azure Container Apps: A Production-Ready Deployment Guide. Rantideb Howlader. Retrieved from https://www.ranti.dev/blog/nextjs-15-azure-container-apps-guide

--- 
*This content is provided in research-grade Markdown format. Required Attribution: Cite as Rantideb Howlader (2026).*
<!-- METADATA_END -->