<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Runsite Team</title>
    <description>The latest articles on DEV Community by Runsite Team (@runsite).</description>
    <link>https://dev.to/runsite</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4068763%2F5981f3c9-d935-4266-957e-08c780340cb8.png</url>
      <title>DEV Community: Runsite Team</title>
      <link>https://dev.to/runsite</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/runsite"/>
    <language>en</language>
    <item>
      <title>S3 Egress Fees: Why Downloading Your Own Data Costs So Much</title>
      <dc:creator>Runsite Team</dc:creator>
      <pubDate>Wed, 19 Aug 2026 15:47:41 +0000</pubDate>
      <link>https://dev.to/runsite/s3-egress-fees-why-downloading-your-own-data-costs-so-much-13no</link>
      <guid>https://dev.to/runsite/s3-egress-fees-why-downloading-your-own-data-costs-so-much-13no</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Cross-posted from &lt;a href="https://runsite.app/blog/s3-egress-fees" rel="noopener noreferrer"&gt;the Runsite blog&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You put a few hundred gigabytes of images on object storage, glance at the pricing page, and the numbers look friendly: storage is a couple of dollars a month, basically a rounding error.&lt;/p&gt;

&lt;p&gt;Then the first real invoice arrives and it's a hundred and something. Nothing about how much you're storing changed.&lt;/p&gt;

&lt;p&gt;The line that blew up isn't storage at all. It's &lt;strong&gt;egress&lt;/strong&gt; — the charge for data leaving the bucket — and it's the part of the bill nobody shops on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the storage bill blows up after the first invoice
&lt;/h2&gt;

&lt;p&gt;The pricing page wasn't lying to you. Object storage genuinely is cheap to sit on. On &lt;a href="https://aws.amazon.com/s3/pricing/" rel="noopener noreferrer"&gt;AWS S3&lt;/a&gt;, standard storage runs about &lt;strong&gt;$0.023 per GB per month&lt;/strong&gt; at the time of writing, so a hundred gigabytes of assets costs you around two dollars and change to keep. That's the number you compare when you're choosing where to put your files.&lt;/p&gt;

&lt;p&gt;The number you don't compare is egress: the fee for moving data out of the provider's network. It doesn't show up when you upload, and it doesn't show up while the files just sit there. It shows up &lt;strong&gt;every time someone downloads something&lt;/strong&gt; — roughly &lt;strong&gt;$0.09 per GB&lt;/strong&gt; to the internet once you're past a small free allowance (about the first 100 GB a month on AWS).&lt;/p&gt;

&lt;p&gt;Individually those are tiny fractions of a cent. The trouble is you're not billed once. You're billed per download, and a popular file gets downloaded a lot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where egress hides
&lt;/h2&gt;

&lt;p&gt;Egress is data transfer out: every byte that leaves the provider's network. The reason it surprises people is that it isn't a single line you can point at. It's a multiplier that quietly attaches itself to things you'd never think of as "downloading":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Serving assets to users.&lt;/strong&gt; Every image, video, PDF, or download your app hands to a visitor is egress. One 4 MB hero image on a page that gets a million views a month is &lt;strong&gt;four terabytes&lt;/strong&gt; of transfer out, from a single file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CDN origin pulls.&lt;/strong&gt; Putting a CDN in front of your bucket helps, but it isn't free. Every cache miss means the CDN fetches the file from your bucket, and that origin pull is billed as egress. A cold cache, a low hit rate, or short cache lifetimes all quietly raise the number.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-region and cross-AZ traffic.&lt;/strong&gt; Replicating a bucket to a second region, or reading it from a service in a different availability zone, counts as data transfer too. That's egress you're paying to move data around &lt;em&gt;inside&lt;/em&gt; the same provider.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Moving backups and exports out.&lt;/strong&gt; Pulling a database dump back down to restore it, shipping logs to an external tool, or handing a data export to a customer all leave the network.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retries and failed transfers.&lt;/strong&gt; A download that drops at 90% and restarts still billed you for that 90%. At scale, retries are a line item of their own.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are exotic. They're the normal behavior of a normal app. That's the point: &lt;strong&gt;egress isn't a penalty for doing something wrong.&lt;/strong&gt; It's a charge on the thing storage is for, which is handing files to people.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to calculate your real bill
&lt;/h2&gt;

