<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Parsa Aftabi]]></title><description><![CDATA[Parsa Aftabi]]></description><link>https://parsaaftabi.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Parsa Aftabi</title><link>https://parsaaftabi.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Thu, 15 Oct 2026 09:45:29 GMT</lastBuildDate><atom:link href="https://parsaaftabi.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Redis in Production: What I Actually Configure]]></title><description><![CDATA[Redis in Production: What I Actually Configure
Redis is the piece of infrastructure everyone under-configures. Out of the box it's a cache with no memory limit, an eviction policy that fails writes in]]></description><link>https://parsaaftabi.hashnode.dev/redis-in-production-what-i-actually-configure</link><guid isPermaLink="true">https://parsaaftabi.hashnode.dev/redis-in-production-what-i-actually-configure</guid><category><![CDATA[Redis]]></category><category><![CDATA[backend]]></category><category><![CDATA[Devops]]></category><category><![CDATA[database]]></category><dc:creator><![CDATA[Parsa Aftabi]]></dc:creator><pubDate>Mon, 12 Oct 2026 18:45:43 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ac917db3b3eb0406cfb3360/d27b1043-141d-4cc5-8ccc-6f2bec85f4b8.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>Redis in Production: What I Actually Configure</h1>
<p>Redis is the piece of infrastructure everyone under-configures. Out of the box it's a cache with no memory limit, an eviction policy that fails writes instead of evicting, and persistence that can lose your last chunk of data. That's fine on a laptop. In production, the defaults will page you at 3am.</p>
<p>Here's what I actually touch on every Redis I ship, and why. None of it is exotic — that's the point.</p>
<h2>1. maxmemory + maxmemory-policy: the two lines that matter most</h2>
<p>Default Redis has no memory limit and <code>noeviction</code>. When memory fills up, writes start failing with OOM errors while reads keep working. That's the worst kind of failure: half your app looks fine, the other half is quietly dropping writes.</p>
<p>I always set <code>maxmemory</code> — below what the box actually has, leaving headroom for fragmentation and the OS — and I pick the policy based on what that Redis instance is <em>for</em>:</p>
<ul>
<li><p><strong>Pure cache:</strong> <code>allkeys-lru</code>. Least-recently-used keys go first. Simple, correct for a cache.</p>
</li>
<li><p><strong>Sessions or mixed data with TTLs:</strong> <code>volatile-lru</code> (or <code>volatile-ttl</code> if you'd rather evict nearest-expiry first).</p>
</li>
<li><p><strong>Queue or anything you can't afford to lose:</strong> <code>noeviction</code> is actually right here. I'd rather have writes fail loudly than silently drop queued jobs. But then <code>maxmemory</code> must be sized so it never fills, and you monitor it like a hawk.</p>
</li>
</ul>
<p>The mistake I keep seeing: one Redis doing all three jobs under one policy. Separate your concerns — or at least know exactly which data you're willing to lose.</p>
<h2>2. Persistence: decide what "losing it" means</h2>
<p>Redis gives you RDB snapshots, an AOF log, or nothing. The default is RDB snapshots only, which means you can lose everything since the last snapshot.</p>
<ul>
<li><p><strong>Pure cache:</strong> persistence off is fine, and it's faster. Losing a cache is a cold start, not an incident. Write that in the runbook so nobody panics at 4am.</p>
</li>
<li><p><strong>Anything you can't rebuild</strong> (sessions you don't want to nuke, queues, rate-limit state): AOF with <code>appendfsync everysec</code>. You lose at most a second of writes, and the performance cost is boring.</p>
</li>
<li><p><strong>Belt and suspenders:</strong> run both. AOF wins on restart; RDB gives you fast point-in-time copies for disaster recovery.</p>
</li>
</ul>
<p>Whatever you choose: test the restart. I've seen more Redis "data loss" caused by nobody ever testing failover than by actual crashes.</p>
<h2>3. Timeouts: reap the dead connections</h2>
<p>Idle client connections pile up. Every app server that connected once and never said goodbye stays on the list. <code>timeout 300</code> closes idle clients (the default is <code>0</code> — never — which is a slow leak). <code>tcp-keepalive 300</code> lets the OS reap dead peers instead of holding their slots forever.</p>
<p>If your <code>CLIENT LIST</code> keeps growing week over week while traffic is flat, this is why.</p>
<h2>4. slowlog: your free query profiler</h2>
<pre><code class="language-plaintext">slowlog-log-slower-than 10000
slowlog-max-len 128
</code></pre>
<p>Then actually read it: <code>SLOWLOG GET</code>. What I'm looking for isn't one slow command — it's the <em>same</em> command showing up hundreds of times. That's your N+1 wearing a Redis costume.</p>
<p>Usual suspects: <code>KEYS *</code> in production (use <code>SCAN</code>), giant hashes fetched whole instead of with <code>HSCAN</code>, and Lua scripts that quietly grew teeth over six months of "just one more line."</p>
<h2>5. Key discipline: TTLs or it didn't happen</h2>
<p>Every key gets a TTL unless you can articulate why not. Keys without expiry are how a cache becomes a memory leak with extra steps.</p>
<p>Here's the trap: <code>volatile-lru</code> and <code>volatile-ttl</code> only evict keys <em>with</em> a TTL. Keys without one are immortal — they will eventually OOM your instance no matter what policy you set. This is the single most common Redis misconfiguration I run into: policy set to <code>volatile-lru</code>, half the keys with no TTL, surprise OOM at 3am.</p>
<p>Two more habits: name keys like paths (<code>service:entity:id</code>) so <code>SCAN</code> is actually usable, and watch out for big keys. A 50MB hash is a latency bomb — on eviction, on replication, on <code>HGETALL</code>. Keep hashes small; split them.</p>
<h2>6. Disable the footguns</h2>
<pre><code class="language-plaintext">rename-command FLUSHDB ""
rename-command FLUSHALL ""
</code></pre>
<p>Nobody needs to flush a production database on a whim and a <code>redis-cli</code>. Same treatment for <code>KEYS</code> and <code>DEBUG</code> in prod. Then the network basics: <code>protected-mode yes</code>, bind to the private interface only, <code>requirepass</code> set. Redis with no password on a reachable interface isn't a misconfiguration — it's an incident waiting for a scanner.</p>
<h2>7. What I actually monitor</h2>
<p>You don't need forty metrics. The ones that have ever paged me usefully:</p>
<ul>
<li><p><strong>used_memory vs maxmemory</strong> (as a percentage — alert <em>before</em> it matters)</p>
</li>
<li><p><strong>evicted_keys rate</strong> (a spike means the working set doesn't fit, or TTLs are missing)</p>
</li>
<li><p><strong>instantaneous_ops_per_sec</strong> (baseline it; deviations mean something changed upstream)</p>
</li>
<li><p><strong>connected_clients and blocked_clients</strong> (blocked &gt; 0 for a long time means something's stuck on <code>BLPOP</code>)</p>
</li>
<li><p><strong>rdb_last_bgsave_status / aof_last_rewrite_status</strong> (persistence failing silently is the classic)</p>
</li>
<li><p><strong>keyspace hits/misses</strong> (hit rate dropping means your cache isn't caching)</p>
</li>
</ul>
<h2>8. Replication: the minimum viable setup</h2>
<p>One replica. <code>replica-serve-stale-data yes</code> — serve reads during failover instead of erroring everything. If you genuinely can't afford to lose writes during a partition, <code>min-replicas-to-write</code> — with the honest caveat that you're trading away availability.</p>
<p>For most services I run, an async replica plus good monitoring beats a fancy Sentinel setup nobody will maintain. If you need real HA with automatic failover, that's a different article — and honestly, probably a managed service.</p>
<hr />
<p>None of this is exotic. Redis in production is mostly about deciding what each instance is <em>for</em>, setting boundaries (memory, TTLs, timeouts), and turning off the defaults that assume a laptop. Do that, and Redis disappears into the background — which is exactly where infrastructure belongs.</p>
]]></content:encoded></item><item><title><![CDATA[Docker Images I'd Actually Ship: A Production Checklist]]></title><description><![CDATA[Most Dockerfiles I've seen in the wild are development artifacts wearing a production costume. Someone took the image they debugged with, slapped a CMD on it, and called it a deploy. It works — right ]]></description><link>https://parsaaftabi.hashnode.dev/docker-images-i-d-actually-ship-a-production-checklist</link><guid isPermaLink="true">https://parsaaftabi.hashnode.dev/docker-images-i-d-actually-ship-a-production-checklist</guid><category><![CDATA[Docker]]></category><category><![CDATA[Devops]]></category><category><![CDATA[PHP]]></category><category><![CDATA[Go Language]]></category><dc:creator><![CDATA[Parsa Aftabi]]></dc:creator><pubDate>Mon, 12 Oct 2026 09:19:09 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ac917db3b3eb0406cfb3360/9be288cd-4808-431c-9a75-66a3e97adc2b.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Most Dockerfiles I've seen in the wild are development artifacts wearing a production costume. Someone took the image they debugged with, slapped a CMD on it, and called it a deploy. It works — right up until it doesn't: 1.2GB images, root processes, secrets baked into layers, and deploys that take ten minutes to pull.</p>
<p>Building a production image is a different discipline. Here's my checklist, the stuff I actually do on every service I ship, PHP or Go.</p>
<h2>Multi-stage builds are the whole game</h2>
<p>If you take one thing from this article, take this. A builder stage with your full toolchain, a runtime stage with almost nothing:</p>
<pre><code class="language-dockerfile"># ---- builder ----
FROM php:8.3-cli AS builder
RUN apt-get update &amp;&amp; apt-get install -y git unzip \
    &amp;&amp; rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader --no-interaction
COPY . .
RUN composer dump-autoload --optimize

# ---- runtime ----
FROM php:8.3-fpm
WORKDIR /var/www
COPY --from=builder /app /var/www
# ... config, permissions, user
</code></pre>
<p>Composer, git, build tools — none of that belongs in the image your users' requests hit. The runtime stage should contain your code, your runtime, and nothing else. This one change usually cuts image size by more than half.</p>
<h2>Pick your base image like an adult</h2>
<p>Three options, three personalities:</p>
<ul>
<li><p><strong>Alpine</strong>: tiny, fast pulls. But musl instead of glibc, which means the occasional weird DNS or binary-compat surprise at 2am. Fine for Go. I'd think twice for PHP with compiled extensions.</p>
</li>
<li><p><strong>Debian slim</strong>: boring in the best way. glibc, everything behaves like your dev machine. Slightly bigger. This is my default for PHP.</p>
</li>
<li><p><strong>Distroless / scratch</strong>: no shell, no package manager, no attack surface to speak of. Perfect for a statically compiled Go binary. Terrifying to debug — which is the point, because there's nothing to debug <em>in</em>.</p>
</li>
</ul>
<p>My defaults: <code>php:8.3-fpm</code> (debian-based) for PHP, <code>gcr.io/distroless/static</code> for Go. Boring choices that don't page me.</p>
<h2>Order your layers for the cache</h2>
<p>Docker caches layers top-down, and every COPY invalidates everything below it. So: dependencies first (they change rarely), source code last (it changes constantly).</p>
<pre><code class="language-dockerfile">COPY go.mod go.sum ./
RUN go mod download
COPY . ./
RUN CGO_ENABLED=0 go build -o /app/server ./cmd/server
</code></pre>
<p><code>go mod download</code> on its own layer means a code change doesn't re-download the world. Same idea with <code>composer.json</code>/<code>composer.lock</code> before the full source copy. Get this wrong and every deploy rebuilds everything from scratch while your CI bill quietly grows.</p>
<h2>Run as non-root. No exceptions.</h2>
<p>A container running as root that escapes is a host compromise. A container running as <code>nobody</code> that escapes is a bad day. The fix is one line and some permission hygiene:</p>
<pre><code class="language-dockerfile">RUN chown -R www-data:www-data /var/www/storage /var/www/bootstrap/cache
USER www-data
</code></pre>
<p>Do it after everything that needs root (installs, chowns), as late as possible in the Dockerfile. If your app can't run without root, that's a bug in your app, not a reason to skip this.</p>
<h2>.dockerignore — the file everyone forgets</h2>
<p>Without it, your build context includes <code>.git</code>, <code>vendor/</code>, <code>node_modules/</code>, your designer's 400MB of PSDs, and last year's backup. Slow builds, bloated contexts, and occasionally secrets you didn't mean to ship.</p>
<pre><code class="language-plaintext">.git
vendor/
node_modules/
.env
*.md
docker-compose*.yml
</code></pre>
<p>Five minutes, once. There's no excuse.</p>
<h2>Healthchecks that actually check something</h2>
<pre><code class="language-dockerfile">HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD wget -qO- http://localhost:8080/health || exit 1
</code></pre>
<p>"Port is open" is not a health check. Your <code>/health</code> endpoint should verify what the app actually needs: can it reach the database, is Redis responding, is the disk writable. A container that accepts traffic while its DB connection is dead is worse than a container that's down — at least a down container gets restarted.</p>
<h2>Secrets don't go in images. Ever.</h2>
<p>Build args, ENV lines with passwords, <code>.env</code> files copied into the image — all of these end up in layer history, readable by anyone who can pull the image. I've seen production AWS keys in public images. Don't be that team.</p>
<p>Secrets arrive at runtime: environment variables from your orchestrator, mounted secret files, a vault agent. The image should boot with zero credentials and complain loudly if they're missing. Fail fast on missing config is a feature.</p>
<h2>PHP specifics</h2>
<ul>
<li><p><strong>Leave xdebug at home.</strong> It's a dev extension. It slows every request and has no business in production. <code>--no-dev</code> on composer, no dev extensions in the runtime stage.</p>
</li>
<li><p><strong>Tune OPcache in the image</strong>, not on the server. <code>opcache.enable=1</code>, <code>opcache.validate_timestamps=0</code> (you redeploy instead of editing files on the box, right?), generous <code>opcache.memory_consumption</code>.</p>
</li>
<li><p><strong>One process per container.</strong> PHP-FPM in one container, nginx in another. Or skip the pair entirely — FrankenPHP serves a Laravel app from a single image with no FPM/nginx split, and it's genuinely simpler to operate.</p>
</li>
</ul>
<h2>Go specifics</h2>
<p>This is where Docker shines brightest:</p>
<pre><code class="language-dockerfile">FROM golang:1.24 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /server ./cmd/server

FROM gcr.io/distroless/static:nonroot
COPY --from=builder /server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]
</code></pre>
<p>A static binary, no libc, no shell, running as nonroot. The final image is ~15MB. Fifteen. Compare that to the 1GB PHP-FPM image you started with and tell me Go doesn't earn its reputation.</p>
<h2>The numbers</h2>
<p>The pattern repeats everywhere: single-stage images with dev dependencies and no <code>.dockerignore</code> routinely land in the 1GB+ range. Rebuild the same app multi-stage — slim runtime, no dev deps, non-root, tuned OPcache — and it lands an order of magnitude smaller, typically well under 200MB. Pull time goes from minutes to seconds, which matters a lot when you're scaling up under load.</p>
<h2>The checklist</h2>
<p>Multi-stage build. Boring base image. Deps before source in layer order. Non-root user. A real <code>.dockerignore</code>. Healthchecks that check dependencies. Secrets only at runtime. No dev tools in the runtime stage.</p>
<p>None of this is exotic. That's the point — production Docker isn't about clever tricks, it's about doing the boring things consistently. The teams that get paged at 3am are usually the ones that skipped the boring things.</p>
]]></content:encoded></item><item><title><![CDATA[Laravel Octane in Production: What the Docs Don't Tell You]]></title><description><![CDATA[Laravel Octane promises something that sounds too good: your Laravel app, running at speeds that make PHP-FPM look like it's wading through mud. Thousands of requests per second. Response times in sin]]></description><link>https://parsaaftabi.hashnode.dev/laravel-octane-in-production-what-the-docs-don-t-tell-you</link><guid isPermaLink="true">https://parsaaftabi.hashnode.dev/laravel-octane-in-production-what-the-docs-don-t-tell-you</guid><category><![CDATA[Laravel]]></category><category><![CDATA[PHP]]></category><category><![CDATA[performance]]></category><category><![CDATA[backend]]></category><dc:creator><![CDATA[Parsa Aftabi]]></dc:creator><pubDate>Sun, 11 Oct 2026 10:59:25 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ac917db3b3eb0406cfb3360/1a825878-c3fb-4a5e-8496-858799a5ad8d.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Laravel Octane promises something that sounds too good: your Laravel app, running at speeds that make PHP-FPM look like it's wading through mud. Thousands of requests per second. Response times in single-digit milliseconds.</p>
<p>It's real. I've seen it. But between the marketing and your production server there's a gap the docs gloss over, and it's full of memory leaks, stateful landmines, and 3am restarts. Here's what I actually learned running Octane for real.</p>
<h2>Pick your server like it matters, because it does</h2>
<p>Octane gives you three engines: FrankenPHP, Swoole, and RoadRunner. The docs present them as roughly equal choices. They're not.</p>
<p><strong>FrankenPHP</strong> is what I'd hand to most teams. It's a Caddy-based app server, installs without drama, and worker mode just works. If your team already deploys PHP the boring way, this is the smallest jump with the biggest payoff.</p>
<p><strong>RoadRunner</strong> is the grown-up in the room. Written in Go, mature process model, great supervisor story. If you want boring reliability and you don't mind one more binary in your stack, it's hard to beat.</p>
<p><strong>Swoole</strong> is the fastest on paper and the one I'd least like to debug at 3am. It's a PHP extension that rewrites the rules of the runtime — coroutines, its own event loop, its own table implementation. When it works it's beautiful. When it doesn't, you're reading C-level stack traces wondering where your evening went.</p>
<p>My take: start with FrankenPHP. Move to RoadRunner if you outgrow it or need its process model. Only reach for Swoole if you know exactly why you need it.</p>
<h2>Your app is stateful and you forgot</h2>
<p>This is the big one. Under PHP-FPM, everything dies at the end of every request. Your sloppy singleton? Gone. That static property accumulating data? Gone. FPM is a garbage collector with a web server attached.</p>
<p>Octane keeps your app booted in memory across requests. Suddenly all that state you were accidentally relying on FPM to clean up... doesn't get cleaned up.</p>
<p>The classic hits:</p>
<pre><code class="language-php">// This looks innocent. Under Octane it's a memory leak with a login form.
class ReportService
{
    protected static array $rows = [];

