bbabafemi
All posts
DevOps

Deploying Next.js to Azure App Service with GitHub Actions

A practical, production-ready setup for deploying Next.js to Azure App Service via GitHub Actions — including standalone output, OIDC, and the gotchas no one warns you about.

February 24, 2026 4 min readby Babafemi Bulugbe

Next.js on Azure App Service has gotten significantly nicer over the last year. Done right, you can deploy a fast, slim, production-grade Next.js app to App Service via GitHub Actions in under 30 minutes.

Here's the setup that works.

Why App Service (and not Static Web Apps)?

Static Web Apps is great for static sites with light Functions. For a real Next.js app with server-side rendering, API routes, middleware, and dynamic features, App Service is the right home. You get a real Node runtime, predictable scaling, and the operational tooling Azure veterans already know.

If your site is fully static (next export), use Static Web Apps. Otherwise, App Service.

Step 1: Enable standalone output

In next.config.mjs:

const nextConfig = {
  output: "standalone",
  poweredByHeader: false,
  reactStrictMode: true,
};
export default nextConfig;

Standalone output produces a .next/standalone/ folder with only the files needed at runtime. The full bundle (with node_modules) is around 300MB; standalone trims it to 30–80MB. Faster deploys, faster cold starts.

Step 2: Create the App Service

I provision via CLI (Bicep is also fine):

az appservice plan create \
  --name asp-mysite \
  --resource-group rg-mysite \
  --sku B1 --is-linux

az webapp create \
  --name mysite \
  --resource-group rg-mysite \
  --plan asp-mysite \
  --runtime "NODE:22-lts"

az webapp config set \
  --name mysite \
  --resource-group rg-mysite \
  --startup-file "node server.js"

az webapp config appsettings set \
  --name mysite --resource-group rg-mysite \
  --settings \
    SCM_DO_BUILD_DURING_DEPLOYMENT=false \
    NEXT_TELEMETRY_DISABLED=1 \
    NODE_ENV=production

Notes:

  • SCM_DO_BUILD_DURING_DEPLOYMENT=false is critical. You're shipping a pre-built bundle; don't let Kudu try to rebuild it.
  • startup-file "node server.js" points to the file Next.js produces in the standalone output.
  • B1 is fine for portfolios. Bump to P1v3 for real production with autoscale.

Step 3: GitHub Actions workflow

name: Build and deploy
on:
  push: { branches: [main] }

permissions:
  id-token: write
  contents: read

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '22.x', cache: 'npm' }

      - run: npm ci
      - run: npm run build

      - name: Assemble standalone bundle
        run: |
          cp -r public .next/standalone/public
          mkdir -p .next/standalone/.next
          cp -r .next/static .next/standalone/.next/static
          # If you have content (e.g. markdown blog), copy that too:
          [ -d content ] && cp -r content .next/standalone/content || true

      - name: Zip
        run: cd .next/standalone && zip -r ../../deploy.zip .

      - uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - uses: azure/webapps-deploy@v3
        with:
          app-name: mysite
          package: deploy.zip

Use OIDC federated credentials — no client secret needed (covered in my OIDC post).

The standalone output gotchas

This is where everyone trips. Three things are not in .next/standalone/ by default:

  1. public/ — your static assets. Copy them in.
  2. .next/static/ — JS chunks, CSS, fonts. Copy them in (note the nested path).
  3. Anything outside node_modules your app reads at runtime. If you have a content/, data/, or prisma/ folder, copy it too.

The cp -r lines in the workflow above handle these. Without them, your site renders without CSS or 404s on every static asset, and you'll spend an afternoon debugging.

The PORT gotcha

Next.js standalone reads process.env.PORT. Azure App Service sets it. Don't override it. Just leave the default behavior alone — it works.

Custom domain + SSL

az webapp config hostname add \
  --webapp-name mysite \
  --resource-group rg-mysite \
  --hostname yourdomain.com

az webapp config ssl create \
  --resource-group rg-mysite \
  --name mysite \
  --hostname yourdomain.com

Then bind the certificate in the portal under Custom domains → SSL settings. Free managed certificates renew automatically.

What I monitor in production

App Insights is wired in via:

az monitor app-insights component create \
  --app mysite-ai --location uksouth --resource-group rg-mysite

az webapp config appsettings set \
  --name mysite --resource-group rg-mysite \
  --settings APPLICATIONINSIGHTS_CONNECTION_STRING="..."

I instrument:

  • Server response time per route.
  • Cold start frequency (visible in App Service metrics).
  • 5xx error rate.
  • A custom event for each blog post page view.

What this setup deliberately doesn't do

  • No autoscale rules. B1 doesn't support them; bump to P1v3 if you need scale.
  • No staging slots. Useful for zero-downtime deploys; configure once you're past the "is this even live yet" stage.
  • No CDN. Add Azure Front Door later for global edge caching.

Why this is enough for most portfolios and small SaaS

The combination of standalone Next.js + App Service Linux + GitHub Actions OIDC gives you:

  • Builds in ~2 minutes.
  • Deploys in ~30 seconds.
  • Cold starts under 3 seconds.
  • A real Node runtime you can attach a debugger to.
  • A predictable monthly bill.

That's a great floor to ship from.