&lt;p&gt;You can estimate egress before a provider does it for you. The shape of it is two separate sums:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;storage cost = gigabytes stored     x  storage rate per GB
egress cost  = gigabytes served out x  egress rate per GB

monthly bill = storage cost + egress cost
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The storage side is easy — it's just how much you keep. The egress side is the one that runs away, because it isn't tied to how much you store, it's tied to &lt;strong&gt;how much you serve&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Take a small media site that keeps 100 GB of images and video and serves 2 TB of it over a month. A busy blog with a bit of video is well into that range:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Line&lt;/th&gt;
&lt;th&gt;Volume&lt;/th&gt;
&lt;th&gt;Rate&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Storage&lt;/td&gt;
&lt;td&gt;100 GB&lt;/td&gt;
&lt;td&gt;$0.023/GB&lt;/td&gt;
&lt;td&gt;$2.30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Egress&lt;/td&gt;
&lt;td&gt;1,900 GB (2 TB served, first 100 GB free)&lt;/td&gt;
&lt;td&gt;$0.09/GB&lt;/td&gt;
&lt;td&gt;$171.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$173.30&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The storage you actually shopped on is $2.30. The bill is $173.30.&lt;/p&gt;

&lt;p&gt;Everything in the gap is egress, and it scales with traffic, not with how much you store. Double your visitors next month and the storage line doesn't move while the egress line does. &lt;strong&gt;You sized your storage, but the meter runs on your audience.&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Check the rates before you trust the math.&lt;/strong&gt; Provider egress prices change, vary by destination and region, and drop in tiers as volume climbs. The figures above are round numbers for AWS S3 to the public internet at the time of writing. Use them to understand the shape of the bill, then plug in your own provider's current numbers.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Expensive egress is also vendor lock-in
&lt;/h2&gt;

&lt;p&gt;There's a second cost that doesn't show up until you try to leave. Your data lives in the bucket, and the only way to move it somewhere else is to download it — which means egress.&lt;/p&gt;

&lt;p&gt;Say you've accumulated a terabyte and want to migrate. At around $0.09 per GB that's roughly &lt;strong&gt;$90 in transfer just to copy it out&lt;/strong&gt;, before you've stored a single byte anywhere new. And a terabyte is a small dataset. For a serious archive the exit fee runs into real money.&lt;/p&gt;

&lt;p&gt;Egress isn't only an operating cost, then. It's an exit tax, and a provider that charges a lot for it has quietly made itself expensive to leave.&lt;/p&gt;

&lt;p&gt;Regulators have landed on the same conclusion: the &lt;a href="https://digital-strategy.ec.europa.eu/en/policies/data-act" rel="noopener noreferrer"&gt;EU Data Act&lt;/a&gt; phases out cloud switching charges precisely because exit fees keep customers stuck.&lt;/p&gt;

&lt;h2&gt;
  
  
  Zero-egress object storage
&lt;/h2&gt;

&lt;p&gt;Flip the pricing around and the picture calms down. When data transfer out is free, the egress line goes to zero and your bill collapses back to the one thing you can predict: the gigabytes you store.&lt;/p&gt;

&lt;p&gt;It isn't a fringe idea. &lt;strong&gt;Cloudflare R2, Backblaze B2, Wasabi, and Runsite&lt;/strong&gt; all drop or heavily discount egress. They don't do it on identical terms — some cap requests, some price minimum retention, some bill differently for classes of storage — so read what each one bills &lt;em&gt;instead&lt;/em&gt; of transfer before you pick.&lt;/p&gt;