    public function addRow(array $row): void
    {
        static::$rows[] = $row; // grows forever, across requests, across users
    }
}
</code></pre>
<p>Singletons bound with request-scoped data. Event listeners registered in a service provider that never get deregistered. Config values mutated at runtime (<code>config(['app.timezone' =&gt; ...])</code> in one request leaking into the next). Locale or timezone set per-request and never reset.</p>
<p>The fix isn't complicated, but it requires a mindset shift: <strong>treat every request as sharing a room with every other request.</strong> Audit your singletons. Prefer <code>bind()</code> over <code>singleton()</code> for anything request-scoped. And use Octane's lifecycle hooks — <code>Octane::tick()</code>, request "flush" callbacks — to reset what must not survive.</p>
<p>If your codebase is old and nobody knows what the singletons hold, be honest with yourself: Octane will find every one of them, in production, on a Friday.</p>
<h2>Memory leaks are not an if, they're a when</h2>
<p>Even a clean app leaks a little under a long-lived worker. A few kilobytes per request doesn't matter at 100 requests. At 100,000 requests on the same worker, it matters a lot.</p>
<p>So don't run workers forever. Octane lets you cap it:</p>
<pre><code class="language-php">// config/octane.php
'max_requests' =&gt; 500, // recycle the worker after 500 requests
</code></pre>
<p>Pick a number and stop theorizing. 500 is a fine default. Watch worker memory in your monitoring — if it climbs steadily between recycles, you have a leak worth hunting. If it stays flat, your number is fine and you can raise it.</p>
<p>And set up <code>octane:reload</code> as part of your deploy, not as an afterthought. A deploy that doesn't recycle workers is a deploy that didn't fully happen.</p>
<h2>Concurrency is a tool, not a lifestyle</h2>
<p>Octane can run tasks concurrently (<code>Octane::concurrently()</code>), and it's genuinely useful for the fan-out pattern: three independent API calls, one response. I've replaced whole queue jobs with it where the work was fast and the user was waiting anyway.</p>
<p>But don't turn your request lifecycle into a concurrency puzzle. If you need real background work with retries and failure handling, that's what queues are for. Octane doesn't replace Horizon. It replaces the part of your code that was waiting on I/O for no reason.</p>
<h2>Deploy it like a grown-up</h2>
<p>A few things that bite people on day one:</p>
<ul>
<li><strong>Supervisor (or systemd) is not optional.</strong> Workers die. OOM-killer exists. Your process manager should restart them without you noticing. Here's the shape of it:</li>
</ul>
<pre><code class="language-ini">[program:octane]
command=php /var/www/artisan octane:start --server=frankenphp --workers=auto --max-requests=500
autostart=true
autorestart=true
user=www-data
</code></pre>
<ul>
<li><p><code>--workers=auto</code> <strong>is a starting point, not an answer.</strong> Start there, then size workers to your actual CPU and memory. More workers than cores just adds context-switching overhead.</p>
</li>
<li><p><strong>OPcache + preloading still matter.</strong> Octane doesn't make PHP's compilation cost disappear for free; keep your OPcache tuned like you would anywhere else.</p>
</li>
<li><p><strong>Warm your caches before traffic arrives.</strong> First requests on fresh workers are the slowest. A deploy-time warmup script hitting your key routes saves you from a latency spike right when you're watching the dashboards.</p>
</li>
</ul>
<h2>When I'd skip Octane entirely</h2>
<p>Not every app should run on it, and saying so isn't heresy:</p>
<ul>
<li><p>Low-traffic apps where FPM is already idle 99% of the time. You're adding operational complexity for numbers nobody will notice.</p>
</li>
<li><p>Apps with heavy per-request memory (big file processing, huge collections) — long-lived workers amplify this instead of containing it.</p>
</li>
<li><p>Codebases where nobody will fix the leaks Octane exposes. The server is honest; your app has to be too.</p>
</li>
</ul>
<p>Octane is a performance tool for apps that have outgrown FPM's model, not a magic flag you flip on everything.</p>
<h2>The verdict</h2>
<p>Octane is the real deal — the performance gains aren't marketing. But it's a different runtime model wearing Laravel's clothes, and it demands you actually understand what your app holds in memory.</p>
<p>Do the singleton audit. Cap your worker lifetimes. Pick FrankenPHP unless you have a reason not to. Deploy with a process manager and a reload strategy from day one.</p>
<p>Get those right and Octane is the easiest performance win in the Laravel ecosystem. Get them wrong and you'll learn about memory leaks the hard way — which, to be fair, is also a way to learn.</p>
]]></content:encoded></item><item><title><![CDATA[PostgreSQL Connection Pooling in Go: What I Actually Tune in Production]]></title><description><![CDATA[In my last post, lesson 3 was "your database is the bottleneck, not Go" — use pgx, size the pool deliberately. A few of you asked what "deliberately" actually means. Fair question.
Here's every pgxpoo]]></description><link>https://parsaaftabi.hashnode.dev/postgresql-connection-pooling-in-go-what-i-actually-tune-in-production</link><guid isPermaLink="true">https://parsaaftabi.hashnode.dev/postgresql-connection-pooling-in-go-what-i-actually-tune-in-production</guid><category><![CDATA[Go Language]]></category><category><![CDATA[backend]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[programming languages]]></category><dc:creator><![CDATA[Parsa Aftabi]]></dc:creator><pubDate>Sat, 10 Oct 2026 19:59:15 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ac917db3b3eb0406cfb3360/5a10793f-ce6a-4546-8483-620c5e2c41af.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In my last post, lesson 3 was "your database is the bottleneck, not Go" — use pgx, size the pool deliberately. A few of you asked what "deliberately" actually means. Fair question.</p>
<p>Here's every <code>pgxpool</code> setting I touch in production, what it does, and the number I actually use. No theory — just the config running behind real traffic.</p>
<h2>1. The pool is the choke point, not your code</h2>
<p>Go will happily spin up 10,000 goroutines to serve requests. If your pool holds 20 connections, 9,980 of those goroutines are standing in line doing nothing.</p>
<p>Every performance problem I've debugged in a Go API eventually led here. Not the handler, not the JSON serialization — the pool. It's the narrowest pipe in your system, and the defaults are not your friend. Every setting below is about controlling that pipe.</p>
<h2>2. MaxConns: do the math, per instance</h2>
<p>Each Postgres connection costs the server a backend process — a few MB of memory plus CPU spent context-switching. Past a few hundred connections, Postgres spends more time managing connections than running queries. More connections is not more throughput; it's more overhead.</p>
<p>The formula I use, per API instance:</p>
<pre><code class="language-plaintext">max_conns ≈ (2 × CPU cores) + 4
</code></pre>
<p>A 4-core box gets 12. Not 100. Not unlimited. 12.</p>
<p>Why 2×? Web queries are I/O-bound — while one connection waits on disk, another uses the CPU. Two per core keeps the CPU busy without drowning Postgres in backends. The +4 is headroom for migrations, admin queries, and that one endpoint that fans out.</p>
<p>The part everyone misses: <strong>multiply by instance count.</strong> Three replicas × 12 = 36 connections hitting Postgres. Five services each with a pool of 20 = 100 backends before a single query runs. I size the pool backwards, from the database's perspective: database connection budget ÷ number of clients = per-instance MaxConns.</p>
<pre><code class="language-go">config, err := pgxpool.ParseConfig(connStr)
if err != nil {
    log.Fatal(err)
}
// sized: (2 × 4 cores) + 4. Database budget ÷ instances.
config.MaxConns = 12
</code></pre>
<h2>3. MinConns: idle is not free</h2>
<p><code>MinConns</code> keeps warm connections ready so a traffic burst doesn't pay connection-setup latency. Sounds great until you realize every idle connection still holds a Postgres backend process. <code>MinConns = 10</code> across ten instances is 100 backends doing absolutely nothing at 3 AM.</p>
<p>I keep it small — 2 to 5. Enough to absorb a burst, cheap enough to forget about. pgx ramps up to <code>MaxConns</code> on demand anyway, and the ramp is fast. Warm is good; hoarding is not.</p>
<pre><code class="language-go">config.MinConns = 2
</code></pre>
<h2>4. The two settings everyone ignores: lifetimes</h2>
<p>Long-lived connections go stale. Load balancers silently drop idle TCP connections. DNS changes don't propagate to connections that are already open. A <code>statement_timeout</code> you changed on the server only applies to new connections.</p>
<pre><code class="language-go">config.MaxConnLifetime = time.Hour        // recycle every connection hourly
config.MaxConnIdleTime = 30 * time.Minute // reap connections idle this long
</code></pre>
<p><code>MaxConnLifetime</code> forces rotation — no connection lives long enough to go quietly stale. <code>MaxConnIdleTime</code> reaps connections that are just sitting there holding a backend for no reason.</p>
<p>One gotcha: don't set <code>MaxConnLifetime</code> too aggressively. One minute on a busy pool means constant churn — TLS handshake and auth on every cycle. An hour is boring. Boring is good.</p>
<h2>5. HealthCheckPeriod: trust, but verify</h2>
<p>pgx validates idle connections before handing them out, but only as often as <code>HealthCheckPeriod</code> allows. The default is one minute, which is fine for most setups.</p>
<p>If your network is flaky — cloud load balancers love silently killing idle TCP — drop it to 30 seconds. It's a cheap ping. A dead connection handed to a live request is an expensive 500.</p>
<pre><code class="language-go">config.HealthCheckPeriod = time.Minute
</code></pre>
<h2>6. Fail fast: the context deadline is your acquire timeout</h2>
<p>This is the setting that saves you during an outage. When the pool is exhausted, what should the 21st request do — wait forever, or fail fast?</p>
<p>pgx v5 has no separate acquire-timeout knob. Connection acquisition respects the context deadline. <strong>Your context timeout is your acquire timeout.</strong> Use it:</p>
<pre><code class="language-go">ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()

var name string
err := pool.QueryRow(ctx, "SELECT name FROM users WHERE id = $1", id).Scan(&amp;name)
</code></pre>
<p>Two seconds, then the request fails with a clear error instead of piling up behind a dead database. This is the difference between "the API is slow" and "the API is down for everyone, including the health check." Fail fast, return 503, let the load balancer route around you.</p>
<h2>7. Watch the pool: <code>pool.Stat()</code></h2>
<p>If you're not watching the pool, you're guessing. pgx exposes everything:</p>
<pre><code class="language-go">s := pool.Stat()
log.Printf("pool: total=%d idle=%d acquired=%d max=%d",
    s.TotalConns(), s.IdleConns(), s.AcquiredConns(), s.MaxConns())
</code></pre>
<p>I export these as Prometheus gauges and alert on exactly one thing: <code>AcquiredConns</code> sitting at <code>MaxConns</code>. That means the pool is saturated — every new request is queueing. It tells me to scale instances or find the slow query holding connections hostage, long before users notice.</p>
<p>Two patterns I watch in dashboards:</p>
<ul>
<li><p><code>IdleConns</code> near zero with <code>TotalConns</code> at <code>MaxConns</code> — the pool is too small, or something is leaking connections.</p>
</li>
<li><p><code>AcquireCount</code> climbing far faster than actual queries — something acquires and never releases. Nine times out of ten, it's a forgotten <code>rows.Close()</code>.</p>
</li>
</ul>
<h2>8. Mistakes I've actually made</h2>
<p><strong>A pool per request.</strong> Early on I called <code>pgxpool.New</code> inside a handler "to be safe." Every request built a pool, every pool opened connections. The database fell over in minutes. One pool per process, created at startup, passed down. Ever.</p>
<p><strong>Forgetting</strong> <code>pool.Close()</code><strong>.</strong> On shutdown, in-flight queries get killed mid-flight and the database logs a wall of errors. Close the pool in your graceful shutdown path — after <code>srv.Shutdown</code>, before exit. (See lesson 6 of my last post.)</p>
<p><strong>Prepared statements behind PgBouncer in transaction mode.</strong> pgx prepares statements by default; PgBouncer in transaction-pooling mode can't handle named prepared statements across transactions, and you'll get <code>prepared statement already exists</code> errors at the worst possible time. The fix is one line:</p>
<pre><code class="language-go">config.ConnConfig.DefaultQueryExecMode = pgx.QueryExecModeSimpleProtocol
</code></pre>
<p>Only needed if you run PgBouncer in transaction mode. If you don't know what that means, you probably don't — skip it.</p>
<h2>9. The config I actually ship</h2>
<pre><code class="language-go">package platform

import (
    "context"
    "log"
    "time"

    "github.com/jackc/pgx/v5/pgxpool"
)

func NewPool(ctx context.Context, connStr string) *pgxpool.Pool {
    config, err := pgxpool.ParseConfig(connStr)
    if err != nil {
        log.Fatalf("parse db config: %v", err)
    }

    config.MaxConns = 12
    config.MinConns = 2
    config.MaxConnLifetime = time.Hour
    config.MaxConnIdleTime = 30 * time.Minute
    config.HealthCheckPeriod = time.Minute

    // Only if you run PgBouncer in transaction mode:
    // config.ConnConfig.DefaultQueryExecMode = pgx.QueryExecModeSimpleProtocol

    pool, err := pgxpool.NewWithConfig(ctx, config)
    if err != nil {
        log.Fatalf("create db pool: %v", err)
    }

    // Fail fast at startup if the database is unreachable.
    pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()
    if err := pool.Ping(pingCtx); err != nil {
        log.Fatalf("ping database: %v", err)
    }

    return pool
}
</code></pre>
<p>Twelve connections. Two idle minimum. Hourly rotation. And a ping at startup, so a bad connection string fails the deploy — not the first request at 2 AM.</p>
<hr />
<p>Connection pooling is unglamorous work. Nobody demos a pool config. But it's the difference between an API that degrades gracefully and one that falls over the first time traffic spikes.</p>
<p>What's your MaxConns, and how did you arrive at it? I'm curious whether anyone actually measures this or just copies 20 from a tutorial.</p>
<hr />
<p><em>I write about backend engineering, Go, and the unglamorous work of keeping systems alive. Follow for more.</em></p>
]]></content:encoded></item><item><title><![CDATA[7 Lessons I Learned Building Production APIs in Go]]></title><description><![CDATA[TL;DR: After shipping several production systems in Go — coming from PHP/Laravel — these are the seven lessons that actually mattered: boring project structure, context.Context everywhere, fixing the ]]></description><link>https://parsaaftabi.hashnode.dev/7-lessons-i-learned-building-production-apis-in-go</link><guid isPermaLink="true">https://parsaaftabi.hashnode.dev/7-lessons-i-learned-building-production-apis-in-go</guid><category><![CDATA[Go Language]]></category><category><![CDATA[backend]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[programming languages]]></category><dc:creator><![CDATA[Parsa Aftabi]]></dc:creator><pubDate>Fri, 09 Oct 2026 22:14:43 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ac917db3b3eb0406cfb3360/186e441b-c65c-436d-8e11-1c725267952f.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><strong>TL;DR:</strong> After shipping several production systems in Go — coming from PHP/Laravel — these are the seven lessons that actually mattered: boring project structure, <code>context.Context</code> everywhere, fixing the database before adding cache, deliberate Redis caching, request-ID logging, graceful shutdown, and thin handlers. Code included.</p>
<hr />
<p>When I first picked up Go, I came from PHP/Laravel land — where the framework holds your hand through everything. Go handed me a toolbox, pointed at the horizon, and said <em>"good luck."</em></p>
<p>A few production systems later, here are the seven lessons that actually stuck. Not theory — the stuff I wish someone had told me on day one.</p>
<hr />
<h2>1. Boring project structure beats clever frameworks</h2>
<p>My first Go API had packages named <code>utils</code>, <code>helpers</code>, and <code>common</code> — three junk drawers with the same junk. Don't do that.</p>
<p>What works: organize by domain, keep <code>main.go</code> thin, and resist importing a web framework until <code>net/http</code> genuinely hurts.</p>
<pre><code class="language-plaintext">/cmd/api/main.go
/internal/user/handler.go
/internal/user/service.go
/internal/user/repository.go
/internal/platform/db/
/internal/platform/cache/
</code></pre>
<p>Boring is a feature. The next person reading your code (probably future you at 2 AM) will thank you.</p>
<h2>2. <code>context.Context</code> is not optional</h2>
<p>Every handler, every DB call, every outbound request should take a context. The day a slow downstream service hangs your entire API is the day you learn this the hard way. Learn it the easy way:</p>
<pre><code class="language-go">ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()

