Running PgBouncer as a Kubernetes Sidecar

This guide is part of PgBouncer vs RDS Proxy vs pgpool-II. Once you have chosen PgBouncer, a second decision follows immediately: run it as a shared central deployment, or as a sidecar container in every application pod. The two topologies fail differently, and the sidecar is the right default for most Kubernetes workloads.

The problem the sidecar solves is connection amplification. A hundred pods each holding a modest pool of twenty connections is two thousand backends against a database that can serve two hundred. A central PgBouncer fixes the arithmetic but introduces a network hop, a single point of failure, and a component that must itself be sized, monitored, and rolled. A sidecar keeps the multiplexing while removing the hop and the shared failure domain — at the cost of more PgBouncer processes to configure.

Sidecar versus central deployment

Dimension Sidecar (one per pod) Central deployment (shared)
Network path localhost, no extra hop one extra hop, plus a Service or load balancer
Failure domain one pod every client of the shared instance
Connection amplification still one pool per pod, but a small one fully collapsed across the fleet
Server-side arithmetic pods x pool_size — must stay small per pod one budget, easy to reason about
Rollout risk rolls with the application a separate rollout that disrupts everyone
Prepared statements same constraints either way same constraints either way
Operational cost config duplicated into every pod one thing to run and monitor

The trade-off is stark on the server-side arithmetic row. A sidecar does not eliminate amplification, it reduces it: each pod still opens pool_size server connections, so a hundred pods with default_pool_size = 4 is four hundred backends. That is better than two thousand and worse than a central pool of forty. The sidecar wins when pool_size per pod can be genuinely small — which transaction pooling makes possible — and loses when the fleet is very large.

A useful threshold: if replicas x default_pool_size comfortably fits inside your database’s connection budget with headroom, use sidecars. If it does not, either lower default_pool_size further or move to a central deployment. Do not run both layers unless you have a specific reason; two proxies in series double the failure modes and make SHOW POOLS output ambiguous.

Two PgBouncer topologies Top: each application pod contains its own PgBouncer container, connecting over localhost, with each pod opening a small number of server connections. Bottom: pods connect over the network to a shared PgBouncer deployment which opens a single small pool to the database. Where the multiplexing happens decides the failure domain sidecar pod: app + PgBouncer connects over localhost pool_size = 4 pod: app + PgBouncer connects over localhost pool_size = 4 pod: app + PgBouncer connects over localhost pool_size = 4 PostgreSQL 4 per pod x pod count central pod: app only network hop to the proxy pod: app only network hop to the proxy shared PgBouncer single failure domain PostgreSQL one fixed pool Sidecars trade a lower blast radius for server-side connection count that grows with replica count.
Sidecars remove the shared failure domain and the network hop; a central deployment collapses the connection count completely.

The pod specification

The essential shape is a second container in the same pod, listening on localhost, with the application pointed at 127.0.0.1:6432 instead of the database:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: orders-api
spec:
  replicas: 40
  template:
    spec:
      terminationGracePeriodSeconds: 60
      containers:
        - name: app
          image: registry.internal/orders-api:1.24.0
          env:
            - name: DATABASE_URL
              value: postgres://orders_app@127.0.0.1:6432/orders
          lifecycle:
            preStop:
              exec:
                command: ["sleep", "10"]   # stop taking traffic first

        - name: pgbouncer
          image: edoburu/pgbouncer:1.23.1
          ports:
            - name: pgbouncer
              containerPort: 6432
          env:
            - name: DB_HOST
              value: orders-db.internal
            - name: DB_USER
              valueFrom: { secretKeyRef: { name: orders-db, key: username } }
            - name: DB_PASSWORD
              valueFrom: { secretKeyRef: { name: orders-db, key: password } }
            - name: POOL_MODE
              value: transaction
            - name: DEFAULT_POOL_SIZE
              value: "4"
            - name: MIN_POOL_SIZE
              value: "1"
            - name: MAX_CLIENT_CONN
              value: "200"
            - name: SERVER_IDLE_TIMEOUT
              value: "600"
          resources:
            requests: { cpu: 50m, memory: 32Mi }
            limits:   { memory: 64Mi }
          readinessProbe:
            tcpSocket: { port: 6432 }
            initialDelaySeconds: 2
            periodSeconds: 5
          lifecycle:
            preStop:
              exec:
                command: ["sh", "-c", "sleep 25"]  # outlive the app container

Four details in that manifest carry most of the value.

