The Situation
Our team's CI/CD pipeline on Azure DevOps was taking 15 minutes to complete on every push to develop. You'd merge a PR, grab a coffee, come back — and it was still running. A 15-minute feedback loop breaks flow state — by the time the pipeline finishes, you've already switched context twice and forgotten what you were checking.
I spent an afternoon digging into the Azure DevOps logs. Here's what I found.
The Numbers (Before)
Artifact content (uncompressed): 1,218 MB (1.2 GB)
Artifact downloaded (compressed): 614 MB
Download time: 3-4 min
Pipeline breakdown:
Build stage: ~5 min (Docker build + artifact)
Download artifact: ~3 min (614 MB over the wire)
Configure App Service: 2m54s (5 Azure API calls)
Deploy (AzureWebApp@1): ~1 min
Validate: 2m07s (sleep 30 + 3×30s probes)
─────────────────────────────────
Total: ~15 min
Root Cause #1: Ignoring output: 'standalone'
next.config.js had this:
const nextConfig = {
output: 'standalone', // ← was there the whole time
...
};
output: 'standalone' tells Next.js to produce .next/standalone/ — a self-contained directory with only what's needed at runtime. Trimmed node_modules. Auto-generated server.js. No source files. No dev dependencies.
But the pipeline was ignoring it:
# Old pipeline — copies everything from Docker
docker cp deployImage:/app/node_modules . # 600 MB 😱
docker cp deployImage:/app/src .
docker cp deployImage:/app/.next .
docker cp deployImage:/app/server.js .
# ... more files
/bin/zip -r deploy.zip .env .next public node_modules package.json \
next.config.js jsconfig.json postcss.config.mjs decs.d.ts src server.js
# Then published the ENTIRE working directory as the artifact
- task: PublishPipelineArtifact@0
inputs:
targetPath: '$(System.DefaultWorkingDirectory)' # 1.2 GB of loose files + zip
Azure DevOps compressed this to 614 MB for transfer. The deploy stage downloaded 614 MB to use a 24 MB zip buried inside it.
The fix:
# New pipeline — standalone only
docker cp deployImage:/app/.next/standalone .
cp .env standalone/
cd standalone && /bin/zip -r ../deploy.zip . && cd ..
- task: PublishPipelineArtifact@0
inputs:
targetPath: '$(System.DefaultWorkingDirectory)/deploy.zip' # 24 MB ✅
Standalone size breakdown:
node_modules/ (trimmed): 18 MB (was ~600 MB)
.next/ (compiled app): 9.7 MB
public/ + .env: <1 MB
─────────────────────────────────
Uncompressed: 29 MB (was 1,218 MB — 42× smaller)
Zipped/downloaded: 23.7 MB (was 614 MB — 26× smaller)
Root Cause #2: Azure App Service Restart Thrashing
Every az webapp config call triggers an automatic App Service restart. I counted ours:
Step Restart Note
──────────────────────────────────────────────────────────────
"Restart App Service" task #1 ← unnecessary pre-deploy kill
appsettings set (call 1) #2 ← Azure auto-restarts on settings change
appsettings set (call 2) #3 ← another auto-restart
az webapp config set #4 ← already set, pure no-op + restart
AzureWebApp@1 deploy #5 ← ✅ the REAL deploy restart
"Restart app" task #6 ← deploy already restarted!
With 5 restarts firing in rapid succession, Azure was constantly aborting in-progress boots. Each complete App Service boot takes 40–80s. With 5 interrupted boots in sequence, the app took 2+ minutes to stabilize.
Removed calls and savings:
| Removed step | Time saved | Restarts saved |
|---|---|---|
| Initial "Restart App Service" | ~10s | 1 |
appsettings delete (old VITE_ vars, already gone) |
~33s | 0 |
az webapp config set (already configured) |
~37s | 1 |
container delete (Docker config already gone) |
~63s | 0 |
| Final "Restart app" (deploy already restarts) | ~10s | 1 |
| Total | ~153s | 3 fewer restarts |
Root Cause #3: Validate Step Using Azure CLI for a curl
# Old — 8s of az login overhead just to run curl
- task: AzureCLI@2
displayName: 'Validate deployment'
inlineScript: |
sleep 30 # hardcoded 30s wait regardless of app readiness
for i in {1..3}; do
# Failed attempt = 60s (30s health check + 30s fallback to root path)
curl --max-time 30 "$APP_URL/api/health"
curl --max-time 30 "$APP_URL" # unnecessary fallback
sleep 15
done
# No exit 1 if app never responds — silent false success!
Issues:
-
sleep 30wastes 30s regardless of readiness - Two
curlcalls per attempt = 60s wasted per failure - Silent success if all probes fail → broken deployments go undetected
# New — plain Bash, no az login, detect readiness fast
- task: Bash@3
displayName: 'Validate deployment'
script: |
healthy=false
for i in {1..24}; do
if curl -f -s --max-time 5 "$APP_URL/api/health" > /dev/null; then
curl -s --max-time 5 "$APP_URL/api/health"
healthy=true
break
fi
sleep 5
done
if [ "$healthy" != "true" ]; then
echo "##[error]App failed to become healthy after 24 attempts (~2min)"
exit 1
fi
Also added a proper /api/health route — it didn't exist, so health checks were hitting the auth middleware and getting a 307 redirect to the login page:
// src/app/api/health/route.js
export const dynamic = 'force-dynamic';
export function GET() {
return NextResponse.json({ status: 'ok' }, { status: 200 });
}
Bonus Fix: NextAuth Cookie Name Mismatch
While testing locally I hit an infinite sign-in loop. Root cause: cookie security was based on NODE_ENV:
// Before: NODE_ENV=production always sets __Secure- prefix
name: process.env.NODE_ENV === 'production'
? '__Secure-next-auth.session-token'
: 'next-auth.session-token'
But getToken() in middleware used NextAuth's default detection (based on NEXTAUTH_URL). With NEXTAUTH_URL=http://localhost:3000, it looked for next-auth.session-token — but the cookie was set as __Secure-next-auth.session-token. Mismatch → redirect loop.
// After: based on actual URL protocol
const useSecureCookies = process.env.NEXTAUTH_URL?.startsWith('https://') ?? false;
name: `${useSecureCookies ? '__Secure-' : ''}next-auth.session-token`
Rule: cookie security should depend on whether you're serving HTTPS, not whether NODE_ENV is production.
The Numbers (After)
Artifact content (uncompressed): 29 MB (42× smaller)
Artifact downloaded (compressed): 23.7 MB (26× smaller)
Download time: ~4s (50× faster)
Pipeline breakdown:
Build stage: ~2 min (Docker build only, no bloat)
Download artifact: ~4s
Configure App Service: ~47s (2 API calls instead of 5)
Deploy (AzureWebApp@1): ~25s
Validate: <1s (app already ready)
─────────────────────────────────
Total: ~4 min (was ~15 min — 4× faster)
App Service restarts: 2 (was 5 — clean startup every time)
11 minutes saved per deployment. At 5 deploys/day across 2 apps: ~2 hours of pipeline time saved daily.
Key Takeaways
-
Your build tool probably already has the solution.
output: 'standalone'has been in Next.js since v12.2. -
Every
az webapp configcall is a hidden restart. Count them. Remove the no-ops. -
Don't use
AzureCLI@2for things that don't need Azure CLI. A health check is justcurl. -
Publish only what you deploy. If the deploy stage uses
deploy.zip, publish onlydeploy.zip. -
Validate with
exit 1. Silent success on a broken deployment is worse than no health check.
Top comments (0)