DEV Community

Steve Zhang
Steve Zhang

Posted on

From 1.2GB to 24MB: How I Sped Up Our Next.js CI/CD Pipeline by 4 in One Afternoon

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
Enter fullscreen mode Exit fullscreen mode

Root Cause #1: Ignoring output: 'standalone'

next.config.js had this:

const nextConfig = {
  output: 'standalone',  // ← was there the whole time
  ...
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
# Then published the ENTIRE working directory as the artifact
- task: PublishPipelineArtifact@0
  inputs:
    targetPath: '$(System.DefaultWorkingDirectory)'  # 1.2 GB of loose files + zip
Enter fullscreen mode Exit fullscreen mode

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 ..
Enter fullscreen mode Exit fullscreen mode
- task: PublishPipelineArtifact@0
  inputs:
    targetPath: '$(System.DefaultWorkingDirectory)/deploy.zip'  # 24 MB ✅
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

Issues:

  • sleep 30 wastes 30s regardless of readiness
  • Two curl calls 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
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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`
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

11 minutes saved per deployment. At 5 deploys/day across 2 apps: ~2 hours of pipeline time saved daily.


Key Takeaways

  1. Your build tool probably already has the solution. output: 'standalone' has been in Next.js since v12.2.
  2. Every az webapp config call is a hidden restart. Count them. Remove the no-ops.
  3. Don't use AzureCLI@2 for things that don't need Azure CLI. A health check is just curl.
  4. Publish only what you deploy. If the deploy stage uses deploy.zip, publish only deploy.zip.
  5. Validate with exit 1. Silent success on a broken deployment is worse than no health check.

Top comments (0)