Skip to main content
  1. Blog
  2. Nextjs 15 Azure Container Apps Guide
LinkedIn
Ranti

Rantideb Howlader

@ranti

Connect
Search PostsReading ListTimelineBlog Stats

On this page

1. What you will build
2. Why Azure Container Apps for dynamic Next.js
3. Prerequisites
4. Bootstrapping the Next.js 15 project
5. Adding SSR, API routes, and Server Actions
6. Writing a production Dockerfile
7. Testing the container locally
8. Provisioning Azure resources
9. Deploying with az containerapp up
10. Turning on scale to zero
11. Environment variables and secrets
12. Built in authentication with Easy Auth
13. Mapping a custom domain with managed TLS
14. Preview environments using revisions
15. GitHub Actions CI/CD with OIDC
16. Logging and observability basics
17. Add Postgres on Azure Database for PostgreSQL Flexible Server
18. Wire Azure Blob Storage with managed identity
19. Add a Container Apps Job for background work
20. Put Azure Front Door in front for global routing and WAF
21. Push OpenTelemetry traces into Application Insights
22. Cost compared to Vercel, App Service, and Static Web Apps
23. Common errors and fixes
24. FAQ
25. Conclusion

Next.js 15 on Azure Container Apps: A Production-Ready Deployment Guide

Rantideb Howlader•April 29, 2026 (1mo ago)•19 min read•
By Rantideb Howlader

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 5to5 to 5to15 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 13foraBurstableB1msPostgresFlexibleServer,afewcentsforstorage,andpenniesforFrontDoorStandardplusApplicationInsightsingestion.Asmallbutrealproductionstacklandsinthe13 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 13foraBurstableB1msPostgresFlexibleServer,afewcentsforstorage,andpenniesforFrontDoorStandardplusApplicationInsightsingestion.Asmallbutrealproductionstacklandsinthe25 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.

Keep Reading

P

Part 2 - The S3 Files Lambda Handbook Serverless Persistence & Access Points

April 18, 2026 (2mo ago)4 min read
AWSCloud
P

Part 1 - The S3 Files EC2 Infrastructure Handbook Manual Configuration & Architecture

April 18, 2026 (2mo ago)8 min read
AWSCloud
P

Part 3 - The S3 Files Terraform Masterclass Modular Automation & Workloads

April 18, 2026 (2mo ago)6 min read
AWSCloud

Subscribe to Newsletter

Get the latest posts delivered right to your inbox

Join 1,000+ readers. No spam, unsubscribe anytime.

Support my work — Brewing thought
Ranti

Rantideb Howlader

Author

Connect