Deploying Laravel to Google Cloud Platform: The Complete Journey
I recently finished migrating a Laravel application to Google Cloud Platform. The process involved navigating dozens of undocumented gotchas, failed deployment attempts, and head-scratching debugging sessions. This post shares what I learned so you don't have to discover these issues the hard way.
The final architecture uses Cloud Run for the application, Cloud SQL for PostgreSQL, Cloud Armor for WAF protection, and Cloud Build for CI/CD. It's a solid setup. Getting there was anything but smooth.
Why GCP?
I'd been running on a simpler platform before. The app had outgrown it. I needed better security controls, more sophisticated WAF rules, and a proper CI/CD pipeline. GCP offered the right combination of features and flexibility.
The learning curve was steep. GCP's documentation is comprehensive but scattered. You'll find detailed guides for individual services, but not much on how they work together. That's what this post tries to address.
GCP Project Setup: More APIs Than You'd Think
First task: create a GCP project and enable the necessary APIs. The list was longer than I expected.
You'll need:
- Cloud SQL Admin API
- Cloud Run Admin API
- Cloud Build API
- Compute Engine API (even though you're not using VMs)
- VPC Access API
- Artifact Registry API
- Cloud Resource Manager API
- Service Networking API
- Secret Manager API
I kept hitting permission errors until I realized another API needed enabling. Eventually I just enabled them all upfront.
Service Accounts: Two, Not One
The architecture needs two service accounts with different roles.
Runtime service account (for Cloud Run):
- Cloud SQL Client
- Secret Manager Secret Accessor
- Storage Object Viewer (if using GCS)
Build service account (for Cloud Build):
- Cloud Run Admin
- Service Account User
- Cloud SQL Client
- Secret Manager Secret Accessor
- Artifact Registry Writer
- Compute Network User
The separation is important. Your build pipeline shouldn't have the same permissions as your runtime environment.
I created both accounts early and documented their roles. This paid off later when debugging permission issues.
Cloud SQL: IAM Auth and the PHP Problem
I went with Cloud SQL PostgreSQL 17, private IP only. No public IP at all. This means the database is only accessible from within the VPC.
I wanted to use IAM database authentication instead of password-based auth. It's more secure. The database user is tied to a GCP service account, and authentication happens via IAM roles.
Setting this up requires two steps:
- Create a database user tied to the service account:
CREATE ROLE "my-runtime-sa@my-project.iam" WITH LOGIN;
GRANT ALL PRIVILEGES ON DATABASE my_database TO "my-runtime-sa@my-project.iam";
- Configure Laravel to connect using IAM auth.
Here's where I hit the first major gotcha.
The Cloud SQL Auth Proxy Is Required for PHP
Cloud Run has built-in Cloud SQL support. You add --add-cloudsql-instances=my-project:us-central1:my-instance to your service config, and it handles the connection for you. Simple.
Except it doesn't support IAM authentication for PHP.
The --auto-iam-authn flag only works with specific language connectors: Java, Python, Go, Node.js. PHP isn't on the list. If you try to use the built-in connection with IAM auth, you get this error:
SQLSTATE[08006] [7] fe_sendauth: no password supplied
The solution: run the Cloud SQL Auth Proxy as a sidecar container.
This took me hours to figure out. The Cloud Run documentation mentions IAM auth, but doesn't clearly state which languages are supported. I tried multiple connection string variations before I found a GitHub issue explaining the limitation.
The Temporary Public IP Dance
There's another problem with private-only databases. How do you run the initial CREATE ROLE and GRANT statements when the database is only accessible from the VPC?
You have a few options:
- Temporarily enable a public IP, run the SQL, then remove it
- Use Cloud SQL Proxy from your local machine
- Deploy a bastion host
I went with option 1. I added a public IP to the database, ran the initial SQL commands, then removed the public IP. Not elegant, but functional.
The whole process:
- Add temporary public IP
- Add your IP to authorized networks
- Connect with
psqland run grants - Remove public IP
- Remove authorized networks
Total time: 5 minutes. Then the database was locked down.
There's a wrinkle if you have organization-level policies enforcing private IP only. I had to temporarily reset sql.restrictPublicIp and sql.restrictAuthorizedNetworks at the org level, add the public IP, run the grants, remove the IP, then re-enforce both policies. The policies exist for good reason, so this dance is worth it.
Cloud SQL Edition Gotcha
When creating a Cloud SQL instance with --tier=db-f1-micro, specify --edition=enterprise explicitly. Without the edition flag, Cloud SQL defaults to Enterprise Plus, which doesn't support f1-micro tiers. You'll get an error about tier incompatibility. The fix is simple, but not obvious from the error message.
Cloud Run with Sidecar Containers
Cloud Run supports multiple containers per service. This is perfect for the Cloud SQL Auth Proxy.
The main container runs your Laravel app. The sidecar container runs the proxy. They communicate over localhost.
A few critical details:
The --container flag must come before --image. I spent 30 minutes debugging a "container not found" error before I realized the flag ordering mattered. Global flags go before --container, container-specific flags go after.
Container dependencies are essential. You need to ensure the proxy starts before the app container. Without this, your app might try to connect to the database before the proxy is ready.
Use services update, not services replace. The replace command clobbers your entire configuration, including sidecar containers. The update command merges changes. I learned this after accidentally removing my proxy container with a deployment.
Image revision caching can bite you. When deploying with services replace using the :latest tag, Cloud Run may reuse a failed revision if the YAML spec hash hasn't changed. The service reports "Ready" but you're running old code. Fix: pin the image digest instead of using :latest, or switch to services update.
With the sidecar running, your Laravel .env becomes:
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=my_database
DB_USERNAME=my-runtime-sa@my-project.iam
DB_PASSWORD=
No password. The proxy handles IAM authentication.
PHP-FPM: TCP, Not Unix Sockets
Here's a small detail that caused a 502 error for hours.
Many PHP-FPM guides configure nginx to connect via unix socket (/var/run/php-fpm.sock). This works great on traditional servers where php-fpm and nginx run under the same process supervisor.
In Cloud Run, the socket never gets created. You'll see nginx errors about "No such file or directory" when trying to connect to the socket.
The fix: use TCP. Configure nginx to connect to 127.0.0.1:9000:
location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
PHP-FPM's default listen = 127.0.0.1:9000 is correct for containers. Don't override it.
Don't Run Migrations in Your Entrypoint
My initial Dockerfile ran migrations in the entrypoint script:
#!/bin/bash
php artisan migrate --force
exec apache2-foreground
This caused problems. Every time Cloud Run scaled up a new instance, it tried to run migrations. Multiple containers racing to migrate simultaneously. Not good.
The better approach: Cloud Run Jobs.
Cloud Run Jobs: The Swiss Army Knife
Cloud Run Jobs are perfect for one-off tasks. They use the same container image as your service, but they run once and exit.
I created a job called migrate with the exact same configuration as my service: same environment variables, same VPC connector, same sidecar proxy. The only difference is the command it runs.
Run migrations:
gcloud run jobs execute migrate --region=us-central1 --wait
The job connects to the database via the sidecar proxy, runs migrations, and exits. Perfect.
But here's where it gets really useful: you can override the --args to run any artisan command.
Clear cache:
gcloud beta run jobs execute migrate \
--region=us-central1 \
--container=migrate \
--args='artisan,cache:clear' \
--wait
Seed the database:
gcloud beta run jobs execute migrate \
--region=us-central1 \
--container=migrate \
--args='artisan,db:seed' \
--wait
Promote a user to admin:
gcloud beta run jobs execute migrate \
--region=us-central1 \
--container=migrate \
--args='artisan,user:promote,user@example.com,ADMIN' \
--wait
The job has all the secrets, all the VPC access, and the database connection. It's become my go-to tool for any command-line task. Much better than SSH-ing into a container.
One important detail: --args overrides args only, not the command. If your job's command is ["php"], your args should start with artisan, not php.
The Load Balancer: One Critical Gotcha
Cloud Run services get an auto-generated URL. You'll want a custom domain instead.
The setup:
- Create a serverless Network Endpoint Group (NEG) pointing to your Cloud Run service
- Create a backend service using the NEG
- Create a URL map for routing
- Create a target HTTPS proxy with a Google-managed SSL certificate
- Create a global forwarding rule
It's straightforward except for one detail that cost me an hour of debugging.
Don't use --protocol=HTTPS when creating the backend service with a serverless NEG.
The correct command:
gcloud compute backend-services create my-backend \
--global \
--load-balancing-scheme=EXTERNAL_MANAGED
I initially added --protocol=HTTPS because I thought the backend needed to match the HTTPS frontend. Wrong. The protocol should be HTTP (the default). The load balancer terminates TLS at the frontend. Traffic to the Cloud Run service goes over Google's internal network.
With --protocol=HTTPS set, I got 502 errors. Changing to HTTP fixed it immediately.
Cloud Run IAM: The allUsers Pattern
This is where I got confused for a while.
The Cloud Run service needs to accept traffic from the load balancer. My first thought: grant roles/run.invoker to the Cloud Run service agent or the load balancer's service account.
That doesn't work. The GXLB (EXTERNAL_MANAGED) does NOT present an IAM identity token when routing to serverless NEGs. There's no service account to grant permissions to.
The correct pattern for a public web app behind GXLB:
| Layer | Setting | Purpose |
|---|---|---|
| Cloud Run IAM | allUsers → roles/run.invoker |
Allow LB traffic without IAM check |
| Cloud Run ingress | internal-and-cloud-load-balancing |
Block direct access to Cloud Run URL |
| GXLB + Cloud Armor | WAF rules + rate limiting | Filter malicious traffic before it reaches the app |
| Application | Laravel auth | Handle user authentication at the app level |
This pattern is secure. Direct internet access to the Cloud Run URL is blocked by the ingress setting. All traffic must flow through the load balancer, where Cloud Armor filters it.
The alternative approach uses Identity-Aware Proxy (IAP). That's overkill for a public web app that has its own authentication system. IAP is designed for protecting internal tools, not public-facing applications.
Grant the invoker role to allUsers:
gcloud run services add-iam-policy-binding my-service \
--region=us-central1 \
--member="allUsers" \
--role="roles/run.invoker"
Verify the ingress setting is internal-and-cloud-load-balancing. This is what actually protects the service.
Cloud Armor: The Cookie Problem
Cloud Armor is GCP's Web Application Firewall. I wanted comprehensive protection, so I enabled the preconfigured rules for SQLi, XSS, LFI, RCE, and more.
The rules blocked legitimate traffic immediately.
Laravel Cookies Trigger SQLi Detection
Laravel encrypts session cookies using AES-256-CBC. The encrypted data is wrapped in a JSON envelope with an HMAC, then base64-encoded. The result is full of special characters — curly braces, quotes, colons, equal signs, plus signs. These characters trigger Cloud Armor's character anomaly detection rules.
Every request with a session cookie got blocked with a 403. Cloud Armor thought the cookies were SQLi attempts.
The debugging was brutal. I couldn't reproduce the issue with curl because curl doesn't send cookies by default. The main HTML page loaded fine (first request, no cookies). But every sub-resource request (CSS, JS, images) included the session cookie from the main page response and got blocked. Browsers showed a styled-less broken page while curl returned 200 for everything.
LB logging wasn't enabled by default. I had zero visibility into what was happening. Once I turned on logging:
gcloud compute backend-services update my-backend \
--global \
--enable-logging \
--logging-sample-rate=1.0
I could see the specific rules flagging requests. I put all OWASP rules in preview mode to confirm Cloud Armor was the cause, then identified the exact rules from previewSecurityPolicy.preconfiguredExprIds in the LB logs.
The Root Cause
Laravel's EncryptCookies middleware wraps every cookie in a {"iv":"...","value":"...","mac":"...","tag":""} JSON envelope. Each cookie contained 25-28 special characters, far exceeding the character anomaly threshold of 8. This is a structural incompatibility between Laravel's cookie encryption and Cloud Armor's SQLi detection — not a fluke.
The Date Parameter Problem
Even with cookie exclusions, I still saw blocks. Requests with date parameters in the query string were getting flagged:
?start_date=2025-01-01&end_date=2025-01-31
Cloud Armor's SQLi rules count dashes as SQL operator special characters. A YYYY-MM-DD date has 2 dashes, which exactly hits the threshold on one of the more aggressive rules. I tried --request-query-param-to-exclude for the date fields, but it didn't work — that rule inspects the raw request URI, not parsed parameter values.
POST Body False Positives
Form submissions got blocked too. The CSRF _token field and password fields contain enough special characters to trigger the anomaly rules. This one was harder to debug because the issue was in the POST body, not cookies or query parameters.
Rule 942431 ("Restricted SQL Character Anomaly Detection — args") fires when request parameters contain too many special characters. It's a statistical rule, not a pattern-matching rule. Normal Laravel forms hit this regularly.
The Comprehensive Fix
Two-pronged approach:
1. Cookie exclusion — Tell the SQLi rule set to skip cookie inspection entirely:
gcloud beta compute security-policies rules add-preconfig-waf-exclusion 1000 \
--security-policy=my-waf \
--target-rule-set=sqli-v33-stable \
--request-cookie-to-exclude=op=EQUALS_ANY
This is safe because Laravel encrypts and HMAC-verifies cookies. Tampering with cookie values won't produce valid data.
2. Sensitivity level 3 + opt-out of rule 942431 — Drop the most aggressive anomaly rules:
gcloud compute security-policies rules update 1000 \
--security-policy=my-waf \
--expression="evaluatePreconfiguredWaf('sqli-v33-stable', {'sensitivity': 3, 'opt_out_rule_ids': ['owasp-crs-v030301-id942431-sqli']})" \
--action=deny-403
Sensitivity level 3 drops 2 paranoia-level-4 rules out of 44 total. The explicit opt-out adds one more sensitivity-level-3 rule that was still producing false positives.
The three opted-out rules are all character-counting anomaly rules:
942421— Cookie character anomaly (threshold 3)942432— Args character anomaly (threshold 2, fires onYYYY-MM-DDdates)942431— Args character anomaly (fires on CSRF tokens and passwords)
All 41 other SQLi rules remain active — libinjection, SQL operators, auth bypass, tautology detection, HAVING injection, classic SQLi probing. The dropped rules are the ones with the lowest thresholds and highest false positive rates.
Important: Field-level exclusions require evaluatePreconfiguredWaf, not the older evaluatePreconfiguredExpr. The older syntax only supports per-rule ID exclusions.
Verification
After the fix:
# Legitimate traffic passes
curl -sf https://test.my-domain.com/ # 200
curl -sf "https://test.my-domain.com/api?start_date=2025-01-01" # 200
# Real SQLi is still blocked
curl -sf "https://test.my-domain.com/api?x=1'+OR+'1'='1" # 403
CI/CD with Cloud Build
Cloud Build runs your pipeline on every push. My pipeline has 6 stages:
- Build the Docker image with commit SHA and
latesttags - Push both tags to Artifact Registry
- Deploy to Cloud Run with
services update - Update the migration job image
- Execute migrations via
gcloud run jobs execute migrate --wait - Smoke test with curl
A few things I learned:
bootstrap/cache must exist in the container. The directory is gitignored, so it doesn't exist when Cloud Build clones the repo. Builds triggered by GitHub pushes failed while local builds worked because the directory existed locally. Fix it in the Dockerfile:
RUN mkdir -p bootstrap/cache && chmod -R 775 bootstrap/cache
Set a generous timeout. The default Cloud Build timeout is 10 minutes. Docker builds with large dependencies can take longer. I set mine to 30 minutes:
timeout: 1800s
Use CLOUD_LOGGING_ONLY for logging if you don't need build logs in GCS:
options:
logging: CLOUD_LOGGING_ONLY
Dockerfile location matters. gcloud builds submit --tag requires a Dockerfile at the repo root. If your Dockerfile is in infra/, use --config with a cloudbuild.yaml that specifies -f infra/Dockerfile.
.gcloudignore should mirror .dockerignore. Cloud Build uploads your source to a staging bucket before building. Local artifacts can bloat the upload. Keep both ignore files in sync.
Background Workers on Cloud Run
Laravel queue workers run in the background processing jobs. Cloud Run's default behavior kills them.
Cloud Run throttles CPU when there's no incoming HTTP request. Your queue worker is a background process with no HTTP traffic, so Cloud Run treats the container as idle and eventually scales it down. Mid-job.
I watched a 10-minute job get killed at the 5-minute mark. The container received SIGTERM, the worker died, and when a new container started, the old job was picked up again with attempts >= tries, throwing MaxAttemptsExceededException.
The fix requires two settings:
gcloud run services update my-app --region=us-central1 \
--no-cpu-throttling \
--min-instances=1
--no-cpu-throttling keeps the CPU active even between requests. --min-instances=1 ensures at least one container is always running.
Cost impact: from ~$5/month to ~$93/month. Still cheaper than a VM, but not free anymore.
Queue Retry Configuration
Laravel's database queue driver defaults retry_after to 90 seconds. If your job runs longer than that, a second worker picks it up — thinks it's abandoned — and the first worker's attempt gets killed.
I had jobs that took 15+ minutes. They kept getting duplicate-processed at the 90 second mark every time.
Set retry_after to match your longest expected job:
// config/queue.php
'database' => [
'driver' => 'database',
'retry_after' => env('DB_QUEUE_RETRY_AFTER', 90),
],
gcloud run services update my-app --region=us-central1 \
--update-env-vars="DB_QUEUE_RETRY_AFTER=7200"
Logging from Artisan Commands
Laravel's $this->info() and $this->error() write to artisan's output buffer, not Cloud Logging. Your pipeline commands might be running fine but you'd never know because the output goes nowhere.
Add explicit Log::info() and Log::error() calls to any command that runs in Cloud Run:
public function handle()
{
$this->info('Starting job...'); // Only visible in artisan output
Log::info('Starting job...'); // Visible in Cloud Logging
// ... do work ...
Log::info('Job completed', ['processed' => $count]);
}
Email with Resend
If you're switching from a local mail driver (like log) to a real email provider, Resend integrates cleanly with Laravel.
The resend/resend-laravel package works with Laravel's standard Mailables. No code changes needed. Just install the package, configure the driver, and you're done.
DNS records you'll need:
- DKIM TXT record — Domain authentication
- SPF TXT record —
v=spf1 include:amazonses.com ~all(Resend uses AWS SES) - MX record — For bounce handling
If you have VPC Service Controls protecting Secret Manager, you'll need to temporarily add yourself to the access level to create the API key secret. Remove yourself after. The temporary access is a minor inconvenience for proper perimeter security.
VPC Service Controls: Lockdown Mode
VPC Service Controls create a security perimeter around your GCP resources. I used it to protect Secret Manager and Cloud SQL.
The setup involves creating an access level (who can access resources inside the perimeter) and a perimeter (which resources to protect).
The tricky part: the access level needs more service accounts than you'd expect.
| Service Account | Purpose |
|---|---|
| Runtime SA | Cloud Run reads secrets, connects to DB |
| Build SA | Deploys images, runs migration jobs |
| Cloud Build service agent | Build orchestration |
| Cloud Run service agent | Resolves secret references |
| Compute Engine default SA | Cloud Build operations |
I missed the service agents initially. Deployments failed with "VPC Service Controls: Request is prohibited by organization's policy" errors. The error message doesn't tell you which service account is missing.
Testing is also tricky. After creating the perimeter, you can't access secrets from your own machine:
PERMISSION_DENIED: Request is prohibited by organization's policy.
vpcServiceControlsUniqueIdentifier: ...
You have to temporarily add yourself to the access level to create secrets, then remove yourself. It's a bit of a dance, but it ensures the perimeter actually works.
PostgreSQL Migration Gotchas
Laravel 11 changed how it handles enum columns with PostgreSQL. Instead of native enum types, it creates varchar(255) columns with CHECK constraints.
If you have a migration that tries to add a new value:
// This FAILS on Laravel 11 + PostgreSQL
DB::statement("ALTER TYPE my_status ADD VALUE 'archived'");
The type doesn't exist. Laravel created a CHECK constraint instead.
The fix: drop and recreate the CHECK constraint:
public function up()
{
DB::statement("ALTER TABLE my_table DROP CONSTRAINT my_table_status_check");
DB::statement("ALTER TABLE my_table ADD CONSTRAINT my_table_status_check CHECK (status::text = ANY (ARRAY['pending', 'active', 'completed', 'archived']::text[]))");
}
More verbose, but it works.
Lessons Learned
Read the fine print on language support. The Cloud SQL Auth Proxy requirement for PHP isn't obvious from the Cloud Run documentation. I wasted hours trying to make the built-in connection work.
Enable load balancer logging immediately. You'll need it. Cloud Armor issues are invisible without it.
Cloud Armor's preconfigured rules need tuning for Laravel. Cookie exclusions and sensitivity adjustments are not optional — they're expected. Laravel's encrypted cookies will trigger SQLi rules out of the box.
Cloud Run Jobs are incredibly versatile. They're not just for migrations. Any artisan command can run in a job with full infrastructure access.
Cost scales with your choices. Default Cloud Run pricing is cheap. Disable CPU throttling or set minimum instances and you're paying for continuous compute.
Flag ordering matters in gcloud commands. Global flags before --container. Container-specific flags after. This will bite you more than once.
Test with real browsers, not just curl. Curl doesn't send cookies. Browsers do. Cloud Armor treats them very differently.
Service accounts need more permissions than you think. Especially with VPC Service Controls. Don't forget the service agents.
PHP-FPM: Use TCP, not unix sockets in containers. The socket file won't exist in Cloud Run. Connect via 127.0.0.1:9000.
allUsers + ingress restriction is the correct pattern for public apps behind GXLB. You don't need IAP or complex service account grants. The ingress setting protects you.
Pin image digests when using services replace to avoid revision caching. Cloud Run may reuse a failed revision if the YAML spec hash hasn't changed.
Would I Use GCP Again?
Yes. The platform is powerful and the services integrate well. The security controls are excellent.
The documentation could be better. I spent too much time piecing together information from multiple sources. The PHP-specific gaps were frustrating.
But the final architecture is solid. The deployment is automated. The security is tight. If you're considering GCP for a Laravel app, go for it. Just budget extra time for the gotchas. They're all solvable. They're just not obvious.