Applied Module 12 · AI-Accelerated Government Development

Package & Deploy to Azure Gov

What you'll learn

~30 min
  • Configure Next.js standalone output mode for Azure App Service deployment
  • Execute the critical static file copy step that standalone mode requires
  • Deploy to Azure Government App Service using zip deployment

Why deployment is different on Azure Gov

Vercel is not an option. Netlify is not an option. The DS platform runs on Azure Government App Service, and the deployment process is specific: build a standalone Next.js bundle, copy the static assets that standalone mode intentionally excludes, configure environment variables for gov cloud endpoints, set up health checks, and zip-deploy to the App Service instance.

Every team that deploys Next.js to Azure App Service hits the same problem the first time: the application starts, the API routes work, but every page loads without CSS, images, or client-side JavaScript. The static files are missing. This lesson makes sure that does not happen to you.


Standalone output mode

Next.js standalone mode produces a self-contained server that includes only the files needed to run in production — no node_modules directory (it bundles dependencies), no dev tooling, no test files. The output is a minimal folder that can be zipped and deployed.

next.config.ts

next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
output: 'standalone',
// Required for Azure App Service — it expects the app on 8080
serverExternalPackages: ['applicationinsights'],
};
export default nextConfig;
serverExternalPackages

The applicationinsights SDK uses native modules that cannot be bundled by Next.js’s output tracing. Adding it to serverExternalPackages tells Next.js to leave it as a standard require() instead of trying to bundle it. Without this, the build succeeds but the server crashes on startup with a missing native module error.

What standalone mode produces

After npm run build, the output looks like this:

.next/
standalone/
server.js # The production server entry point
package.json
node_modules/ # Bundled dependencies (much smaller than full)
.next/
server/ # Server-side rendered pages and API routes
BUILD_ID
# NOTE: .next/static/ is NOT here
static/ # Client-side JS, CSS, images
chunks/
css/
media/
public/ # Your public/ assets (favicons, manifests, etc.)

Notice the gap: .next/standalone/.next/static/ does not exist. The static assets are in .next/static/, one level up from the standalone folder. The public/ directory is also not inside standalone.

This is intentional. The Next.js team assumes you will serve static files from a CDN in production. Azure App Service does not have a built-in CDN separation — it serves everything from the same process. So you copy the files manually.


The critical copy step

This is the step that every team misses the first time. It is two commands:

Terminal window
cp -r .next/static .next/standalone/.next/static
cp -r public .next/standalone/public

Without these, your deployed application returns HTML with references to /_next/static/chunks/main-abc123.js and /_next/static/css/styles-def456.css, but those files do not exist on the server. The browser shows a blank page or unstyled HTML with no interactivity.

This is the number one deployment failure

If you remember one thing from this lesson, remember these two cp commands. They are not in every Next.js tutorial. They are not in the Azure App Service quickstart. They are the difference between a working deployment and a broken one that shows no errors in the server logs because the server is fine — it is the client that cannot find its assets.


The deployment script

The prompt

Generate a deployment script called deploy.sh for our Next.js 15.5 platform
targeting Azure Government App Service.
Requirements:
1. Build step: npm ci && npm run build
2. Static file copy:
- cp -r .next/static .next/standalone/.next/static
- cp -r public .next/standalone/public
3. Create a zip of the .next/standalone directory
4. Deploy using az webapp deploy:
- Resource group and app name from environment variables
- Use --type zip
- Azure Gov cloud (az cloud set --name AzureUSGovernment)
5. Health check: after deploy, curl the /api/health endpoint and verify
200 response within 60 seconds (retry every 5 seconds)
6. Environment variables to set on the App Service:
- NODE_ENV=production
- PORT=8080 (Azure App Service requirement)
- WEBSITES_PORT=8080
- KEY_VAULT_NAME (from env)
- AZURE_AUTHORITY_HOST=https://login.microsoftonline.us
- APPLICATIONINSIGHTS_CONNECTION_STRING (from Key Vault, not hardcoded)
7. Include a --dry-run flag that builds and packages but does not deploy
8. Exit with error code if any step fails (set -euo pipefail)

What the script produces

The AI generates a script with these key sections:

