← Back to Blog
Next.js2026-05-14Β·80 min read

We moved our Next.js app from Vercel to Google Cloud Run. Here's how it actually went.

By Abhay

Architectural Coherence: Migrating Next.js from Platform-as-a-Service to Managed Cloud Infrastructure

Current Situation Analysis

Platform-as-a-Service (PaaS) providers like Vercel solve a specific problem: they abstract infrastructure so engineering teams can ship features without managing servers, DNS, or SSL certificates. This abstraction works brilliantly during the validation phase. The moment an application matures, however, the abstraction becomes a liability. Teams inevitably build backend workers, message queues, and data pipelines that live outside the PaaS boundary. The result is a fractured architecture where the user-facing layer operates under one cloud provider's mental model, while the business logic runs under another.

This fragmentation creates three compounding operational debts:

  1. Secret Management Divergence: PaaS platforms often provide simplified secret storage with strict read restrictions for security. While this protects against platform-level breaches, it creates a migration paradox. You cannot export sensitive variables; you must rotate them. The April 2026 Vercel security incident demonstrated why this restriction exists: attackers who compromise platform internal systems can enumerate non-sensitive variables, but properly classified sensitive variables remain cryptographically isolated. When migrating, teams must treat secret rotation not as an afterthought, but as the critical path.
  2. Latency Compounding: Modern applications rarely serve all routes from cache. Server actions, real-time streams, and personalized dashboards require origin fetches. When traffic routes through a PaaS edge, then through a CDN, then to a geographically distant origin, TTFB compounds. Consolidating the stack under a single cloud provider with native load balancing and regional deployment reduces hop count and stabilizes uncached response times.
  3. Observability & IAM Fragmentation: Monitoring alerts, service accounts, and deployment pipelines split across two providers force engineers to maintain dual operational runbooks. Context switching between Vercel's dashboard and GCP's Cloud Monitoring increases mean-time-to-resolution during incidents.

The migration from a PaaS to managed cloud infrastructure is rarely about raw compute cost. It is about architectural coherence. When the frontend, backend workers, and data layer share the same identity provider, network topology, and deployment pipeline, operational friction drops dramatically. The challenge lies in executing the transition without introducing downtime, secret exposure, or deployment pipeline failures.

WOW Moment: Key Findings

The decision to migrate should be driven by measurable operational deltas, not anecdotal cost complaints. The following comparison illustrates the structural shift when moving a Next.js 16 application from Vercel Pro to Google Cloud Run, backed by production telemetry and infrastructure mapping.

Dimension Vercel Pro (PaaS) GCP Cloud Run (Managed) Operational Impact
Uncached TTFB (India) ~170ms ~45ms 73% reduction in origin-pull latency for dynamic routes
Secret Recovery Path Read-restricted; requires upstream rotation Native Secret Manager; programmatic rotation Eliminates platform lock-in for credential management
Deployment Control Platform-managed rollouts; limited canary config Traffic splitting, revision tagging, IAM-scoped deploys Enables deterministic rollback and audit trails
Cost Predictability Per-invocation + egress + add-ons vCPU/memory + network + LB Linear scaling; credits offset baseline; predictable at scale
Monitoring Scope Platform logs + Vercel Analytics Cloud Monitoring + Cloud Logging + Trace Unified alerting across web, workers, and queues

Why this matters: The latency improvement alone justifies the migration for regions distant from PaaS edge nodes. More importantly, the shift to managed infrastructure transforms deployment from a black-box operation into a transparent, auditable pipeline. Teams gain explicit control over traffic shifting, revision lifecycle, and identity permissions. This visibility is critical for compliance, incident response, and long-term platform stability.

Core Solution

Migrating a Next.js application to Cloud Run requires three coordinated workstreams: infrastructure alignment, secret rotation orchestration, and deployment pipeline hardening. Each component must be implemented with explicit failure boundaries.

1. Infrastructure Alignment: Load Balancer & Cloud Run

Cloud Run does not expose direct public IPs by default. Production traffic should route through a Global HTTPS Load Balancer with Cloud Armor for WAF protection and Cloud CDN for static asset caching. This topology provides SSL termination, DDoS mitigation, and flexible routing rules.

Architecture Decision: Use a self-managed certificate initially, then transition to a Google-managed certificate. Google's managed SSL provisioning requires DNS validation, which creates a chicken-and-egg scenario during cutover. A self-managed certificate (such as a Cloudflare Origin Certificate) ensures immediate TLS handshake success, while the managed certificate provisions in the background.

Terraform Configuration (Infrastructure Layer):

resource "google_compute_global_address" "lb_ip" {
  name = "app-lb-address"
}

