Skip to content
ShipFastDev
All posts

Article

Deploying a Laravel app on Google Cloud Run: the pitfalls and the fixes

, 8 min read

To deploy a Laravel app on Google Cloud Run, you put PHP-FPM and a web server in one container that listens on the port Cloud Run gives it, build your frontend assets before the image, keep configuration on the service rather than in your CI file, and tell Laravel it runs behind an HTTPS proxy. Most of the work is in a handful of Laravel-specific traps; here are the ones we hit putting Driveway Morocco into production, and the fixes.

Driveway Morocco is ShipFastDev's car-rental platform (case study): Laravel 13 with an Inertia/React storefront, a Filament admin panel, Stripe deposit holds, and plugins (fleet, booking, payments) installed as local Composer packages. It went live on Cloud Run in August 2026, with a managed Postgres database on Supabase and GitHub Actions deploying on every push to the main branch. Every pitfall below comes from that deployment's commit history.

What Cloud Run expects from your container

Read the container runtime contract before you write the Dockerfile. Four rules matter most for a Laravel app:

  • Port. The container must listen on 0.0.0.0 on the port in the PORT environment variable. Requests are sent to 8080 by default.
  • No TLS inside the container. Cloud Run terminates HTTPS and proxies requests to your container as plain HTTP. Laravel has to be told about it, or it builds http:// links.
  • The filesystem is memory. Writing to disk uses the instance's RAM and disappears when the instance stops. Sessions, uploads and logs belong in the database, object storage or stdout.
  • Startup and shutdown limits. An instance must listen for requests within 4 minutes of starting. On shutdown it receives SIGTERM, then SIGKILL 10 seconds later.

Driveway runs a single image with PHP 8.4 FPM, Nginx and Supervisor. Nginx listens on the port, serves the built assets and hands PHP requests to FPM.

Pitfall 1: frontend assets missing from the build

With gcloud run deploy --source ., your directory is uploaded and built by Cloud Build using your Dockerfile if one is present. What gets uploaded is decided by .gcloudignore, and the default file pulls in your .gitignore. Vite writes its output to public/build, which is gitignored, so the first CI deploys shipped an image with no compiled CSS or JavaScript.

Running npm ci && npm run build inside the image did not fix it cleanly: the build regenerated manifest.json with new hashes but no matching JS files. The working setup builds the assets in the CI job before the deploy step, and the Dockerfile only copies public/build. Removing Node and npm from the image also made it about 200 MB smaller.

Pitfall 2: your local vendor/ overwriting the real one

The Dockerfile runs composer install and then copies the source. Because vendor/ was not listed in .gcloudignore, COPY . replaced the freshly installed dependencies with the developer's local vendor/, and the Filament admin routes broke. Exclude vendor/ and node_modules/ from the upload; the image should install its own dependencies from the lockfile.

Pitfall 3: Composer path repositories and symlinks

Driveway's plugins live in a plugins/ folder and are installed as Composer path repositories. Composer symlinks path packages by default. Inside the Docker build, the copy turned those symlinks into real directories, and the autoloader stopped finding plugin classes. Vehicle detail and checkout pages, which depended on plugin routes, rendered blank.

The fixes in the history were pragmatic: a build step that adds the plugins' PSR-4 namespaces to Composer's autoload map, a core-owned fleet controller, and core fallback routes for the booking flow. The image also needed the intl, zip and gd extensions for composer install to succeed. If you are starting fresh, try Composer's "symlink": false option first: the Composer documentation notes that forcing mirroring "can be useful when deploying".

Pitfall 4: layer caching that breaks the build

Docker's advice is to copy the package files, install dependencies, then copy the source. A commit applied that order (copy composer.json and plugins/, install, then copy the rest) and estimated typical deploys would drop from about 8 minutes to about 2. It was reverted the same day because the new order broke CI builds: with path repositories, the dependency layer depends on your own plugin code too. Get a working image first, then optimise, and prove the change with a real CI build.

Pitfall 5: http:// links behind the HTTPS proxy

This one took the whole storefront down. PHP-FPM saw plain HTTP, so Laravel's url() helper produced http:// URLs for assets and API calls. The page was served over HTTPS, so the Content Security Policy blocked those requests: navigation, login, search, filters and booking all stopped working, with no server error to point at.

The fix was to make Laravel generate HTTPS URLs: set APP_URL and ASSET_URL to the public domain, force HTTPS in the startup script, and call URL::forceScheme('https') in production. The Laravel documentation describes the standard route: enable the TrustProxies middleware so Laravel reads the forwarded protocol, and use * when you don't know your load balancer's addresses.

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware): void {
    $middleware->trustProxies(at: '*');
})

Either way, check it in a browser with the console open.

Pitfall 6: secrets in the CI file

