← Back to Patterns

Safe scaling defaults for Cloud Run + Postgres

Cloud Run autoscaling is not a database strategy. Safe defaults keep the application from scaling itself into a Postgres incident before the team understands the workload.

By Ivan Richter LinkedIn

Last updated: Sep 1, 2026

4 min read

On this page

Cloud Run can add application instances faster than Postgres can become a different database.

Safe defaults make the application reach an intentional limit before the database reaches an accidental one. They’re conservative until production evidence earns more concurrency, more instances, or larger pools.

Start from the connection budget

Reserve database sessions for administration, migrations, monitoring, replicas or poolers, and every other application. Give the Cloud Run service only its share.

Then satisfy the basic upper bound:

max service instances × maximum DB sessions per instance
  ≤ service connection budget

Include overlapping revisions during deployment and any worker processes that create separate pools. A pool max of four inside three processes is a per-instance claim of twelve.

This article applies the budget. Why Cloud Run + Postgres needs a connection budget defines it.

Tune the knobs as one contract

The important settings interact:

  • Container concurrency controls how much request work one instance accepts
  • Maximum instances bounds fleet width
  • Pool maximum bounds database sessions per process or instance
  • Acquisition timeout bounds how long work waits for a session
  • Statement and transaction timeouts bound database occupation
  • Request timeout bounds the synchronous response path

Higher concurrency can reduce instance count for light requests, but it can overload one pool when most requests are database-heavy. Lower concurrency can cause more instances to start, multiplying pools. Neither direction is safe without the fleet-wide math.

Begin with measured request behavior, not Cloud Run’s maximum or a framework default.

A conservative starting posture

For a small internal API, a reasonable first profile might look like this:

cloud_run:
  concurrency: 10
  max_instances: 5
  request_timeout_seconds: 30

postgres:
  pool_max_per_instance: 4
  pool_min_per_instance: 0
  acquire_timeout_ms: 1000
  statement_timeout_ms: 5000

Those numbers aren’t a recommendation detached from database size. They express a posture: no unbounded fleet, no eager minimum pool on every instance, no long invisible wait for a scarce session, and no assumption that request work may hold the database indefinitely.

Calculate the actual budget before copying any value. A five-instance service with a four-session pool can claim twenty sessions, plus rollout overlap if both revisions are live.

Prefer small pools

Most web services don’t need a session for every concurrent request. Many requests perform no database work. Others borrow a connection for a short query and return it.

A small pool creates local backpressure and keeps the database from carrying hundreds of mostly idle backends. Increase it only when pool wait time is material while Postgres still has CPU, I/O, locks, and connection headroom.

Set pool minimum to zero or a very small number for elastic services unless warm sessions solve a measured latency issue. Multiplying idle minimums across scaled instances is an efficient way to reserve capacity for work that doesn’t exist.

Bound waiting before Postgres becomes the queue

When every pool connection is busy, a request should wait briefly or fail with a controlled response. It shouldn’t occupy an application worker for most of the Cloud Run timeout while additional instances repeat the same demand.

Short acquisition deadlines expose saturation near the caller. Pair them with bounded retries and load shedding. For durable work, enqueue rather than retrying synchronously until one request becomes several.

A visible 503 or 429 at the intended boundary is healthier than a fleet of requests waiting on a database that can no longer complete work.

Keep transactions narrower than requests

Acquire a session after validation and release it before external HTTP calls, file processing, rendering, or other non-database work. Transactions should cover the consistency boundary, not the handler’s entire narrative arc.

Cloud Run may keep processing after a request timeout, so database work must use its own deadlines and cancellation. The transport timeout isn’t a transaction manager.

Separate service shapes

APIs, queue consumers, jobs, migrations, and admin tools shouldn’t inherit one pool profile.

A worker processing database-heavy messages may need concurrency one or two and a tiny pool. A read-heavy API can tolerate higher HTTP concurrency with the same small pool. A migration needs a dedicated execution window and reserved headroom, not the production API’s credentials and scale policy.

Give each workload an application_name, service account, pool configuration, and connection budget that makes its pressure visible.

Loosen only from evidence

Run production-shaped load tests including traffic bursts and revision overlap. Watch:

  • instance count and startup rate
  • pool utilization and acquisition latency
  • active, idle, and waiting Postgres sessions
  • transaction age and lock waits
  • query latency, CPU, memory, and I/O
  • request errors and tail latency

Raise concurrency when instances are underused and database demand stays bounded. Raise max instances when useful throughput is limited before Postgres pressure appears. Raise pool size only when session scarcity, rather than database work, is the bottleneck.

Aim for a predictable failure mode: a bounded queue, a classified rejection, or an asynchronous handoff. Autoscaling is useful only while it respects the finite system behind it.

More in this domain: Infrastructure

Browse all

Related patterns