user, err := repo.GetByID(ctx, id)
</code></pre>
<p>Three lines. Timeouts, cancellation, and request-scoped values — all from threading <code>ctx</code> through your call chain.</p>
<h2>3. Your database is the bottleneck, not Go</h2>
<p>Go can handle tens of thousands of concurrent requests. Your Postgres connection pool cannot — at least not with default settings.</p>
<p>Use <code>pgx</code> with a properly sized pool, and size it deliberately:</p>
<pre><code class="language-go">pool, err := pgxpool.New(ctx,
    "postgres://user:pass@localhost:5432/app?pool_max_conns=20&amp;pool_min_conns=5")
</code></pre>
<p>Rule of thumb I follow: <code>max_connections ≈ (2 × CPU cores) + a small buffer</code> per instance. And add the missing index <em>before</em> you add Redis. Most "slow API" problems I've debugged were a missing index, not a missing cache.</p>
<h2>4. Cache deliberately with Redis — don't sprinkle it everywhere</h2>
<p>Caching everything is how you get stale data bugs that only appear on Tuesdays. I cache with two rules:</p>
<ol>
<li><p><strong>Cache-aside only</strong> — the app reads from cache, falls back to DB, and writes back. No magic.</p>
</li>
<li><p><strong>Every cached key gets a TTL and an invalidation story.</strong> If you can't explain when the key expires or gets invalidated, don't cache it.</p>
</li>
</ol>
<img src="https://cdn.hashnode.com/uploads/covers/6ac917db3b3eb0406cfb3360/ed50561e-ccda-4d70-a018-6d852b495030.webp" alt="Cache-aside pattern: App → Redis → (on miss) Postgres → write back" style="display:block;margin:0 auto" />