&lt;p&gt;Run the same media site through flat storage pricing at roughly €0.025/GB: the 100 GB of assets costs about €2.50 a month, and that's the whole bill, whether the site serves 2 TB or 20 TB. The line that was 99% of the invoice isn't discounted — it's simply gone.&lt;/p&gt;

&lt;p&gt;The migration worry goes with it. These are all S3-compatible, so the AWS SDK, boto3, or any S3 client works unchanged: point the endpoint at the new host, swap the keys, and nothing in your code moves.&lt;/p&gt;

&lt;h2&gt;
  
  
  When egress actually matters — and when it doesn't
&lt;/h2&gt;

&lt;p&gt;Worth being honest here, because egress isn't a crisis for everyone. It's probably your biggest bill line if you're doing one of these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Media and video.&lt;/strong&gt; Large files served repeatedly is exactly the shape that runs the meter hot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Public assets at scale.&lt;/strong&gt; A busy site's static files, a widely used app's downloads, user-generated content with real traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Frequent restores or data movement.&lt;/strong&gt; Backups you pull back often, datasets you shuttle between environments, exports you hand to customers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And if that's not you, don't let the horror stories push you into over-engineering. A low-traffic internal tool, a private bucket that's written far more than it's read, or an archive that mostly sits untouched will barely register any egress at all. On that kind of workload the difference between metered and free transfer is a few cents.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The reason to care is the shape of your traffic, not the fact that egress exists.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;Storage is the cheap, predictable part. Egress is the part that scales with how much the world uses your files — and it doubles as an exit fee when you try to leave.&lt;/p&gt;

&lt;p&gt;Work out your own two lines before the invoice does it for you.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Related: &lt;a href="https://runsite.app/blog/s3-compatible-object-storage" rel="noopener noreferrer"&gt;S3-compatible object storage, and where the compatibility runs out&lt;/a&gt; and &lt;a href="https://runsite.app/blog/s3-alternatives-no-egress-fees" rel="noopener noreferrer"&gt;what the zero-egress providers bill instead&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: I work on &lt;a href="https://runsite.app" rel="noopener noreferrer"&gt;Runsite&lt;/a&gt;, which is one of the zero-egress providers listed above. The AWS figures are its published list pricing, not my estimate — check the current rates before you budget against them.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>cloud</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
    <item>
      <title>PostgreSQL "FATAL: sorry, too many clients already" — What It Means and How to Fix It</title>
      <dc:creator>Runsite Team</dc:creator>
      <pubDate>Tue, 11 Aug 2026 13:31:50 +0000</pubDate>
      <link>https://dev.to/runsite/postgresql-fatal-sorry-too-many-clients-already-what-it-means-and-how-to-fix-it-5gpl</link>
      <guid>https://dev.to/runsite/postgresql-fatal-sorry-too-many-clients-already-what-it-means-and-how-to-fix-it-5gpl</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Cross-posted from &lt;a href="https://runsite.app/blog/postgres-too-many-clients-already" rel="noopener noreferrer"&gt;the Runsite blog&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A deploy goes out, traffic picks up, and your app starts throwing 500s. You open the logs and there it is, over and over: &lt;code&gt;FATAL: sorry, too many clients already&lt;/code&gt;. Nothing changed in your queries, the database is barely breaking a sweat on CPU, and yet new requests can't get in.&lt;/p&gt;

&lt;p&gt;The error reads like the database is overloaded. It almost never is. What's actually full is the connection pool, and once you see why, the fix is straightforward and permanent.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the error actually means
&lt;/h2&gt;

&lt;p&gt;PostgreSQL accepts a fixed number of simultaneous client connections, set by the &lt;a href="https://www.postgresql.org/docs/current/runtime-config-connection.html" rel="noopener noreferrer"&gt;&lt;code&gt;max_connections&lt;/code&gt; parameter&lt;/a&gt; (100 by default). When connection number 101 tries to open while the first 100 are still held, the server doesn't queue it or slow it down. It refuses outright.&lt;/p&gt;

