Home

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_on lists 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 = true uses request-based CPU allocation; don’t rely on continuous background work while idle.
  • allUsers makes 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.