DEFAULT_POOL_SIZE is small deliberately. Forty replicas at four server connections is 160 backends — a number that fits inside a 200-connection database with headroom. Raising it to twenty because “twenty felt right for a pool” would produce 800 and exhaust the ceiling. The sizing arithmetic is in configuring PgBouncer pool_size and max_client_conn, applied here per pod rather than per cluster.

PgBouncer is single-threaded, so a CPU request of 50m is usually generous for a sidecar handling one pod’s traffic, and a CPU limit is dangerous: throttling a single-threaded event loop shows up as latency on every query through it. Set a memory limit, leave CPU unlimited or generously above the request.

No CPU limit, but a memory limit. PgBouncer’s memory is dominated by max_client_conn x pkt_buf; 200 clients at 2 KB is under half a megabyte, so 64 Mi is ample and protects the node from a runaway.

The application still needs its own pool. A common mistake is to set the application pool to an enormous value on the theory that PgBouncer absorbs it. The application pool bounds queue depth at the layer where you can attach a timeout and a circuit breaker, so keep it modest — typically two to four times the pod’s thread or worker count.

Shutdown ordering

This is where sidecar deployments most often go wrong. When Kubernetes terminates a pod, it sends SIGTERM to every container simultaneously. If PgBouncer exits first, the application’s in-flight transactions fail with connection-reset errors during every rolling deploy.

The fix has two parts. First, make PgBouncer outlive the application by giving it a longer preStop sleep, as above. Second, use a SIGTERM handling mode in PgBouncer that drains rather than kills. PgBouncer’s default SIGTERM behaviour is an immediate shutdown; SIGINT performs a graceful one that waits for active transactions to finish. Container images differ in what they forward, so verify with your image:

          lifecycle:
            preStop:
              exec:
                command:
                  - sh
                  - -c
                  - |
                    # graceful drain, then wait for the app to finish
                    kill -INT 1 || true
                    sleep 25

If your cluster runs Kubernetes 1.29 or later, native sidecar containers — declared as initContainers with restartPolicy: Always — solve the ordering properly: they start before the application containers and are terminated after them, without any sleep hacks:

    spec:
      initContainers:
        - name: pgbouncer
          image: edoburu/pgbouncer:1.23.1
          restartPolicy: Always     # makes this a native sidecar
          ports:
            - containerPort: 6432

Prefer that form where it is available. The preStop sleep is a workaround for clusters that do not yet support it.

Termination ordering during a rolling deploy Two timelines. Without a drain delay both containers receive SIGTERM together and PgBouncer exits while the application still has in-flight transactions, producing errors. With a drain delay PgBouncer stays up until the application has finished and exited. SIGTERM reaches every container at once — ordering must be added without a drain delay app draining in-flight requests pgbouncer proxy gone; remaining requests fail with a drain delay app draining in-flight requests pgbouncer outlives the app, then exits no connection resets Native sidecar containers on Kubernetes 1.29+ enforce this ordering without a sleep in preStop.
The proxy must be the last thing in the pod to exit, or every rolling deploy produces connection resets.

Authentication and secrets

The sidecar needs credentials for the database, and passing them as plain environment variables — as above, for brevity — means they appear in kubectl describe pod and in the container’s environment. Two better options exist.

Mount userlist.txt from a Secret and use auth_file with SCRAM hashes rather than plaintext:

          volumeMounts:
            - name: pgbouncer-auth
              mountPath: /etc/pgbouncer/userlist.txt
              subPath: userlist.txt
              readOnly: true

Or use auth_query, where PgBouncer looks up the client’s password hash from the database itself using a single dedicated lookup role. That keeps per-user credentials out of every pod entirely — only the lookup role’s credential is distributed:

auth_type = scram-sha-256
auth_user = pgbouncer_auth
auth_query = SELECT usename, passwd FROM pg_shadow WHERE usename = $1

For managed databases with IAM authentication the calculus changes again: tokens are short-lived, so the sidecar needs a refresh mechanism. On AWS that generally means preferring RDS Proxy, which handles token rotation natively — one of the concrete decision factors covered in PgBouncer vs RDS Proxy vs pgpool-II.

The sidecar multiplier A sidecar reduces connection amplification but does not remove it. Server-side connections are the replica count multiplied by the per-pod pool size, so an autoscaler that raises replicas also raises database connections. Server-side connections scale with replica count, not with traffic 40 replicas x 4 160 backends — fits a 200 ceiling autoscaler to 60 240 backends — over the ceiling database refuses too many clients, during a traffic peak cap from the budget set the HPA maximum accordingly Derive the autoscaler maximum from the connection budget as well as from CPU, or the spike that triggers a scale-up also exhausts the database. Where the maximum replica count times a pool size of two still does not fit, the sidecar topology has reached its limit.
Sidecar connection count scales with replica count, so the autoscaler maximum must respect the database budget.