&lt;p&gt;You'll sometimes see its sibling, &lt;code&gt;remaining connection slots are reserved for non-replication superuser connections&lt;/code&gt;, which is the same wall hit a few slots earlier — Postgres keeps a handful in reserve so an admin can still log in to fix the mess.&lt;/p&gt;

&lt;p&gt;The key thing: &lt;strong&gt;this is a count of open connections, not a measure of load.&lt;/strong&gt; An app running ten trivial &lt;code&gt;SELECT 1&lt;/code&gt; queries can exhaust the limit while one running a single heavy report stays well under it. The error is about how many doors are open, not how much work is going through them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Postgres has a connection ceiling at all
&lt;/h2&gt;

&lt;p&gt;It helps to know why the limit exists, because it explains why "just raise it" is a trap.&lt;/p&gt;

&lt;p&gt;PostgreSQL uses a process-per-connection model: every connection forks a dedicated backend process on the server, and each one reserves memory for its work area, query plans, and buffers — idle or not. A hundred connections doing nothing still cost real RAM.&lt;/p&gt;

&lt;p&gt;Push &lt;code&gt;max_connections&lt;/code&gt; to 500 on a small instance and you can starve the database of the memory it needs to actually run queries, trading a clean refusal for thrashing and OOM kills. The ceiling isn't arbitrary; it's the database protecting itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Find out what's eating your connections
&lt;/h2&gt;

&lt;p&gt;Before changing anything, look at who's actually connected. Postgres exposes this in the &lt;a href="https://www.postgresql.org/docs/current/monitoring-stats.html" rel="noopener noreferrer"&gt;&lt;code&gt;pg_stat_activity&lt;/code&gt; view&lt;/a&gt;. Group it by state to get the shape of the problem in one query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- How many connections, and what are they doing?&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_activity&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;state&lt;/code&gt; column is the tell, and each value points at a different fix:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;active&lt;/code&gt;&lt;/strong&gt; — real concurrent work in flight. The answer is pooling, so fewer connections do the same amount of work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;idle&lt;/code&gt;&lt;/strong&gt; — your app opened the connection and walked away without closing it. The answer is fixing how it manages connections.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;idle in transaction&lt;/code&gt;&lt;/strong&gt; — the worst of the three: a connection that ran a query, never committed or rolled back, and is now holding both a slot and its locks hostage. A few of these can wedge a whole app.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The usual culprit: connections multiply with instances
&lt;/h3&gt;

&lt;p&gt;If each app instance opens its own pool of, say, 20 connections, then four instances plus a couple of background workers and a cron job quietly add up to well past 100 — even though no single piece looks unreasonable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Connection math is per-fleet, not per-process.&lt;/strong&gt; Count every instance, worker, and scheduled job that talks to the database.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real fix: pool your connections
&lt;/h2&gt;

&lt;p&gt;Opening a fresh connection per request is the root cause behind most of these incidents. Connections are expensive to create and strictly limited, so the fix is to open a small set once and reuse them. There are two layers to do it at, and busy apps want both.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Pool inside your app
&lt;/h3&gt;

&lt;p&gt;Every serious database driver ships a pool. Use it, and cap it deliberately instead of leaving it on a generous default. The cap is per process, so multiply it by how many instances you run and keep the total comfortably under &lt;code&gt;max_connections&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Pool&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// One pool per process, reused across requests.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Pool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// ceiling PER instance: 10 x instances must stay under max_connections&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Borrow and return; never open a connection per request.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SELECT * FROM users WHERE id = $1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This alone resolves a surprising share of "too many clients" fires. The trap to avoid is creating the pool &lt;strong&gt;inside a request handler&lt;/strong&gt;, which spins up a brand-new pool on every call and reproduces the exact problem you're trying to kill. Create it once, at startup.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Put a pooler in front of the database
&lt;/h3&gt;

&lt;p&gt;App-side pools have a hard limit: they can't coordinate across instances. Each one only knows its own connections, so a fleet that scales horizontally will still blow the ceiling no matter how careful each process is.&lt;/p&gt;