resource "google_compute_ssl_certificate" "origin_cert" {
  name        = "app-origin-cert"
  certificate = file("${var.cert_dir}/origin.crt")
  private_key = file("${var.cert_dir}/origin.key")
}

resource "google_compute_target_https_proxy" "lb_proxy" {
  name            = "app-https-proxy"
  url_map         = google_compute_url_map.app_routing.self_link
  ssl_certificates = [
    google_compute_ssl_certificate.origin_cert.self_link,
    google_compute_managed_ssl_certificate.app_cert.self_link
  ]
}

resource "google_compute_backend_service" "cloud_run_backend" {
  name    = "app-cloudrun-backend"
  protocol = "HTTPS"
  backend {
    group = google_compute_region_network_endpoint_group.cloud_run_neg.self_link
  }
  security_policy = google_cloud_armor_security_policy.app_waf.self_link
}

2. Secret Rotation Orchestration

Vercel's sensitive classification prevents plaintext retrieval via CLI or dashboard. This is a security feature, not a bug. When leaving the platform, you must rotate every sensitive credential. The rotation workflow should be automated to prevent drift.

TypeScript Rotation Helper:

import { SecretManagerServiceClient } from '@google-cloud/secret-manager';
import { execSync } from 'child_process';

const client = new SecretManagerServiceClient();

async function rotateSecret(secretName: string, newPayload: string) {
  const parent = `projects/${process.env.GCP_PROJECT_ID}`;
  const secretPath = `${parent}/secrets/${secretName}`;
  
  // Create new version in Secret Manager
  await client.addSecretVersion({
    parent: secretPath,
    payload: { data: Buffer.from(newPayload, 'utf8') }
  });

  // Verify downstream acceptance (example: payment provider)
  execSync(`curl -s -X POST https://api.provider.com/v1/keys/verify \
    -H "Authorization: Bearer ${newPayload}" \
    -d '{"test": true}'`);

  console.log(`βœ… ${secretName} rotated and verified`);
}

// Usage: rotateSecret('STRIPE_WEBHOOK_SECRET', process.env.NEW_STRIPE_KEY);

3. Deployment Pipeline Hardening

Cloud Run deployments require explicit readiness verification before traffic shifting. The gcloud CLI does not support JMESPath filtering in --format=value(). Attempting to use it returns an empty string, causing silent deployment failures where the new revision sits at 0% traffic while the old revision continues serving.

TypeScript Readiness Monitor:

import { execSync } from 'child_process';
import { setTimeout } from 'timers/promises';

interface DeployConfig {
  service: string;
  image: string;
  maxRetries: number;
}

async function waitForRevisionReady(config: DeployConfig) {
  const { service, image, maxRetries } = config;
  
  // Deploy without traffic
  execSync(`gcloud run services update ${service} --image ${image} --no-traffic`);
  
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    const output = execSync(
      `gcloud run services describe ${service} --format='value(status.latestReadyRevisionName)'`,
      { encoding: 'utf8' }
    ).trim();
    
    const expectedRevision = image.split(':').pop() || 'latest';
    if (output === expectedRevision) {
      console.log(`βœ… Revision ${expectedRevision} is ready`);
      return true;
    }
    
    console.log(`⏳ Waiting for readiness... attempt ${attempt + 1}/${maxRetries}`);
    await setTimeout(5000);
  }
  
  throw new Error('Deployment readiness timeout');
}

Pitfall Guide

1. The JMESPath Mirage in gcloud CLI

Explanation: Developers migrating from AWS often port deployment scripts that use [?type=Ready] JMESPath filters. gcloud --format=value() does not support JMESPath. The command returns an empty string, the readiness poll times out, and traffic never shifts. Fix: Query the service-level field status.latestReadyRevisionName directly. Avoid JMESPath syntax in gcloud format strings.

2. Artifact Registry Tag Permission Gap

Explanation: Canary deployments often tag the current production image as :previous-good for instant rollback. The roles/artifactregistry.writer role permits image pushes but lacks artifactregistry.tags.delete. On the second deployment, the tag update fails silently, leaving the old revision active. Fix: Assign roles/artifactregistry.repoAdmin to the CI/CD service account. This covers both image creation and tag mutation.

3. Vercel Sensitive Variable Recovery Fallacy

Explanation: Teams assume they can export sensitive environment variables before decommissioning a Vercel project. The security model explicitly prevents plaintext retrieval. Attempting to downgrade variables to encrypted before export weakens security posture and violates compliance baselines. Fix: Treat rotation as mandatory. Generate new credentials, provision them in Secret Manager, update upstream providers, verify traffic, then revoke old keys.

4. Cloudflare Strict Mode Certificate Mismatch