<pre><code class="language-go">// cache-aside in ~10 lines
if val, err := rdb.Get(ctx, key).Result(); err == nil {
    return val, nil // cache hit
}
val, err := db.Query(ctx, ...) // cache miss: go to source
if err == nil {
    rdb.Set(ctx, key, val, 5*time.Minute)
}
return val, err
</code></pre>
<h2>5. Structured logging with request IDs will save you</h2>
<p><code>log.Println("something happened")</code> is a confession, not logging. In production you need to trace one request across handlers, DB calls, and cache lookups. The cheapest way: a request-ID middleware plus structured logs.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ac917db3b3eb0406cfb3360/20c37771-c609-4819-90b8-5dda99b2053f.webp" alt="One request ID traveling through handler, service, and database layers" style="display:block;margin:0 auto" />

<pre><code class="language-go">func requestID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        id := uuid.NewString()
        w.Header().Set("X-Request-ID", id)
        ctx := context.WithValue(r.Context(), "requestID", id)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}
</code></pre>
<p>Then log with fields, not sentences: <code>logger.Info("order.created", "request_id", id, "order_id", orderID)</code>. When something breaks at 3 AM, you'll grep one ID instead of reading 50,000 lines.</p>
<h2>6. Handle graceful shutdown from day one</h2>
<p>It's ten lines of code, and it prevents dropped requests on every deploy:</p>
<pre><code class="language-go">quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
&lt;-quit // wait for shutdown signal

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
    log.Fatalf("forced shutdown: %v", err)
}
</code></pre>
<p>In-flight requests finish, DB connections close cleanly, and your deploys stop causing mysterious 502s.</p>
<h2>7. Keep handlers thin — they're translators, not business logic</h2>
<p>A handler should do three things: parse input, call a service, write output. The moment you see SQL queries or business rules inside a handler, extract them.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ac917db3b3eb0406cfb3360/9bcecfdc-8696-4660-8177-412d67ef11a8.webp" alt="Layered architecture: Handler → Service → Repository" style="display:block;margin:0 auto" />