&lt;p&gt;The fix is a single pooler that sits between your apps and Postgres, multiplexing thousands of client connections down onto a small set of real database ones. PgBouncer is the standard tool, and in &lt;strong&gt;transaction pooling&lt;/strong&gt; mode it hands a real connection to a client only for the duration of a transaction, then returns it to the shared set — so a handful of backend connections can serve a large fleet.&lt;/p&gt;

&lt;p&gt;A common way to expose this is two ports on the same database: one for a direct session, one that routes through the pooler.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Direct connection (port 5432)&lt;/span&gt;
&lt;span class="nv"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;postgresql://user:pass@db.example.com:5432/mydb

&lt;span class="c"&gt;# Pooled through PgBouncer (port 6432)&lt;/span&gt;
&lt;span class="nv"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;postgresql://user:pass@db.example.com:6432/mydb?pgbouncer&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One caveat worth knowing up front: &lt;strong&gt;transaction pooling doesn't play nicely with session-level features&lt;/strong&gt; like prepared statements or &lt;code&gt;SET&lt;/code&gt; that expect to live for a whole session. Most ORMs have a setting for this — check yours before you switch the port in production rather than improvising mid-incident.&lt;/p&gt;

&lt;p&gt;Rule of thumb: pooled endpoint for app traffic, direct endpoint for migrations and admin tasks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hunt down leaked and idle connections
&lt;/h2&gt;

&lt;p&gt;If &lt;code&gt;pg_stat_activity&lt;/code&gt; showed a wall of &lt;code&gt;idle&lt;/code&gt; or &lt;code&gt;idle in transaction&lt;/code&gt; rows, pooling alone won't save you, because something is opening connections and never letting go. The common causes are short: a code path that opens a connection and returns before closing it, a transaction that errors out without a rollback, or a worker that holds a connection open while it waits on something slow.&lt;/p&gt;

&lt;p&gt;To buy breathing room in the moment, you can terminate the connections that have been sitting idle the longest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Free connections idle for more than 10 minutes&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_terminate_backend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_activity&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'idle'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;state_change&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'10 minutes'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;pid&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;pg_backend_pid&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a fire extinguisher, not a fix; the leak will refill the slots if you don't find it. The durable version is to let the database reap them automatically by setting &lt;code&gt;idle_in_transaction_session_timeout&lt;/code&gt;, so a forgotten transaction can't hold a slot indefinitely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this error usually comes from
&lt;/h2&gt;

&lt;p&gt;The error text is identical everywhere, but the thing filling the slots depends on what you're running. Four setups account for most cases, and each has a different tell.&lt;/p&gt;

&lt;h3&gt;
  
  
  Docker and Docker Compose
&lt;/h3&gt;

&lt;p&gt;A Postgres container ships with the stock &lt;code&gt;max_connections&lt;/code&gt; of 100, and Compose makes it easy to outgrow that without noticing. &lt;code&gt;docker compose up --scale api=4&lt;/code&gt; quadruples your pools while the database config stays where it was.&lt;/p&gt;

&lt;p&gt;Restarting an app container makes it worse before it makes it better: the old backends aren't closed cleanly, so Postgres keeps them until TCP keepalive expires, and the fresh container opens a full pool on top of them. &lt;strong&gt;If the count drops on its own a few minutes after a restart, that's what you were looking at.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Set the pool size per container from the fleet total, not per service, and raise the container's &lt;code&gt;max_connections&lt;/code&gt; deliberately rather than letting the default decide.&lt;/p&gt;

&lt;h3&gt;
  
  
  Django
&lt;/h3&gt;

&lt;p&gt;Django's &lt;code&gt;CONN_MAX_AGE&lt;/code&gt; is the usual cause. At the default of &lt;code&gt;0&lt;/code&gt; every request closes its connection — wasteful but safe. Set it to a non-zero value (or &lt;code&gt;None&lt;/code&gt;) without a pooler in front and every Gunicorn worker holds a connection open for its lifetime.&lt;/p&gt;