#!/usr/bin/env bash
set -euo pipefail
# Configuration from environment
RESOURCE_GROUP="${AZURE_RESOURCE_GROUP:?Set AZURE_RESOURCE_GROUP}"
APP_NAME="${AZURE_APP_NAME:?Set AZURE_APP_NAME}"
DRY_RUN="${1:-}"
echo "=== Building Next.js standalone ==="
npm ci --ignore-scripts=false
npm run build
echo "=== Copying static assets ==="
cp -r .next/static .next/standalone/.next/static
cp -r public .next/standalone/public
echo "=== Creating deployment package ==="
cd .next/standalone
zip -r ../../deploy.zip . -x "*.map"
cd ../..
PACKAGE_SIZE=$(du -sh deploy.zip | cut -f1)
echo "Package size: ${PACKAGE_SIZE}"
if [ "$DRY_RUN" = "--dry-run" ]; then
echo "=== Dry run — skipping deployment ==="
echo "Package ready: deploy.zip"
exit 0
fi
echo "=== Deploying to Azure Gov ==="
az cloud set --name AzureUSGovernment
az webapp deploy \
--resource-group "$RESOURCE_GROUP" \
--name "$APP_NAME" \
--type zip \
--src-path deploy.zip
echo "=== Health check ==="
HEALTH_URL="https://${APP_NAME}.azurewebsites.us/api/health"
for i in {1..12}; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$HEALTH_URL" || echo "000")
if [ "$STATUS" = "200" ]; then
echo "Health check passed (attempt ${i})"
exit 0
fi
echo "Waiting for health check... (attempt ${i}/12, status: ${STATUS})"
sleep 5
done
echo "ERROR: Health check failed after 60 seconds"
exit 1
azurewebsites.us, not azurewebsites.net

Azure Government App Service uses the .azurewebsites.us domain, not .azurewebsites.net. The health check URL, CORS origins, and any external integrations that reference your app URL must use the .us domain.


Environment variables for Azure Gov

Azure App Service environment variables are configured through the Azure portal or the az CLI. These are the variables the DS platform requires in production:

VariableValueNotes
NODE_ENVproductionEnables Next.js production optimizations
PORT8080Azure App Service expects the app on this port
WEBSITES_PORT8080Tells the App Service reverse proxy which port to forward to
KEY_VAULT_NAMEds-platform-kvYour Key Vault instance name
AZURE_AUTHORITY_HOSThttps://login.microsoftonline.usGov AD endpoint
NEXTAUTH_URLhttps://ds-platform.azurewebsites.usFull app URL for next-auth callbacks
NEXTAUTH_SECRET(from Key Vault)JWT signing key — never in App Service config directly
Terminal window
az webapp config appsettings set \
--resource-group "$RESOURCE_GROUP" \
--name "$APP_NAME" \
--settings \
NODE_ENV=production \
PORT=8080 \
WEBSITES_PORT=8080 \
KEY_VAULT_NAME=ds-platform-kv \
AZURE_AUTHORITY_HOST=https://login.microsoftonline.us \
NEXTAUTH_URL=https://ds-platform.azurewebsites.us
NEXTAUTH_SECRET belongs in Key Vault

The NEXTAUTH_SECRET is the JWT signing key. If it is in App Service configuration, it is visible to anyone with access to the Azure portal. Fetch it from Key Vault at runtime instead. Your auth.ts should call getSecret('nextauth-secret') during initialization.


Health check endpoint

Azure App Service pings a health check endpoint to determine if your app is ready to receive traffic. If the health check fails, App Service restarts the container.

src/app/api/health/route.ts
import { NextResponse } from 'next/server';
export async function GET() {
// Basic health: server is running and can respond
const health = {
status: 'healthy',
timestamp: new Date().toISOString(),
version: process.env.BUILD_ID ?? 'unknown',
environment: process.env.NODE_ENV,
};
// Optional: verify downstream dependencies
// Only if you want health to reflect DB connectivity
// Caution: this makes health checks slower and can
// cause restart loops if the DB is temporarily down
return NextResponse.json(health, { status: 200 });
}
💡Keep health checks simple

Do not add database connectivity checks to the health endpoint unless you want App Service to restart your app every time Azure SQL has a blip. A healthy app that cannot reach the database should return 200 (it is alive) and handle the DB failure gracefully at the route level. Reserve dependency checks for a separate /api/ready endpoint.

Configure the health check in the Azure portal: App Service > Health check > Path: /api/health. Set the threshold to 3 failed checks before restart.


See it in action

