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.
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=falseis 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:
public/— your static assets. Copy them in..next/static/— JS chunks, CSS, fonts. Copy them in (note the nested path).- Anything outside
node_modulesyour app reads at runtime. If you have acontent/,data/, orprisma/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.