— 9 reading minutes
Migrating a production application is rarely just a matter of changing where a container runs. Our starting point was a Symfony application running on Google App Engine, backed by Cloud SQL for its relational database, Memorystore (Redis) for sessions and caching, scheduled jobs, static assets, maintenance workflows, third-party integrations, and multiple long-lived environments.
The goal was to move the application's runtime to Cloud Run without turning the migration into a leap of faith. We wanted to preserve day-to-day operations, support environment-based deployments, run database migrations in a controlled way, protect non-public environments with Identity-Aware Proxy (IAP), and give developers access to realistic, but fully anonymized, production data for testing.
Starting Point
We made the move for two reasons that reinforced each other: we needed more control over deployments, and Google had been moving in that direction for some time.
Today, Google recommends Cloud Run over App Engine as the preferred option for new projects and presents it as the evolution of its serverless platform, built on more than a decade of operating App Engine and on much of the same underlying infrastructure. App Engine remains fully supported, and no one has announced its retirement. But the signal is hard to ignore: when the platform owner tells you to start with Cloud Run by default, keeping a long-term project on App Engine means betting against its roadmap. We did not migrate to follow a trend; we migrated because Cloud Run is where Google is investing and where it is directing new teams.
App Engine had given us a simple platform for serving the application, but the project had reached a point where we needed more control over deployments:
- clearly separate the main web services
- use a global HTTPS load balancer with explicit domain-based routing
- enable or disable a maintenance page from the infrastructure layer
- run Symfony commands as ephemeral jobs
- manage secrets through Secret Manager
- connect to Cloud SQL and Redis in a controlled way
- maintain a release workflow based on versioned images
Cloud Run was a better fit for that model. It allows us to deploy immutable containers, configure concurrency and resources per service, connect to Cloud SQL, use Serverless VPC Access for private resources, and rely on Google Cloud’s global load balancer for certificates, CDN, Cloud Armor, and IAP.
Target Architecture
The application was split into three Cloud Run services: the public storefront, the administration panel, and the API.
All three services use the same application image but receive different environment variables, including the canonical domain, cache prefixes, runtime configuration, and service-specific secrets where needed. This separation prevents different traffic profiles from being mixed together and allows resources to be tailored to each service's workload. The public storefront may require higher concurrency and a minimum number of instances, while the administration panel can scale up on demand.
We added a fourth Cloud Run service dedicated to maintenance mode. The load balancer can route traffic for one, several, or all services to this maintenance page without modifying the application or deploying a new image.
In front of Cloud Run, we deployed a global HTTPS load balancer responsible for:
- redirecting HTTP traffic to HTTPS
- serving managed TLS certificates
- routing each domain to the appropriate backend
- applying Cloud Armor policies
- enabling Cloud CDN where appropriate
- protecting internal environments with IAP
The database remained on Cloud SQL, and Redis remained on Memorystore. Cloud Run connects to Cloud SQL through its native integration and to Redis through a Serverless VPC Access connector with egress restricted to private IP ranges.
Layered Terraform
One of the most significant changes was breaking the infrastructure into smaller Terraform modules. Instead of a single, monolithic configuration that managed everything, we organized Terraform by responsibility:
- global configuration for builds, buckets, Artifact Registry, and triggers
- shared project infrastructure: Cloud SQL, Redis, and the VPC connector
- environment-specific resources: global IP addresses and DNS outputs
- data pipelines
- Cloud Run application deployment
- maintenance deployment
- HTTPS load balancer
- monitoring
- development data import
- production dump generation
Each module has its own Terraform Cloud workspace. Global modules use fixed workspaces, while environment-specific ones are selected using TF_WORKSPACE. This makes the deployment order explicit:
- shared infrastructure
- environment infrastructure
- data pipelines
- application
- maintenance
- load balancer
- monitoring
This separation has a practical advantage: if we only need to update a load balancer rule or a dashboard, we don't have to touch the application deployment. And if an environment reuses an existing Cloud SQL instance, Redis instance, or VPC connector, the module can reference those resources instead of trying to recreate them.
Immutable Images and Tag-Based Releases
The application build was moved to Cloud Build. The release process starts from a Git tag:
- GitHub Actions triggers the Cloud Build pipelines.
- Cloud Build builds the base PHP image.
composer installis run for production.- The frontend assets are compiled.
- The assets are validated to prevent bundling errors.
- The versioned assets are uploaded to Cloud Storage.
- The final image is built with PHP-FPM and Nginx.
- The image is published to Artifact Registry using the release tag.
A separate image is also built for the maintenance service. Keeping it independent from the application image reduces risk: the maintenance page must be deployable even if there is an issue with the main application.
During deployment, Terraform receives deploy_version and updates the Cloud Run services with the exact image for that version. We never deploy latest; we always deploy specific tags.
Database Migrations as Ephemeral Jobs
In App Engine, deployments and operational commands were often mixed together in ways that weren't always explicit. With Cloud Run, we chose to turn Symfony commands into ephemeral jobs created by Terraform.
Before updating the services, the deployment synchronizes the Doctrine Migrations metadata and runs doctrine:migrations:migrate -n.
The jobs use the same image, environment variables, and secrets as the main service, as well as the same Cloud SQL connection and VPC connector. Once they complete, they are deleted. This makes the deployment fully traceable: if a migration fails, the Terraform apply fails before any traffic is routed to a version that expects a different database schema.
Cron Jobs Outside the Runtime
The legacy cron jobs were moved to Cloud Scheduler. Each scheduled task calls an internal administration endpoint with a specific header and is defined in Terraform with its name, description, route, schedule, and time zone.
This has two advantages: schedules are reviewed as code, and the jobs no longer depend on a feature of the previous runtime.
Secrets and Configuration
Configuration was split into two groups: non-sensitive variables, managed through Terraform Cloud, and secrets, stored in Secret Manager.
Terraform does not store sensitive values in the repository. Instead, it receives mappings between environment variable names and secret identifiers, then grants the runtime service account the secretAccessor role for all the required secrets.
One important detail was granting access not only to global secrets, but also to service-specific ones. If the administration panel or the API introduces its own secret, the runtime must be able to read it without requiring any additional manual permissions.
We also defined explicit default values for external integrations. During a migration, missing environment variables can lead to failures that are difficult to diagnose. We preferred the application to start with empty or safe defaults, allowing each environment to override only the values it needs.
IAP, Cloud Armor, and Cloud CDN
Internal environments were protected with IAP. In development and staging, all services can be placed behind IAP. In production, the pattern changes: the storefront and API remain public, while the administration panel continues to be protected by IAP.
The Cloud Run module grants the IAP service agent the run.invoker role on the protected services. Without this step, the load balancer may be configured with IAP, but Cloud Run will still reject incoming requests.
Cloud Armor and Cloud CDN are configured from the load balancer backend, allowing security and caching policies to be managed independently of the Symfony application.
Maintenance Through Load Balancer Routing
The maintenance page is deployed as a separate Cloud Run service. The load balancer decides whether each route points to the production backend or the maintenance one, making the switch a purely infrastructure-level change:
- maintenance mode for the storefront only
- maintenance mode for the administration panel only
- maintenance mode for the API only
- global maintenance mode
This allows us to avoid deploying a special version of the application and gives us a clear operational switch for enabling or disabling maintenance.
Realistic Development Data
A critical part of the migration was being able to test the development environment with production-like data. To achieve this, we built a dump pipeline:
- Cloud Build temporarily clones the production instance.
- It exports a full dump.
- It applies SQL anonymization scripts.
- It exports an anonymized dump.
- It reduces the dataset for development environments.
- It exports a reduced dump.
- It deletes the temporary instance.
The dumps are cleaned to remove the CREATE DATABASE and USE statements, allowing them to be imported into databases with different names.
The anonymization process replaces personal data from users, orders, contacts, campaigns, notes, audit histories, and external integrations. We also made one practical exception: preserving the team's internal user accounts so they can log in to the development environment after importing a dump. It's a small detail, but it avoids having to recreate access every time the database is refreshed.
The development import process is also defined as infrastructure. A dedicated module, limited to the dev environment, copies the selected dump to a temporary bucket, grants the Cloud SQL service account access to it, drops and recreates the development database, imports the dump, and finally deletes the temporary bucket.
Monitoring
The migration wasn't complete once the services started returning HTTP 200 responses. We also added a monitoring module for each environment, including:
- HTTPS uptime checks
- 5xx alerts on the load balancer
- p95 latency alerts
- Cloud Run instance pressure
- Cloud SQL CPU utilization
- Redis memory usage
- an environment dashboard
The primary monitoring signal comes from the load balancer, not just Cloud Run. That matters because real user traffic passes through DNS, TLS certificates, IAP, Cloud Armor, the backend service, and finally Cloud Run.
Challenges We Encountered
There were several implementation details worth noting.
The Cloud Run timeout format had to match what the API expects. Passing a plain number where the API expects a duration with a suffix causes the deployment to fail with little indication of what went wrong. We fixed this by generating values such as 60s.
The data import jobs should not rely on fragile waits between intermediate operations. We simplified the workflow by letting the gcloud commands block until completion, reducing the amount of custom orchestration logic.
Service-specific secrets were not always granted to the runtime. This doesn't show up as an obvious error in a Terraform plan, but it fails at runtime when a service cannot read a required secret. The solution was to build a single map containing every secret used by every service and grant permissions from that consolidated list.
We also had to pay close attention to Cloud Build permissions for the dump pipeline. Cloning a Cloud SQL instance, exporting it, running SQL scripts, and deleting the temporary replica requires broader permissions than a standard container build.
Validation
Validation was performed in layers:
terraform fmt,terraform init, andterraform validatefor the modified Terraform modules- Container image builds with Cloud Build
- Deployment of the development environment
- Verification that the Cloud Run services were in the
Readystate and serving 100% of traffic - Verification that the managed SSL certificates were active
- HTTPS response testing, including IAP redirection for internal domains
- Generation of a fresh dump
- Import of the dump into the development environment
- Direct verification of the users table to confirm that internal user accounts were preserved
This validation covered the entire deployment lifecycle: container images, infrastructure, load balancing, security, the database, and test data.
What We Gained
The outcome was more than simply running the application on Cloud Run. We gained a more explicit and reliable way of operating it:
- reproducible releases based on tags
- services separated by responsibility
- traceable database migrations before service updates
- maintenance mode controlled from the load balancer
- secrets kept out of the repository
- internal environments protected with IAP
- refreshable, anonymized development data
- monitoring aligned with real user traffic
The migration succeeded because we didn't treat it as a simple runtime change. Instead, we rebuilt the entire release lifecycle in a structured way: build, container image, Terraform, database migrations, services, load balancer, data, and observability.
If you're considering making the same move, keep one thing in mind: Cloud Run simplifies running containers, but it doesn't provide an operational architecture out of the box. Building that architecture is still your responsibility.