Common failure patterns and remediation

Symptom Underlying cause Remediation
FATAL: sorry, too many clients already after scaling up replicas x default_pool_size exceeded the database ceiling Lower default_pool_size; add an HPA maximum that respects the budget
Connection resets during every rolling deploy PgBouncer exits before the application Add a longer preStop on the sidecar, or use native sidecars
Latency spikes correlated with CPU throttling A CPU limit on a single-threaded proxy Remove the CPU limit; keep the request
Prepared statement errors after adding the sidecar Transaction mode multiplexes server connections Disable server-side prepared statements, or use PgBouncer 1.21+ with max_prepared_statements
Application cannot reach the proxy at startup Sidecar not ready before the app connects Use native sidecars, or retry with backoff in the app’s startup path
Metrics missing for the proxy layer No exporter alongside the sidecar Add a third container running the PgBouncer exporter, scraped per pod
Server connections climbing after HPA scale-down Old pods terminated before their server connections closed Set server_idle_timeout low enough that orphaned backends are reaped
Every pod holds min_pool_size connections at rest Correct behaviour, but multiplied by replica count Set min_pool_size to 0 or 1 on large fleets

Observability for a sidecar fleet

A central PgBouncer has one SHOW POOLS to read. A sidecar fleet has one per pod, which is both harder and more useful — per-pod queue depth tells you whether saturation is uniform or concentrated on a few pods.

Run the exporter as a third container in the pod and scrape it with a pod-level ServiceMonitor, so every series carries the pod label. The queue depth expression then aggregates naturally:

# pods where the local proxy has clients queued
sum by (pod) (pgbouncer_pools_client_waiting_connections) > 0

# fleet-wide server connections against the database budget
sum(pgbouncer_pools_server_active_connections
  + pgbouncer_pools_server_idle_connections)

That second expression is the one to alert on. It is the number that must stay below your database’s connection budget, and in a sidecar topology it moves whenever the replica count moves — including automatically, if an autoscaler is involved. Wire it into the dashboard described in Prometheus and Grafana Pool Metrics, and treat it as a capacity signal rather than an incident signal.

Operational boundary

A sidecar does not change what transaction pooling costs you. Session-level features — advisory locks held across statements, LISTEN/NOTIFY, session-scoped temporary tables, and SET outside a transaction — remain unavailable regardless of where the proxy runs. Those constraints are the subject of Transaction vs Statement Pooling Tradeoffs, and they are unchanged by topology.

Nor does a sidecar reduce total server-side connections below what a central deployment achieves. If your replica count is high enough that even default_pool_size = 2 blows the budget, the sidecar topology has reached its limit and a central deployment — or a managed proxy — is the answer.

Frequently Asked Questions

What pool_size should a sidecar use?
Small. Start from your database’s connection budget, subtract headroom, and divide by the maximum replica count, not the current one. Four is a common answer for a forty-pod deployment against a 200-connection database.
Should the application still have its own pool?
Yes. It bounds queue depth where timeouts and circuit breakers live, and it avoids paying a connection setup on every query. Keep it modest — a small multiple of the worker count.
Does the sidecar need a liveness probe?
A readiness probe on the TCP port is worthwhile so the pod does not receive traffic before the proxy is listening. A liveness probe that restarts PgBouncer is riskier: a restart drops every in-flight transaction in that pod.
Can I run the exporter in the same container?
Not usefully. Run it as an additional container in the pod; it connects to the proxy over localhost and adds a few megabytes.
How do I avoid distributing database passwords to every pod?
Use auth_query with a single lookup role, or mount a SCRAM userlist.txt from a Secret. Plain environment variables are the least good option.
What happens when the HPA scales up during a traffic spike?
Each new pod opens up to default_pool_size server connections. If the maximum replica count times default_pool_size exceeds the database ceiling, the spike that triggered the scale-up also exhausts the database. Set the HPA maximum from the connection budget, not only from CPU.
Is a DaemonSet a middle ground?
Yes — one PgBouncer per node rather than per pod. It reduces the multiplier from replica count to node count and is worth considering when pods are small and dense. The shutdown-ordering problem disappears, but the failure domain grows to every pod on the node.