Deploying Next.js 15 to Google Cloud Run: From Zero to HTTPS in 2 Hours
Complete guide to deploying Next.js 15 with Docker to Google Cloud Run, configuring custom domains, SSL certificates, and global load balancing. Real troubleshooting included.
I originally deployed the marketplace now known as Tons of Skills from scratch to production on Google Cloud Run with custom domain and SSL. The commands below preserve the original deployment lessons while redacting the retired host.
The Goal
Deploy a Next.js 15 application to Google Cloud Run with:
- Custom domain ([retired legacy public domain])
- Google-managed SSL certificate
- Global load balancer with HTTP/2
- Serverless auto-scaling (0-10 instances)
- Production-grade infrastructure as code
Starting point: A working Next.js 15 app on localhost.
Target: Live site with HTTPS at a custom domain.
Time budget: Get it done today.
The Stack
Application:
- Next.js 15 (App Router) with standalone mode
- React 19 + TypeScript
- Node 20 runtime
- Docker multi-stage build
Infrastructure:
- Google Cloud Run (serverless containers)
- Cloud Build (CI/CD)
- Artifact Registry (container images)
- Global Load Balancer (anycast IP)
- Google-managed SSL certificates
Domain: Porkbun DNS pointing to Google Cloud
Part 1: Docker Configuration
Next.js 15 requires output: 'standalone' in next.config.js for Docker deployment. This creates a minimal production build without node_modules bloat.
// next.config.js
module.exports = {
output: 'standalone',
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'storage.googleapis.com' },
{ protocol: 'https', hostname: '*.googleusercontent.com' },
],
},
};
Multi-Stage Dockerfile
I used a three-stage build: deps → builder → runner.
# Stage 1: Dependencies
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --legacy-peer-deps
# Stage 2: Builder
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# Stage 3: Runner
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
RUN chown -R nextjs:nodejs /app
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
Key decisions:
- Alpine Linux for small image size (saves bandwidth and startup time)
- Non-root user (security best practice)
- Standalone mode copies only what’s needed to run
- Environment variable PORT for Cloud Run compatibility
Part 2: The First Deployment Attempt
I started with the simplest approach: direct Cloud Run deployment.
gcloud run deploy claudecodeplugins-web \
--source . \
--region us-central1 \
--allow-unauthenticated \
--port 3000
Error 1: Tailwind CSS TypeScript Error
Type '["class"]' is not assignable to type 'DarkModeStrategy | undefined'
The build failed because Tailwind CSS 4 changed darkMode syntax. Quick fix in tailwind.config.ts:
// Before: darkMode: ["class"]
// After:
darkMode: "class"
Error 2: Missing Public Directory
COPY failed: stat app/public: file does not exist
The Dockerfile expected a public directory that didn’t exist. Created it:
mkdir -p apps/web/public
touch apps/web/public/.gitkeep
Part 3: Container Registry Nightmares
Now the build succeeded but the push failed.
Error 3: Container Registry Permission Denied
denied: Permission "artifactregistry.repositories.uploadArtifacts" denied
I initially tried to use the old Container Registry (gcr.io), which has known permission issues. The fix: migrate to Artifact Registry.
# Create Artifact Registry repository
gcloud artifacts repositories create app \
--repository-format=docker \
--location=us-central1
# Update image path from:
# gcr.io/PROJECT/IMAGE
# To:
# us-central1-docker.pkg.dev/PROJECT/REPO/IMAGE
Error 4: Organization Policy Blocking Public Access
Cloud Run deployed successfully but returned 403 Forbidden. The error:
One or more users named in the policy do not belong to a permitted customer
This was an organization-level IAM policy restricting allUsers access. I needed to override it at the project level:
# org-policy-override.yaml
constraint: constraints/iam.allowedPolicyMemberDomains
listPolicy:
allValues: ALLOW
Applied with:
gcloud resource-manager org-policies set-policy \
org-policy-override.yaml \
--project=PROJECT_ID
Then set the IAM policy directly:
{
"bindings": [{
"role": "roles/run.invoker",
"members": ["allUsers"]
}]
}
gcloud run services set-iam-policy claudecodeplugins-web \
iam-policy.json \
--region=us-central1
Success! The Cloud Run service was now publicly accessible.
Part 4: Custom Domain and Load Balancer
Google Cloud Run supports custom domains two ways:
- Cloud Run Domain Mapping - Simple, but requires Google Search Console verification
- Load Balancer + Serverless NEG - More complex, but no Search Console needed
I chose the Load Balancer approach because:
- No domain verification required
- Global anycast IP (faster worldwide)
- Built-in CDN support
- More control over routing
Architecture
Internet (Port 443/80)
↓
Global Load Balancer (35.201.66.187)
↓
SSL Certificate (Google-managed)
↓
Serverless NEG (Network Endpoint Group)
↓
Cloud Run Service
Setting Up the Load Balancer
Step 1: Create Serverless Network Endpoint Group
This connects the Load Balancer to Cloud Run.
gcloud compute network-endpoint-groups create claudecodeplugins-neg \
--region=us-central1 \
--network-endpoint-type=serverless \
--cloud-run-service=claudecodeplugins-web
Step 2: Create Backend Service
gcloud compute backend-services create claudecodeplugins-backend \
--global \
--load-balancing-scheme=EXTERNAL_MANAGED
Error 5: Backend Service Port Name Conflict
I initially tried to specify --protocol=HTTPS but got:
Port name is not supported for Serverless NEG
Serverless NEGs automatically determine ports. Just create the backend service without protocol specifications.
Step 3: Add Backend
gcloud compute backend-services add-backend claudecodeplugins-backend \
--global \
--network-endpoint-group=claudecodeplugins-neg \
--network-endpoint-group-region=us-central1
Step 4: Create URL Map
gcloud compute url-maps create claudecodeplugins-lb \
--default-service=claudecodeplugins-backend \
--global
Step 5: Reserve Static IP
gcloud compute addresses create claudecodeplugins-ip \
--ip-version=IPV4 \
--global
This gave me: 35.201.66.187
Step 6: Create SSL Certificate
# Historical command shape; the reserved host intentionally does not resolve.
gcloud compute ssl-certificates create claudecodeplugins-cert \
--domains=retired-domain.invalid \
--global
Google-managed certificates are free and auto-renewing. But they require domain verification via HTTP challenge on port 80.
Step 7: Create HTTPS Proxy
gcloud compute target-https-proxies create claudecodeplugins-https-proxy \
--url-map=claudecodeplugins-lb \
--ssl-certificates=claudecodeplugins-cert \
--global
Step 8: Create Forwarding Rule (HTTPS)
gcloud compute forwarding-rules create claudecodeplugins-https-rule \
--address=claudecodeplugins-ip \
--target-https-proxy=claudecodeplugins-https-proxy \
--global \
--ports=443
Part 5: The SSL Certificate Problem
After configuring everything, I checked the SSL certificate status:
gcloud compute ssl-certificates describe claudecodeplugins-cert --global
Status: PROVISIONING with domain status FAILED_NOT_VISIBLE
This means Google’s verification bot couldn’t reach my domain on HTTP port 80 to complete the challenge.
The Issue: I only configured HTTPS (port 443), but Google needs HTTP (port 80) for verification.
The Fix: Add HTTP forwarding.
Step 9: Create HTTP Proxy and Forwarding Rule
gcloud compute target-http-proxies create claudecodeplugins-http-proxy \
--url-map=claudecodeplugins-lb \
--global
gcloud compute forwarding-rules create claudecodeplugins-http-rule \
--address=claudecodeplugins-ip \
--target-http-proxy=claudecodeplugins-http-proxy \
--global \
--ports=80
Now both ports 80 and 443 route to the same backend.
Part 6: DNS Configuration
In Porkbun, I added A records pointing to the static IP:
Type: A
Host: www
Answer: 35.201.66.187
TTL: 3600
Type: A
Host: @
Answer: 35.201.66.187
TTL: 3600
Verified DNS propagation:
# Historical verification shape; the reserved host intentionally does not resolve.
dig +short retired-domain.invalid @8.8.8.8
# Historical output: 35.201.66.187
Part 7: Waiting for SSL
SSL certificate provisioning takes 15-60 minutes after DNS propagation. I monitored it with:
gcloud compute ssl-certificates describe claudecodeplugins-cert \
--global \
--format="yaml(managed.status, managed.domainStatus)"
Progression:
PROVISIONING+FAILED_NOT_VISIBLE(DNS not propagated)PROVISIONING+PROVISIONING(Google verifying domain)ACTIVE+ACTIVE(Ready!)
After about 30 minutes, the certificate became active.
Part 8: Testing the Live Site
curl -I https://retired-domain.invalid
Historical output (the reserved host no longer reproduces this response):
HTTP/2 200
vary: rsc, next-router-state-tree, next-router-prefetch
x-nextjs-cache: HIT
x-powered-by: Next.js
cache-control: s-maxage=31536000
content-type: text/html; charset=utf-8
Success! The site was live with:
- ✅ HTTPS with valid SSL certificate
- ✅ HTTP/2 protocol
- ✅ Next.js static pre-rendering
- ✅ CDN edge caching
- ✅ 75ms response time
Part 9: Infrastructure as Code
I documented everything in a comprehensive runbook (gcp-cloud-run-launch.md) and created:
- cloudbuild.yaml - Automated CI/CD pipeline
steps:
- name: "gcr.io/cloud-builders/docker"
args: ["build", "-t", "${_REGION}-docker.pkg.dev/${_PROJECT_ID}/${_REPO_NAME}/${_IMAGE_NAME}:${_TAG}", "apps/web"]
- name: "gcr.io/cloud-builders/docker"
args: ["push", "${_REGION}-docker.pkg.dev/${_PROJECT_ID}/${_REPO_NAME}/${_IMAGE_NAME}:${_TAG}"]
- name: "gcr.io/google.com/cloudsdktool/cloud-sdk"
entrypoint: gcloud
args:
- "run"
- "deploy"
- "${_SERVICE_NAME}"
- "--image=${_REGION}-docker.pkg.dev/${_PROJECT_ID}/${_REPO_NAME}/${_IMAGE_NAME}:${_TAG}"
- "--region=${_REGION}"
- "--allow-unauthenticated"
- "--port=3000"
- Makefile - Convenience commands
deploy:
\tgcloud builds submit --config=cloudbuild.yaml
url:
\t@gcloud run services describe $(SERVICE_NAME) --region $(REGION) --format='value(status.url)'
logs:
\tgcloud run services logs read $(SERVICE_NAME) --region $(REGION) --limit=100
rollback:
\t# Roll back to previous revision
-
.gcloud.env.example - Environment variable documentation
-
README_DEPLOY.md - 15-line quickstart guide
Future deployments are now just:
make deploy
Key Learnings
What Worked
- Multi-stage Docker builds - Reduced final image size by 70%
- Artifact Registry over Container Registry - Better permissions, modern tooling
- Load Balancer approach - No Search Console verification needed
- Comprehensive documentation - Made second deployment trivial
What Didn’t Work (At First)
- Missing HTTP port 80 - SSL verification requires it
- Organization policies - Required project-level override
- Container Registry permissions - Migrated to Artifact Registry instead
- Tailwind CSS array syntax - Changed to string format
- Serverless NEG with port names - Let Google auto-detect ports
Cost Breakdown
Monthly estimate:
- Cloud Run: $0-20 (pay per use, 0 min instances)
- Load Balancer: ~$18/month (global)
- SSL Certificate: $0 (Google-managed)
- Total: ~$20-40/month
For comparison, a single $5 VPS would be cheaper but:
- No auto-scaling
- Manual SSL certificate management
- No global CDN
- Manual deployments
- Single point of failure
The serverless approach is worth it for production sites.
The Complete Command Sequence
For reference, here’s the exact order of commands that worked:
# 1. Set up project
gcloud config set project PROJECT_ID
# 2. Enable APIs
gcloud services enable run.googleapis.com \
cloudbuild.googleapis.com \
artifactregistry.googleapis.com \
compute.googleapis.com
# 3. Create Artifact Registry
gcloud artifacts repositories create app \
--repository-format=docker \
--location=us-central1
# 4. Build and push Docker image
gcloud builds submit \
--tag us-central1-docker.pkg.dev/PROJECT_ID/app/web:latest
# 5. Deploy to Cloud Run
gcloud run deploy claudecodeplugins-web \
--image us-central1-docker.pkg.dev/PROJECT_ID/app/web:latest \
--region us-central1 \
--allow-unauthenticated \
--port 3000
# 6. Create load balancer infrastructure
# (Serverless NEG, backend service, URL map, SSL cert, static IP, proxies, forwarding rules)
# See full commands in article above
# 7. Configure DNS
# Add A records in DNS provider pointing to static IP
# 8. Wait for SSL certificate provisioning (15-60 minutes)
# 9. Test
curl -I https://retired-domain.invalid
# Historical verification shape; the reserved host intentionally does not resolve.
Conclusion
Total time from “I need to deploy this” to “site is live with HTTPS”: 2 hours.
Most of that was waiting for SSL certificate provisioning (30 minutes) and troubleshooting permissions (45 minutes). The actual configuration took about 45 minutes.
The result: A production-grade deployment with auto-scaling, global CDN, managed SSL, and one-command future deployments.
The full runbook and all configuration files are in the project repository. Future deployments are literally just make deploy.
Related Posts
- Debugging Slack Integration: From 6 Duplicate Responses to Instant Acknowledgment
- Building a 254-Table BigQuery Schema in 72 Hours
- Building Production Testing Suite with Playwright and GitHub Actions
Links
- Historical live site: [retired legacy public domain]
- Architecture: Global Load Balancer → Serverless NEG → Cloud Run
- Response Time: 75ms average
- Stack: Next.js 15 + Docker + Google Cloud Run