Deploying a mini monolith on Cloud Run with Terraform
A frontend, a backend, and a reverse proxy can have separate builds without needing separate deployments. For a small application, I deploy them together as one Cloud Run service.
I think of this as a mini monolith: three containers, but one deployment and scaling unit. It keeps the components distinct without introducing independent services to operate.
Three containers, one service
Each Cloud Run instance runs:
- Proxy: a reverse proxy receives and routes incoming requests.
- API: an HTTP server connects to an external database.
- Web UI: a static server serves the production build.
Browser
│
│ HTTPS
│
v
Cloud Run managed endpoint
│
│ HTTP
│
v
┌───────────── Cloud Run service ─────────────┐
│ │
│ Proxy :8080 │
│ │ │
│ │──── /api/* ────> API :6000 │
│ │ │ │
│ │ └────> External DB │
│ │ │
│ └──── /* ────────> Web UI :3000 │
│ │
└─────────────────────────────────────────────┘
Cloud Run terminates HTTPS and forwards requests to the proxy, the only ingress container. The proxy reaches the other containers over localhost; they aren’t separate public endpoints.
The browser sees one origin for the frontend and API, keeping routing and cookie-based authentication straightforward. The database stays outside the service because container instances are disposable.
The Terraform configuration
One google_cloud_run_v2_service holds all three containers. This simplified example assumes a configured Google provider, enabled APIs, an existing runtime service account, and image references supplied as variables. Application environment variables and secrets are omitted.
resource "google_cloud_run_v2_service" "app" {
name = "web-app"
location = var.region
ingress = "INGRESS_TRAFFIC_ALL"
deletion_protection = true
template {
service_account = var.runtime_service_account
scaling {
min_instance_count = 0
max_instance_count = 3
}
containers {
name = "proxy"
image = var.proxy_image
ports {
container_port = 8080
}
resources {
limits = {
cpu = "1"
memory = "512Mi"
}
cpu_idle = true
}
depends_on = ["api", "web-ui"]
}
containers {
name = "api"
image = var.api_image
resources {
limits = {
cpu = "1"
memory = "512Mi"
}
cpu_idle = true
}
startup_probe {
tcp_socket {
port = 6001
}
}
}
containers {
name = "web-ui"
image = var.web_ui_image
resources {
limits = {
cpu = "1"
memory = "512Mi"
}
cpu_idle = true
}
startup_probe {
tcp_socket {
port = 3000
}
}
}
}
}
resource "google_cloud_run_service_iam_member" "public" {
service = google_cloud_run_v2_service.app.name
location = google_cloud_run_v2_service.app.location
role = "roles/run.invoker"
member = "allUsers"
}
A few details matter:
- The proxy must listen on
0.0.0.0:8080. Cloud Run supplies a default startup probe for the ingress container. - The nested
depends_onlists container names. Cloud Run waits for the upstream startup probes to pass before starting the proxy. Here, the API has a separate operational listener on port 6001; a TCP probe checks that it’s listening, not that the database works. - Resource limits apply per container: this example totals 3 vCPUs and 1.5 GiB per instance. Size them for the workload rather than assuming equal allocations are ideal.
cpu_idle = trueuses request-based CPU allocation; don’t rely on continuous background work while idle.allUsersmakes the website publicly reachable. The API still needs application-level authentication and authorization.
Routing with Caddy
The proxy’s basic routing configuration is small:
{
auto_https off
}
:8080 {
handle /api/* {
reverse_proxy localhost:6000
}
handle {
reverse_proxy localhost:3000
}
}
handle preserves the /api prefix. The frontend server handles static files and client-side navigation fallback. Headers, request limits, and logging can be configured separately.
Separate builds, shared scaling
A frontend change only needs a new frontend image. The deployment supplies that image alongside the existing proxy and API images, creating a revision containing all three. Separate builds save work, but the rollout remains shared.
Scaling works the same way: each additional instance starts the entire group. Static-file traffic can therefore scale the API too, and a cold start includes starting the upstream containers before the proxy. Minimum instances can reduce cold starts at an idle cost.
That’s the trade-off I accept for a small application: fewer independently operated services, but no independent scaling or release boundary. I’d split components when their traffic, resource needs, or release schedules diverge enough to justify it.