Explanation: Cloudflare's Full (strict) mode requires a valid origin certificate at TLS handshake. Google-managed certificates remain in PROVISIONING until DNS validation completes. If DNS flips before provisioning finishes, Cloudflare drops connections or fails open depending on edge configuration. Fix: Upload a Cloudflare Origin Certificate (15-year validity, internal CA) to the load balancer as the primary certificate. Add the Google-managed certificate as a fallback. The origin cert ensures immediate handshake success; the managed cert provisions asynchronously.

5. Cloud Run Concurrency & Timeout Defaults

Explanation: Next.js server actions and SSE streams often hold connections longer than Cloud Run's default 300-second timeout. The default concurrency of 80 can also cause request queuing under burst traffic. Fix: Explicitly set --max-instances, --concurrency, and --timeout during service creation. Monitor Cloud Run metrics to tune concurrency based on CPU/memory saturation, not just request count.

6. Workload Identity Federation Scope Drift

Explanation: GitHub Actions authenticating to GCP via Workload Identity Federation require precise OIDC claims and IAM bindings. Overly broad bindings (roles/owner) or missing attribute.repository constraints create privilege escalation risks. Fix: Scope the IAM binding to the exact repository and branch. Use gcloud iam workload-identity-pools create-cred-binding with explicit attribute conditions. Rotate tokens via short-lived OIDC assertions, never long-lived service account keys.

7. Premature DNS Cutover Without Synthetic Validation

Explanation: Flipping DNS immediately after infrastructure provisioning skips validation of routing rules, certificate chains, and application health checks. Bot traffic or misconfigured headers can trigger cascading 5xx errors before human monitoring detects them. Fix: Perform a /etc/hosts or curl --resolve smoke test against the load balancer IP. Validate OAuth callbacks, payment webhooks, and streaming endpoints. Only flip DNS after synthetic traffic confirms end-to-end functionality.

Production Bundle

Action Checklist

  • Inventory all environment variables and classify as public, encrypted, or sensitive
  • Provision Secret Manager secrets and map IAM bindings to Cloud Run service account
  • Generate and rotate all sensitive credentials; verify upstream provider acceptance
  • Deploy Cloud Run service with --no-traffic and verify revision readiness
  • Configure Global HTTPS Load Balancer with Cloud Armor WAF and Cloud CDN
  • Upload Cloudflare Origin Certificate as primary; attach Google-managed cert as fallback
  • Execute /etc/hosts smoke test across critical user journeys
  • Shift traffic to new revision; monitor Cloud Run metrics for 15 minutes
  • Update DNS records; verify Cloudflare edge propagation
  • Decommission Vercel project after 7-day observation window

Decision Matrix

Scenario Recommended Approach Why Cost Impact
Early-stage startup (<10k MAU) Stay on Vercel Operational overhead outweighs latency/cost benefits Lower engineering time cost
Regulated enterprise (SOC2/ISO) GCP Cloud Run + Secret Manager Unified IAM, audit trails, and compliance-aligned secret rotation Higher infra cost, lower compliance risk
High uncached traffic (>60% dynamic) GCP Cloud Run + Regional LB Direct origin routing eliminates PaaS edge hop latency Predictable egress costs, improved TTFB
Multi-cloud strategy GCP Cloud Run + Terraform Infrastructure-as-code enables provider-agnostic deployments Moderate initial setup, high long-term flexibility

Configuration Template

# next.config.js - Cloud Run Optimization
/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'standalone',
  experimental: {
    serverActions: {
      allowedOrigins: ['your-production-domain.com']
    }
  },
  headers: async () => [
    {
      source: '/:path*',
      headers: [
        { key: 'X-Content-Type-Options', value: 'nosniff' },
        { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' }
      ]
    }
  ]
};

module.exports = nextConfig;
# Dockerfile - Production Build
FROM node:20-alpine AS base
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

FROM node:20-alpine AS builder
WORKDIR /app
COPY . .
RUN npm ci && npm run build

FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static

EXPOSE 8080
CMD ["node", "server.js"]

Quick Start Guide

  1. Build & Push: Run docker build -t gcr.io/$PROJECT_ID/app:latest . and push to Artifact Registry.
  2. Deploy Revision: Execute gcloud run deploy app --image gcr.io/$PROJECT_ID/app:latest --region us-central1 --no-traffic --allow-unauthenticated.
  3. Verify Readiness: Poll gcloud run services describe app --format='value(status.latestReadyRevisionName)' until it matches the deployed tag.
  4. Shift Traffic: Run gcloud run services update-traffic app --to-latest --region us-central1.
  5. Validate: Curl the load balancer IP with Host header set to your domain. Confirm 200 responses across dynamic routes before updating DNS.