The first workflow passed environment variables in the deploy step, including the database password and the app key. They were removed within the day. Cloud Run carries a revision's configuration into the next revisions unless you change it explicitly, so the pipeline only needs to ship a new image. Two details from the same page: --set-env-vars deletes every variable you don't list, while --update-env-vars only adds or changes; and Google does not recommend environment variables for secrets such as database credentials or API keys, pointing to Secret Manager instead.

For authentication, the pipeline first used a service account key stored as a GitHub secret. In September 2026 it moved to Workload Identity Federation with Google's deploy action. The google-github-actions/auth documentation recommends federation because it "obviates the need to export a long-lived credential"; a key file never expires and must be treated like a password. The workflow needs the id-token: write permission.

Pitfall 7: cold starts and memory

A new instance starts from the image, so anything cached at build time can be stale. Driveway's startup script clears the config and route caches so the plugin manager reads which plugins are enabled from the database, and runs php artisan package:discover so Laravel registers installed packages. Keep this script short: it runs on every cold start, and the instance has 4 minutes to start listening.

Memory is the other limit. The default is 512 MiB per instance, instances that exceed their limit are terminated, and files written to disk count against it. Driveway's service was raised to 1 GiB for stability.

Pitfall 8: one admin feature taking down every page

In September 2026, a plugin registered an admin-only Filament/Livewire component in its boot() method. In the Cloud Run boot context that call threw, and because the exception happened during application boot, every page failed, storefront included. The error pages then called route('home'), which wasn't in the route list after the failed boot, so React unmounted and visitors got a white screen.

Two fixes: admin-only registration is wrapped in a try/catch so it is best-effort, and the error pages fall back to plain paths when a named route is missing. Boot code runs on every request; an error page must not depend on anything that can fail.

Queues and scheduled tasks

The project's production checklist lists a queue worker and the Laravel scheduler. A backend audit had already found that, without a worker, queued emails, push notifications and the deposit-hold expiry command silently never ran in the local Docker setup. On Cloud Run, plan this explicitly: under the default request-based billing, the container contract says CPU is allocated while instances process requests, so a queue:work process in the web container is not a reliable worker. The documented options are instance-based billing, which keeps CPU allocated for the instance's lifetime, or a Cloud Run job triggered by Cloud Scheduler for periodic commands.

The checklist

  • Nginx and PHP-FPM in one image, listening on $PORT.
  • Assets built in CI; no Node in the runtime image.
  • vendor/ and node_modules/ excluded from the upload.
  • Autoloading tested inside the built image.
  • HTTPS URLs verified in a real browser.
  • Secrets in Secret Manager, CI on Workload Identity Federation.
  • Short startup script, memory sized for the app.
  • Guarded boot code, error pages that always render.
  • A plan for queues and cron.
  • Browser tests before deploy: see end-to-end tests with Playwright in CI.

Most of these problems appeared only in the deployed container. Small, frequent deploys made each one easy to trace, as argued in weekly releases with a remote developer. The same project's search setup, with a fallback when the search engine is down, is covered in Laravel search with Meilisearch and a database fallback.

Frequently asked questions

Can Laravel run on Cloud Run without code changes?

Mostly. You need a container that listens on $PORT, Laravel configured to generate HTTPS URLs behind the proxy, configuration from environment variables or Secret Manager, and no reliance on the local disk for anything that must persist.

Do I need Nginx inside the container?

Cloud Run forwards HTTP to your container, and PHP-FPM speaks FastCGI, not HTTP, so a web server has to sit in front of it. Driveway Morocco uses Nginx and PHP-FPM under Supervisor.

How do I run Laravel queues and cron jobs on Cloud Run?

Not as a background process in the web container under request-based billing, because CPU is only allocated while requests are processed. Use instance-based billing for a worker, or Cloud Run jobs triggered by Cloud Scheduler for scheduled commands.

Why does my Laravel app on Cloud Run load assets over http://?

Cloud Run terminates TLS and sends plain HTTP to the container, so Laravel thinks the request is insecure. Trust the proxy with the TrustProxies middleware or force the HTTPS scheme in production, and set APP_URL to your https:// domain.

Sources

  1. Container runtime contract, Google Cloud, 2026-09
  2. Deploy services from source code, Google Cloud, 2026-09
  3. gcloud topic gcloudignore, Google Cloud, 2026-10
  4. Configure environment variables for services, Google Cloud, 2026-09
  5. Configure memory limits for services, Google Cloud, 2026-09
  6. Execute jobs on a schedule, Google Cloud, 2026-09
  7. google-github-actions/auth, Google (GitHub), 2026-10
  8. HTTP Requests: Configuring Trusted Proxies, Laravel 13.x documentation, 2026-10
  9. Repositories: Path, Composer documentation, 2026-10
  10. Optimize cache usage in builds, Docker Docs, 2026-10

Want this kind of work on your product?

Tell Adil what you’re building. You get a reply within 1 business day with times for a 15-minute video call.