<pre><code class="language-go">// handler: translation layer only
func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
    var in CreateUserInput
    if err := json.NewDecoder(r.Body).Decode(&amp;in); err != nil {
        writeError(w, http.StatusBadRequest, err)
        return
    }
    user, err := h.service.CreateUser(r.Context(), in)
    if err != nil {
        writeError(w, http.StatusInternalServerError, err)
        return
    }
    writeJSON(w, http.StatusCreated, user)
}
</code></pre>
<p>All the real logic lives in <code>service</code>. Handlers stay dumb, services stay testable.</p>
<hr />
<h2>The thread connecting all seven</h2>
<p>None of these are about Go-specific tricks. They're about <strong>respecting production</strong>: timeouts, observability, clean boundaries, deliberate caching. Go just happens to make the right thing easy — if you let it.</p>
<p>Which lesson hit home for you? And what's the one <em>you</em> wish someone told you on day one? I'd genuinely like to steal it for the next post. 👇</p>
<hr />
<p><em>I write about backend engineering, Go, and the unglamorous work of keeping systems alive. Follow for more.</em></p>
<p><em>Originally published at <a href="https://dev.to/parsaaftabi/7-lessons-i-learned-building-production-apis-in-go-35f9">https://dev.to/parsaaftabi/7-lessons-i-learned-building-production-apis-in-go-35f9</a></em></p>
]]></content:encoded></item></channel></rss>