Claude Code — AI-assisted
/home/user $ Generate a deploy.sh script for our Next.js platform. We deploy to Azure Government App Service. The app name is ds-platform and the resource group is DS-Platform-RG.
I'll create the deployment script with standalone build, static copy, and gov-specific settings. Created deploy.sh with these stages: 1. npm ci && npm run build 2. cp -r .next/static .next/standalone/.next/static 3. cp -r public .next/standalone/public 4. zip -r deploy.zip .next/standalone/ -x '*.map' 5. az cloud set --name AzureUSGovernment 6. az webapp deploy --type zip 7. Health check loop (12 attempts, 5s interval) The script uses set -euo pipefail so any failure stops the deployment. Source maps are excluded from the zip (*.map) to reduce package size and avoid exposing source code in production. Run with: ./deploy.sh Dry run: ./deploy.sh --dry-run
/home/user $ The deployment succeeded but the site shows unstyled HTML. No CSS, no JS loading. Server logs show no errors.
Classic missing static files. The standalone build does not include .next/static/ or public/ — you have to copy them manually. Checking deploy.sh... The copy commands are there: ``` cp -r .next/static .next/standalone/.next/static cp -r public .next/standalone/public ``` But wait — the zip command is: ``` cd .next/standalone && zip -r ../../deploy.zip . ``` Let me verify the zip contents: ``` $ unzip -l deploy.zip | grep static | head -5 ``` If static/ is missing from the zip, the copy ran but the zip was created before the copy. Let me check the script order... Found it: the copy commands are after the zip command. Moving them before the zip step. Fixed deploy.sh — the order is now: 1. npm run build 2. cp static files into standalone 3. zip the standalone directory 4. deploy
/home/user $ What's the expected package size for our Next.js 15.5 app?
For a Next.js 15.5 standalone build with MUI 7, Tailwind v4, and MSAL: - Standalone server + bundled node_modules: ~45-65 MB - .next/static (client JS/CSS chunks): ~8-15 MB - public/ (favicons, manifests, images): varies - Total zip (excluding source maps): ~25-40 MB If your zip is over 100 MB, check for: - Source maps included (.map files) — exclude with -x '*.map' - Full node_modules copied instead of standalone's pruned version - Large static assets in public/ that should be in Blob Storage Azure App Service zip deploy has a 2 GB limit, so size is rarely a blocker. But smaller packages deploy faster — the upload goes through Azure's ingestion pipeline.
/home/user $

Common deployment failures

SymptomCauseFix
Blank page, no CSS or JSStatic files not copied into standaloneRun cp -r .next/static .next/standalone/.next/static and cp -r public .next/standalone/public before zipping
EADDRINUSE: address already in use :::8080Previous process did not shut downApp Service handles this — it restarts the container. If persistent, check for a custom server.js that hardcodes a port
MODULE_NOT_FOUND: applicationinsightsNative module not in standalone bundleAdd applicationinsights to serverExternalPackages in next.config.ts
Health check fails, app restarts in loopHealth endpoint checks DB, DB is slow to connectSimplify health check to return 200 without dependency checks
az webapp deploy returns 403PAT or service principal lacks deployment permissionsVerify the identity has the Website Contributor role on the App Service resource
CORS errors in browserNEXTAUTH_URL uses .azurewebsites.net instead of .usUpdate to .azurewebsites.us for Azure Government

Deployment to multiple environments

The DS platform deploys to three environments: dev, staging, and production. The deployment script is the same; only the environment variables change.

Terminal window
# Dev
AZURE_RESOURCE_GROUP=DS-Platform-Dev-RG AZURE_APP_NAME=ds-platform-dev ./deploy.sh
# Staging
AZURE_RESOURCE_GROUP=DS-Platform-Staging-RG AZURE_APP_NAME=ds-platform-staging ./deploy.sh
# Production (requires manual approval in Azure Pipelines)
AZURE_RESOURCE_GROUP=DS-Platform-Prod-RG AZURE_APP_NAME=ds-platform-prod ./deploy.sh
💬Manual deployment is a stepping stone

This deploy.sh script is useful for understanding the process and for emergency deployments. In the next lesson, you will automate this entire workflow in Azure Pipelines — including the approval gate before production. Once the pipeline is working, manual deployments should only happen when the pipeline itself is broken.


KNOWLEDGE CHECK

You run npm run build with output: 'standalone' in next.config.ts, then immediately zip the .next/standalone directory and deploy it to Azure App Service. The application starts and API routes return JSON correctly, but all pages render as unstyled HTML with no client-side interactivity. What is missing?


Key takeaways

  • Standalone output mode produces a minimal server bundle without node_modules. Add output: 'standalone' to next.config.ts and add native SDKs like applicationinsights to serverExternalPackages.
  • Copy static files before zipping. cp -r .next/static .next/standalone/.next/static and cp -r public .next/standalone/public are mandatory. This is the most common Next.js deployment failure on Azure App Service.
  • Azure Gov uses .azurewebsites.us, not .azurewebsites.net. All URLs, CORS origins, and callback configurations must use the gov domain.
  • Port 8080 is the Azure App Service convention. Set both PORT and WEBSITES_PORT to 8080 in App Service configuration.
  • Health checks should be simple. Return 200 if the process is alive. Do not check database connectivity in the health endpoint unless you want restart loops during transient DB outages.
  • Secrets belong in Key Vault, not in App Service configuration. NEXTAUTH_SECRET, database connection strings, and API keys should be fetched at runtime via Managed Identity.

What’s next

In the capstone lesson, you will automate this entire deployment process in an Azure Pipelines YAML pipeline: lint, test, build, Docker image, and deploy to Azure Gov — with approval gates, SAST scanning, and environment-specific variables.