&lt;p&gt;The arithmetic is workers × instances: 16 workers across three instances is 48 permanent connections before Celery or a management command touches the database.&lt;/p&gt;

&lt;p&gt;Either keep &lt;code&gt;CONN_MAX_AGE&lt;/code&gt; low and let a pooler do the reuse, or keep persistent connections and size the worker count against &lt;code&gt;max_connections&lt;/code&gt; on purpose. On Django 4.2+, turn on &lt;code&gt;conn_health_checks&lt;/code&gt; so a recycled connection that died server-side doesn't surface as an error in the request.&lt;/p&gt;

&lt;h3&gt;
  
  
  DBeaver, pgAdmin, and other GUI clients
&lt;/h3&gt;

&lt;p&gt;A single connection in a database IDE is rarely a single connection to Postgres. DBeaver opens a separate session for metadata, and typically another per SQL editor tab, and holds all of them for as long as the app is open.&lt;/p&gt;

&lt;p&gt;Three developers with a handful of tabs each can quietly occupy a fifth of a 100-connection limit while doing nothing at all. If &lt;code&gt;pg_stat_activity&lt;/code&gt; shows &lt;code&gt;idle&lt;/code&gt; rows whose &lt;code&gt;application_name&lt;/code&gt; is a client tool, that's your answer.&lt;/p&gt;

&lt;h3&gt;
  
  
  A local Postgres on Ubuntu or Debian
&lt;/h3&gt;

&lt;p&gt;The package default is the same 100, set in &lt;code&gt;/etc/postgresql/&amp;lt;version&amp;gt;/main/postgresql.conf&lt;/code&gt;. Changing it needs a full &lt;code&gt;sudo systemctl restart postgresql&lt;/code&gt;, not a reload, because &lt;code&gt;max_connections&lt;/code&gt; is allocated at startup.&lt;/p&gt;

&lt;p&gt;On a dev machine the culprit is usually simpler than a config value: &lt;code&gt;psql&lt;/code&gt; sessions left in old terminal tabs, plus every project's dev server holding its own pool against the same instance.&lt;/p&gt;

&lt;h2&gt;
  
  
  When raising max_connections is (and isn't) the answer
&lt;/h2&gt;

&lt;p&gt;Sometimes the limit really is too low for legitimate, well-pooled traffic, and the right move is genuinely a bigger instance. The order matters, though:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Pool first.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Find any leaks second.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only then raise the ceiling&lt;/strong&gt; — and raise the instance's memory along with it, not just the number.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A higher limit on the same small instance buys instability, not headroom.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;"Too many clients already" is a connection-count problem wearing the costume of an overload.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Check &lt;code&gt;pg_stat_activity&lt;/code&gt; to see whether you're looking at real concurrency, idle leftovers, or stuck transactions.&lt;/li&gt;
&lt;li&gt;Pool connections inside each app, and put PgBouncer in front of the fleet so thousands of clients ride on a handful of real connections.&lt;/li&gt;
&lt;li&gt;Reach for a bigger &lt;code&gt;max_connections&lt;/code&gt; last, and when you do, give it the memory to match.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do that and the error stops being a recurring incident and becomes a line you never see again.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Related, if you want to go deeper: &lt;a href="https://runsite.app/blog/pgbouncer-connection-pooling" rel="noopener noreferrer"&gt;connection pooling with PgBouncer&lt;/a&gt; and &lt;a href="https://runsite.app/blog/self-hosted-vs-managed-postgresql" rel="noopener noreferrer"&gt;self-hosted vs managed PostgreSQL&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: I work on &lt;a href="https://runsite.app" rel="noopener noreferrer"&gt;Runsite&lt;/a&gt;, a deployment platform whose managed Postgres ships with PgBouncer on port 6432 — which is why that setup is the one I reach for by reflex. Everything above works the same on any Postgres you run yourself.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>devops</category>
      <category>docker</category>
    </item>
  </channel>
</rss>
