<?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: Libme</title>
    <description>The latest articles on DEV Community by Libme (@libme).</description>
    <link>https://dev.to/libme</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%2F4062668%2F1762b3d3-a3e8-4856-a46b-270e29821fed.png</url>
      <title>DEV Community: Libme</title>
      <link>https://dev.to/libme</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/libme"/>
    <language>en</language>
    <item>
      <title>pip "externally-managed-environment" Error: venv, pipx, or uv?</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Sun, 01 Nov 2026 17:19:18 +0000</pubDate>
      <link>https://dev.to/libme/pip-externally-managed-environment-error-venv-pipx-or-uv-5bak</link>
      <guid>https://dev.to/libme/pip-externally-managed-environment-error-venv-pipx-or-uv-5bak</guid>
      <description>&lt;p&gt;If &lt;code&gt;pip install&lt;/code&gt; fails with &lt;code&gt;error: externally-managed-environment&lt;/code&gt;, your operating system's package manager owns that Python, and pip is refusing to write into it (PEP 668). The fix is almost never &lt;code&gt;--break-system-packages&lt;/code&gt;: use a virtual environment for project dependencies, &lt;code&gt;pipx&lt;/code&gt; or &lt;code&gt;uv tool&lt;/code&gt; for command-line applications, and reserve the override flag for throwaway containers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the error actually say, and why does it happen?
&lt;/h2&gt;

&lt;p&gt;On a recent Debian, Ubuntu, or Homebrew-installed Python, this is what you get:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ pip install requests
error: externally-managed-environment

× This environment is externally managed
╰─&amp;gt; To install Python packages system-wide, try apt install
    python3-xyz, where xyz is the package you are trying to
    install.

    If you wish to install a non-Debian-packaged Python package,
    create a virtual environment using python3 -m venv path/to/venv.
    ...
note: If you believe this is a mistake, please contact your Python installation or OS distribution provider. You can override this, at your own risk, by passing the --break-system-packages flag.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mechanism is a marker file. Distributions that ship PEP 668 support place a file named &lt;code&gt;EXTERNALLY-MANAGED&lt;/code&gt; in the interpreter's standard library directory, and pip checks for it before any install into that environment. You can see it yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import sysconfig, pathlib; p = pathlib.Path(sysconfig.get_path('stdlib')) / 'EXTERNALLY-MANAGED'; print(p, p.exists())"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reason it exists is real: &lt;code&gt;apt&lt;/code&gt; and &lt;code&gt;pip&lt;/code&gt; both write into the same &lt;code&gt;site-packages&lt;/code&gt;, and when pip upgrades a library that an OS tool depends on, the OS tool can break in ways that are miserable to diagnose. As of late 2026 this behavior is the default on current Debian and Ubuntu releases, and Homebrew's Python applies it too. It is not a pip bug.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: the error means "this interpreter belongs to your OS", and the right response is to stop installing into it, not to silence the check.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Which dead ends should you skip?
&lt;/h2&gt;

&lt;p&gt;These are the ones I see suggested most often, and why they cost you later:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;sudo pip install ...&lt;/code&gt;&lt;/strong&gt; does not bypass the check, and if it did, you would be writing into the exact directory the protection exists for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deleting the &lt;code&gt;EXTERNALLY-MANAGED&lt;/code&gt; file&lt;/strong&gt; works until the next Python package update puts it back, and in the meantime you have turned off the guard for every user on the machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;pip install --user&lt;/code&gt;&lt;/strong&gt; is blocked by the same marker on most setups, so it does not help.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--break-system-packages&lt;/code&gt;&lt;/strong&gt; works, and on a disposable machine that is fine. On a laptop or server you care about, it reintroduces the exact apt-versus-pip conflicts PEP 668 prevents.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: every shortcut here trades a one-minute fix now for an unexplainable breakage of an OS tool later.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Which tool fits which job?
&lt;/h2&gt;

&lt;p&gt;The right choice depends on what you are installing, not on personal taste.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Best fit&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Dependencies for a project you are developing&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;venv&lt;/code&gt; (or &lt;code&gt;uv venv&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;One isolated environment per project, reproducible from a requirements file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A CLI tool you just want on your PATH (e.g. a formatter, &lt;code&gt;httpie&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pipx&lt;/code&gt; or &lt;code&gt;uv tool install&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Each app gets its own hidden environment, the command lands on PATH&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Many projects, slow installs, lockfile wanted&lt;/td&gt;
&lt;td&gt;&lt;code&gt;uv&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fast resolver and installer, handles Python versions too&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docker image or CI job that is thrown away&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;venv&lt;/code&gt;, or the override flag&lt;/td&gt;
&lt;td&gt;The OS-protection argument is weaker in a disposable root filesystem&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A library packaged by your distro&lt;/td&gt;
&lt;td&gt;&lt;code&gt;apt install python3-&amp;lt;name&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Managed and patched by the OS, but often older&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: libraries go in a project venv, applications go in an isolated tool environment, and neither goes in the system interpreter.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you set up a venv correctly (including the Debian gotcha)?
&lt;/h2&gt;

&lt;p&gt;The standard library route needs no extra tools:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv
&lt;span class="nb"&gt;source&lt;/span&gt; .venv/bin/activate
pip &lt;span class="nb"&gt;install &lt;/span&gt;requests
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Debian and Ubuntu you may hit a second error first: &lt;code&gt;The virtual environment was not created successfully because ensurepip is not available&lt;/code&gt;. Debian splits &lt;code&gt;venv&lt;/code&gt; into its own package, so run &lt;code&gt;sudo apt install python3-venv&lt;/code&gt; and retry.&lt;/p&gt;

&lt;p&gt;Do not skip &lt;code&gt;source .venv/bin/activate&lt;/code&gt;. If you forget, &lt;code&gt;pip&lt;/code&gt; resolves to the system one and you are back at the original error. When in doubt, call the venv's interpreter explicitly: &lt;code&gt;.venv/bin/python -m pip install requests&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you want the managed version of this workflow, uv is the tool that creates the environment and installs from a requirements file in one fast step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv venv
uv pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A genuine drawback: &lt;code&gt;uv&lt;/code&gt; is a separate binary you have to install and trust, and its &lt;code&gt;uv pip&lt;/code&gt; interface follows pip closely but not identically, so edge cases in older requirements files can resolve differently than they did under pip. Test before you swap it into CI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: the venv is only protecting you if it is active or invoked by path; most "it still fails" reports are an unactivated shell.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you install command-line tools without polluting anything?
&lt;/h2&gt;

&lt;p&gt;For applications, &lt;code&gt;pipx&lt;/code&gt; creates one isolated environment per tool and exposes only its executables:&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="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;pipx   &lt;span class="c"&gt;# or: brew install pipx&lt;/span&gt;
pipx ensurepath
pipx &lt;span class="nb"&gt;install &lt;/span&gt;httpie
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you want the one that handles isolated tool installs without a separate package from your distro, &lt;code&gt;uv tool install httpie&lt;/code&gt; does the same job with the same one-environment-per-app model.&lt;/p&gt;

&lt;p&gt;The limitation of both: they are for applications. If you &lt;code&gt;pipx install&lt;/code&gt; a library and then &lt;code&gt;import&lt;/code&gt; it in your own script, it will not be importable, because the library lives in its hidden environment. That is by design, and it is the most common confusion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: if you will type its name in a terminal, use pipx or uv tool; if you will &lt;code&gt;import&lt;/code&gt; it, use a venv.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When is &lt;code&gt;--break-system-packages&lt;/code&gt; acceptable?
&lt;/h2&gt;

&lt;p&gt;In a Dockerfile or CI runner that is rebuilt from scratch every time, there is no OS tool left to protect, and a venv adds a layer for little benefit. Setting &lt;code&gt;ENV PIP_BREAK_SYSTEM_PACKAGES=1&lt;/code&gt; in the image is a reasonable, explicit choice there. Even so, a venv in the image keeps the same code path you use locally, which makes "works on my machine" bugs rarer. Choose the override flag knowingly, not because a forum answer said so.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: the override flag is a tool for disposable filesystems, not for your daily laptop.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How do I fix "error: externally-managed-environment" on Ubuntu or Debian?
&lt;/h3&gt;

&lt;p&gt;Create a virtual environment with &lt;code&gt;python3 -m venv .venv&lt;/code&gt;, activate it, and run pip inside it. If venv creation fails, install &lt;code&gt;python3-venv&lt;/code&gt; with apt first.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is &lt;code&gt;pip install --break-system-packages&lt;/code&gt; safe?
&lt;/h3&gt;

&lt;p&gt;It is safe on a throwaway container or CI runner. On a machine where apt manages Python tools, it can overwrite files that OS utilities depend on and cause breakage that is hard to trace.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I use pipx or uv to install Python command-line tools?
&lt;/h3&gt;

&lt;p&gt;Either works: both give each application its own isolated environment and put its command on your PATH. Choose &lt;code&gt;uv tool&lt;/code&gt; if you already use uv for projects, otherwise &lt;code&gt;pipx&lt;/code&gt; is available from most package managers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;Use a venv (or &lt;code&gt;uv venv&lt;/code&gt;) for anything you import, &lt;code&gt;pipx&lt;/code&gt; or &lt;code&gt;uv tool install&lt;/code&gt; for anything you run from a terminal, and &lt;code&gt;apt install python3-&amp;lt;name&amp;gt;&lt;/code&gt; only when you want the distro-maintained version. Treat &lt;code&gt;--break-system-packages&lt;/code&gt; as a Docker-and-CI setting. If you are on a team, put the one-line environment setup in the README so nobody rediscovers this error on day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/stop-guessing-your-webhook-dedup-ttl-derive-it-from-the-delivery-contract-1978"&gt;Stop Guessing Your Webhook Dedup TTL: Derive It From the Delivery Contract&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/docker-image-works-on-your-mac-but-fails-on-the-server-fixing-exec-format-error-for-good-dag"&gt;Docker Image Works on Your Mac but Fails on the Server: Fixing exec format error for Good&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/cursor-vs-github-copilot-vs-windsurf-which-ai-editor-fits-how-you-actually-work-1ih9"&gt;Cursor vs GitHub Copilot vs Windsurf: Which AI Editor Fits How You Actually Work?&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>tutorial</category>
      <category>tooling</category>
      <category>devops</category>
    </item>
    <item>
      <title>Git Hooks Not Running for Teammates? Husky vs Lefthook vs pre-commit</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Sat, 31 Oct 2026 17:04:47 +0000</pubDate>
      <link>https://dev.to/libme/git-hooks-not-running-for-teammates-husky-vs-lefthook-vs-pre-commit-g6f</link>
      <guid>https://dev.to/libme/git-hooks-not-running-for-teammates-husky-vs-lefthook-vs-pre-commit-g6f</guid>
      <description>&lt;p&gt;Git hooks don't run for teammates because &lt;code&gt;.git/hooks/&lt;/code&gt; is never committed, so every clone starts with no hooks at all. Husky, lefthook, and pre-commit all solve this the same way: the hook definitions live in the repo, and an install step points Git at them. Pick husky if your repo is already a Node project, lefthook if you want one fast binary with no runtime dependency, and pre-commit if your team is Python-heavy or wants a large catalog of ready-made hooks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why do hooks work on my machine but not on a teammate's?
&lt;/h2&gt;

&lt;p&gt;The symptom is boring and easy to miss: a teammate pushes code that your pre-commit lint would have rejected, and nobody sees an error, because nothing ran. There is no failure to find.&lt;/p&gt;

&lt;p&gt;Git only executes hooks from the directory in &lt;code&gt;core.hooksPath&lt;/code&gt;, which defaults to &lt;code&gt;.git/hooks&lt;/code&gt;. That directory is inside &lt;code&gt;.git&lt;/code&gt;, so it is not part of the repository content. Anything you put there stays on your machine. You can check what a clone is actually using:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git config &lt;span class="nt"&gt;--get&lt;/span&gt; core.hooksPath   &lt;span class="c"&gt;# empty output means the default .git/hooks&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; .git/hooks | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s1"&gt;'\.sample$'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the second command prints nothing, no hooks are installed in that clone. A fresh clone always looks like this.&lt;/p&gt;

&lt;p&gt;The first fix people try is committing a &lt;code&gt;scripts/hooks/&lt;/code&gt; folder and asking everyone to symlink it. That works until someone forgets, which is the whole problem. The tools below automate the one step that humans skip.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: hooks are local state, so any hook setup that depends on a person remembering to run a command will eventually be missing on someone's machine.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do husky, lefthook, and pre-commit install themselves?
&lt;/h2&gt;

&lt;p&gt;All three need an install step to run once per clone. They differ in how automatic they can make it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Husky&lt;/th&gt;
&lt;th&gt;Lefthook&lt;/th&gt;
&lt;th&gt;pre-commit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Runtime&lt;/td&gt;
&lt;td&gt;Node&lt;/td&gt;
&lt;td&gt;Single Go binary&lt;/td&gt;
&lt;td&gt;Python&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Config&lt;/td&gt;
&lt;td&gt;Shell scripts in &lt;code&gt;.husky/&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;lefthook.yml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.pre-commit-config.yaml&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auto-install trigger&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;prepare&lt;/code&gt; script in &lt;code&gt;package.json&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;lefthook install&lt;/code&gt; (a &lt;code&gt;prepare&lt;/code&gt; script or a manual step)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pre-commit install&lt;/code&gt; (manual or a setup script)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Staged-files filtering&lt;/td&gt;
&lt;td&gt;Add a tool such as lint-staged&lt;/td&gt;
&lt;td&gt;Built in (&lt;code&gt;{staged_files}&lt;/code&gt;, &lt;code&gt;glob&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Built in (hooks receive matching files)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Parallel execution&lt;/td&gt;
&lt;td&gt;Not built in&lt;/td&gt;
&lt;td&gt;Built in (&lt;code&gt;parallel: true&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Not by default for different hooks; it runs hooks serially&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hook catalog&lt;/td&gt;
&lt;td&gt;Bring your own commands&lt;/td&gt;
&lt;td&gt;Bring your own commands&lt;/td&gt;
&lt;td&gt;Large community catalog of hook repos&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Husky's trick is that &lt;code&gt;npm install&lt;/code&gt; runs the &lt;code&gt;prepare&lt;/code&gt; script automatically. As of its v9 line, setup is &lt;code&gt;npx husky init&lt;/code&gt;, which creates a &lt;code&gt;.husky/&lt;/code&gt; directory and a &lt;code&gt;prepare&lt;/code&gt; entry that runs &lt;code&gt;husky&lt;/code&gt;. That command sets &lt;code&gt;core.hooksPath&lt;/code&gt; to a path inside &lt;code&gt;.husky/&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scripts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"prepare"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"husky"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# .husky/pre-commit&lt;/span&gt;
npx lint-staged
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lefthook looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lefthook.yml&lt;/span&gt;
&lt;span class="na"&gt;pre-commit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;parallel&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;commands&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;lint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;glob&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.{js,ts,tsx}"&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx eslint {staged_files}&lt;/span&gt;
    &lt;span class="na"&gt;format-check&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;glob&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.{js,ts,tsx,json,md}"&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx prettier --check {staged_files}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And pre-commit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .pre-commit-config.yaml&lt;/span&gt;
&lt;span class="na"&gt;repos&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;repo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://github.com/pre-commit/pre-commit-hooks&lt;/span&gt;
    &lt;span class="na"&gt;rev&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v4.6.0&lt;/span&gt;
    &lt;span class="na"&gt;hooks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;trailing-whitespace&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;end-of-file-fixer&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;check-merge-conflict&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check the &lt;code&gt;rev&lt;/code&gt; against the project's latest tag before you copy it; &lt;code&gt;pre-commit autoupdate&lt;/code&gt; rewrites it for you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: the real difference between these tools is not the hook syntax, it's what triggers the install on a fresh clone.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What goes wrong even after you adopt one?
&lt;/h2&gt;

&lt;p&gt;These are the failure modes I'd check first when "we have husky, but it still isn't running."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The install step never ran.&lt;/strong&gt; Husky depends on &lt;code&gt;prepare&lt;/code&gt;. If someone installs with &lt;code&gt;npm ci --ignore-scripts&lt;/code&gt;, or a package manager is configured to skip lifecycle scripts, &lt;code&gt;prepare&lt;/code&gt; doesn't run and no hooks get wired up. The same applies to Docker images and CI, where this is usually what you want, but it also applies to a laptop with a hardened npm config. Verify with &lt;code&gt;git config --get core.hooksPath&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Another tool already owns &lt;code&gt;core.hooksPath&lt;/code&gt;.&lt;/strong&gt; pre-commit refuses to install when it's set, with a message along the lines of "Cowardly refusing to install hooks with &lt;code&gt;core.hooksPath&lt;/code&gt; set." If you're migrating from husky to pre-commit, unset it first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git config &lt;span class="nt"&gt;--unset-all&lt;/span&gt; core.hooksPath
pre-commit &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Git GUIs and PATH problems.&lt;/strong&gt; A hook that calls &lt;code&gt;npx&lt;/code&gt; or &lt;code&gt;node&lt;/code&gt; can fail under a GUI client that launches with a different &lt;code&gt;PATH&lt;/code&gt; than your shell, especially with version managers like nvm. Lefthook's single binary sidesteps part of this, though the commands it runs still need their own tools on &lt;code&gt;PATH&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Someone passed &lt;code&gt;--no-verify&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;git commit --no-verify&lt;/code&gt; skips pre-commit and commit-msg hooks by design. This is not a bug and you can't prevent it locally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: if hooks stop running, check &lt;code&gt;core.hooksPath&lt;/code&gt; and whether the install script executed, in that order, before blaming the hook tool.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Should hooks be the only enforcement?
&lt;/h2&gt;

&lt;p&gt;No. Hooks are a convenience for fast feedback, not a gate. Anyone can skip them, and a contributor on a fork has never run your install step. Run the same checks in CI so the merge button is the actual enforcement.&lt;/p&gt;

&lt;p&gt;pre-commit makes this easy: &lt;code&gt;pre-commit run --all-files&lt;/code&gt; in a CI job runs the same hook set you run locally. With husky or lefthook, call the same underlying commands (lint, format check) directly from your CI workflow rather than invoking the hook runner. Keep the local hook fast, ideally only the staged files, and let CI do the slow, complete checks.&lt;/p&gt;

&lt;p&gt;A drawback worth stating for each: husky needs a Node toolchain and adds nothing for non-JS repos; lefthook has a smaller ecosystem of shared recipes, so you write more commands yourself; pre-commit creates its own isolated environments for hooks, which makes the first run slow and adds cache management to CI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: treat local hooks as a speed-up and CI as the rule, because only CI applies to everyone.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why are my git hooks not running after cloning a repo?&lt;/strong&gt;&lt;br&gt;
Hooks live in &lt;code&gt;.git/hooks&lt;/code&gt;, which is not versioned, so a fresh clone has none. You need a tool or script that installs them, such as &lt;code&gt;npm install&lt;/code&gt; triggering husky's &lt;code&gt;prepare&lt;/code&gt;, &lt;code&gt;lefthook install&lt;/code&gt;, or &lt;code&gt;pre-commit install&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I check whether a hook is installed?&lt;/strong&gt;&lt;br&gt;
Run &lt;code&gt;git config --get core.hooksPath&lt;/code&gt; and list the target directory (&lt;code&gt;.git/hooks&lt;/code&gt; if the config is empty). A hook file must exist there without the &lt;code&gt;.sample&lt;/code&gt; suffix and be executable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I enforce git hooks for everyone on the team?&lt;/strong&gt;&lt;br&gt;
Not locally, since &lt;code&gt;--no-verify&lt;/code&gt; and skipped install steps bypass them. Run the same checks in CI and require them to pass before merging.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;If your repo already has a &lt;code&gt;package.json&lt;/code&gt;, husky plus a staged-files runner is the least friction, because &lt;code&gt;npm install&lt;/code&gt; wires it up for free. If you want parallel hooks and no language runtime, lefthook is the one to try. If your team writes Python or wants ready-made hooks for YAML, secrets, and merge conflicts, pre-commit is the strongest fit. Whichever you choose, mirror the checks in CI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/postgres-deadlock-detected-fix-the-lock-order-first-then-add-a-retry-3ip1"&gt;Postgres "deadlock detected": Fix the Lock Order First, Then Add a Retry&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/docker-compose-dependson-doesnt-wait-for-the-database-fix-connection-refused-on-startup-4jn6"&gt;Docker Compose depends_on Doesn't Wait for the Database: Fix "Connection Refused" on Startup&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/users-randomly-logged-out-your-redis-eviction-policy-is-deleting-sessions-4722"&gt;Users Randomly Logged Out? Your Redis Eviction Policy Is Deleting Sessions&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>tooling</category>
      <category>productivity</category>
      <category>devops</category>
      <category>cicd</category>
    </item>
    <item>
      <title>GitHub Actions Cache Never Hits on Pull Requests: Branch Scope, Immutable Keys, and restore-keys</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Fri, 30 Oct 2026 21:37:17 +0000</pubDate>
      <link>https://dev.to/libme/github-actions-cache-never-hits-on-pull-requests-branch-scope-immutable-keys-and-restore-keys-59bb</link>
      <guid>https://dev.to/libme/github-actions-cache-never-hits-on-pull-requests-branch-scope-immutable-keys-and-restore-keys-59bb</guid>
      <description>&lt;p&gt;If &lt;code&gt;actions/cache&lt;/code&gt; reports "Cache not found" on every pull request even though you can see caches in the repo's Actions tab, the cause is almost always one of three things: the cache was saved on a branch your PR cannot read, the key is identical to an existing one (caches are immutable, so the new content is never saved), or your &lt;code&gt;restore-keys&lt;/code&gt; prefix does not match anything. All three are fixable in a few lines of YAML, and you can diagnose which one you have from the job log alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the failure actually look like?
&lt;/h2&gt;

&lt;p&gt;The symptoms I see most often in logs (wording as of late 2026; the exact text may change between releases):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Cache not found for input keys: Linux-npm-3f1a9c..., Linux-npm-
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and, in the post-job step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Warning: Failed to save: Unable to reserve cache with key Linux-npm-3f1a9c..., another job may be creating this cache.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first line means a restore found nothing. The second means a save was refused because that exact key already exists. People often see both in the same repo and conclude the feature is flaky. It is not. It follows three deterministic rules.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: a cache miss is never random; it is one of three rules, and the log tells you which.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why can't my PR see the cache that main built?
&lt;/h2&gt;

&lt;p&gt;Caches are scoped to a branch (more precisely, to a git ref). According to GitHub's documentation, a workflow run can restore caches created on its own ref, on the base branch of a pull request, and on the default branch. It cannot read caches created by a sibling branch or a different PR.&lt;/p&gt;

&lt;p&gt;That has two practical consequences:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;If your workflow only runs on &lt;code&gt;pull_request&lt;/code&gt; and never on pushes to &lt;code&gt;main&lt;/code&gt;, then nothing ever populates the default-branch cache. Every PR starts cold, forever, and each PR writes a cache that no other PR can read.&lt;/li&gt;
&lt;li&gt;Two feature branches never share caches with each other. They both fall back to &lt;code&gt;main&lt;/code&gt;'s cache, if one exists.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The fix is to run the same cache-saving job on pushes to the default branch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A related trap: pull requests from forks run with a restricted token, and the cache they write is scoped to the PR merge ref, so don't expect fork PRs to warm anything for others.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: if main never runs the cache step, no PR can ever get a warm start from it.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does the cache never update when my lockfile changes... or doesn't?
&lt;/h2&gt;

&lt;p&gt;Caches are immutable. Once a key is saved, it cannot be overwritten. This bites in two opposite ways.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key too static.&lt;/strong&gt; A key like &lt;code&gt;deps-${{ runner.os }}&lt;/code&gt; is saved once, then every later run restores it (an exact hit) and the post-step refuses to save new content. Your cache slowly goes stale and installs get slower, while the log says "Cache hit".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key too volatile.&lt;/strong&gt; A key containing &lt;code&gt;${{ github.sha }}&lt;/code&gt; is unique on every commit, so every run is a miss on the primary key and writes a fresh multi-hundred-MB entry. That floods the repository's cache quota and evicts the useful entries.&lt;/p&gt;

&lt;p&gt;The standard pattern is a key derived from the file that determines the content, with a prefix fallback:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/cache@v4&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;~/.npm&lt;/span&gt;
    &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}&lt;/span&gt;
    &lt;span class="na"&gt;restore-keys&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;${{ runner.os }}-npm-&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the lockfile changes, the primary key misses, the prefix in &lt;code&gt;restore-keys&lt;/code&gt; restores the most recent older cache, &lt;code&gt;npm ci&lt;/code&gt; fills in the difference, and the post-step saves a new entry under the new key.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: hash the lockfile, not the commit, and let restore-keys carry you across lockfile changes.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do restore-keys and cache-hit actually behave?
&lt;/h2&gt;

&lt;p&gt;Two details trip people up.&lt;/p&gt;

&lt;p&gt;First, &lt;code&gt;restore-keys&lt;/code&gt; are tried in order, and each is treated as a prefix. The first prefix that matches picks the most recently created cache among the matches. Put the most specific prefix first.&lt;/p&gt;

&lt;p&gt;Second, the &lt;code&gt;cache-hit&lt;/code&gt; output is &lt;code&gt;'true'&lt;/code&gt; only for an exact match on the primary key. A restore via &lt;code&gt;restore-keys&lt;/code&gt; leaves it &lt;code&gt;'false'&lt;/code&gt; even though files were restored. If you gate &lt;code&gt;npm ci&lt;/code&gt; on &lt;code&gt;cache-hit != 'true'&lt;/code&gt; you will still run installs after a partial restore, which is usually what you want. If you gate it the other way, you will skip installs after a partial restore and ship a stale &lt;code&gt;node_modules&lt;/code&gt;. Gating on &lt;code&gt;cache-hit&lt;/code&gt; for something like &lt;code&gt;node_modules&lt;/code&gt; is risky for exactly this reason; caching the package download directory (&lt;code&gt;~/.npm&lt;/code&gt;) and always running &lt;code&gt;npm ci&lt;/code&gt; is the safer shape.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Primary key&lt;/th&gt;
&lt;th&gt;restore-keys&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Lockfile unchanged, same branch lineage&lt;/td&gt;
&lt;td&gt;exact match&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;cache-hit: true&lt;/code&gt;, no save&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lockfile changed&lt;/td&gt;
&lt;td&gt;miss&lt;/td&gt;
&lt;td&gt;prefix match on older cache&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;cache-hit: false&lt;/code&gt;, files restored, new entry saved&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;First PR in a repo, main never cached&lt;/td&gt;
&lt;td&gt;miss&lt;/td&gt;
&lt;td&gt;no match&lt;/td&gt;
&lt;td&gt;cold install, entry saved to PR ref only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Key includes &lt;code&gt;github.sha&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;always miss&lt;/td&gt;
&lt;td&gt;prefix match&lt;/td&gt;
&lt;td&gt;works, but one new entry per run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same key already exists&lt;/td&gt;
&lt;td&gt;exact match&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;"Unable to reserve cache" warning on save&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: &lt;code&gt;cache-hit&lt;/code&gt; means "exact key", not "something was restored", so don't use it to skip installs.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What about eviction and size limits?
&lt;/h2&gt;

&lt;p&gt;As of late 2026, GitHub's documented defaults are: caches not accessed for 7 days are removed, and the repository total is capped (10 GB by default); when you exceed it, the oldest caches by last access are evicted first. Check the current numbers in the GitHub docs, since these have been adjustable. The practical effect: if you generate one big cache per PR, they can push out the default-branch cache that every PR depended on, and the next PR goes cold for no visible reason.&lt;/p&gt;

&lt;p&gt;You can inspect what exists with the GitHub CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh cache list &lt;span class="nt"&gt;--limit&lt;/span&gt; 50
gh cache list &lt;span class="nt"&gt;--sort&lt;/span&gt; size_in_bytes &lt;span class="nt"&gt;--order&lt;/span&gt; desc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the list is full of &lt;code&gt;refs/pull/*/merge&lt;/code&gt; entries and the &lt;code&gt;refs/heads/main&lt;/code&gt; entry is missing or old, you have found your problem.&lt;/p&gt;

&lt;p&gt;To stop PRs from writing caches at all while still reading main's, split restore and save:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/cache/restore@v4&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;~/.npm&lt;/span&gt;
    &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}&lt;/span&gt;
    &lt;span class="na"&gt;restore-keys&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;${{ runner.os }}-npm-&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/cache/save@v4&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.ref == 'refs/heads/main'&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;~/.npm&lt;/span&gt;
    &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only &lt;code&gt;main&lt;/code&gt; saves, so PR caches never crowd out the shared one. The tradeoff: a PR that changes dependencies pays the full install every time until it merges. For most teams that is the right trade.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: one writer (the default branch) and many readers (PRs) is the cache topology that stays warm.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When is a built-in cache not enough?
&lt;/h2&gt;

&lt;p&gt;The built-in cache is fine for package downloads. It is a poor fit for Docker layers (large, and the 10 GB quota fills fast) and for build outputs that are expensive to restore compared with rebuilding. For Docker, BuildKit's registry or GitHub Actions cache exporter is the usual route. If you would rather not manage cache quotas at all, a hosted runner service with persistent disks, such as Depot or Blacksmith, is the kind of product that takes this problem off your plate, at the cost of another vendor and bill to evaluate. I would only reach for that after measuring that cache restore time actually dominates your pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why does GitHub Actions say "Cache not found" when the cache exists?&lt;/strong&gt;&lt;br&gt;
Caches are scoped by branch. A PR can read its own ref, its base branch, and the default branch, but not caches saved by other branches or PRs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I overwrite an existing GitHub Actions cache?&lt;/strong&gt;&lt;br&gt;
No. Caches are immutable per key. Change the key (for example by hashing your lockfile) or delete the old entry with &lt;code&gt;gh cache delete&lt;/code&gt; before saving again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does &lt;code&gt;cache-hit&lt;/code&gt; be true when restore-keys matched?&lt;/strong&gt;&lt;br&gt;
No. &lt;code&gt;cache-hit&lt;/code&gt; is &lt;code&gt;'true'&lt;/code&gt; only when the primary key matched exactly; a &lt;code&gt;restore-keys&lt;/code&gt; match restores files but reports &lt;code&gt;'false'&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;If your cache never hits on PRs, check three things in order: does &lt;code&gt;main&lt;/code&gt; run and save the cache, is your key derived from the lockfile rather than a static string or a commit SHA, and do your &lt;code&gt;restore-keys&lt;/code&gt; prefixes match the primary key's prefix. For most small teams, a single saver on the default branch plus restore-only PRs is the simplest setup that stays fast. Reach for external cache services only once you have measured that restore time, not install logic, is the bottleneck.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/hotstandbyfeedback-for-a-reporting-replica-query-cancellations-or-primary-bloat-mek"&gt;hot_standby_feedback for a Reporting Replica: Query Cancellations or Primary Bloat?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/every-deploy-throws-a-few-502s-where-graceful-shutdown-actually-breaks-300g"&gt;Every Deploy Throws a Few 502s: Where Graceful Shutdown Actually Breaks&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/netlify-pros-and-cons-when-its-the-right-host-and-when-youll-outgrow-it-2ka1"&gt;Netlify Pros and Cons: When It's the Right Host, and When You'll Outgrow It&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>github</category>
      <category>cicd</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Docker Compose depends_on Doesn't Wait for the Database: Fix "Connection Refused" on Startup</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Thu, 29 Oct 2026 22:03:53 +0000</pubDate>
      <link>https://dev.to/libme/docker-compose-dependson-doesnt-wait-for-the-database-fix-connection-refused-on-startup-4jn6</link>
      <guid>https://dev.to/libme/docker-compose-dependson-doesnt-wait-for-the-database-fix-connection-refused-on-startup-4jn6</guid>
      <description>&lt;p&gt;&lt;strong&gt;Short answer:&lt;/strong&gt; plain &lt;code&gt;depends_on&lt;/code&gt; only waits until the dependency's container has &lt;em&gt;started&lt;/em&gt;, not until the process inside is ready. To make the app wait for Postgres, give the database a &lt;code&gt;healthcheck&lt;/code&gt; and use &lt;code&gt;depends_on&lt;/code&gt; with &lt;code&gt;condition: service_healthy&lt;/code&gt;. Then add a connection retry in the app anyway, because a healthcheck narrows the race but cannot close it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the failure look like?
&lt;/h2&gt;

&lt;p&gt;You run &lt;code&gt;docker compose up&lt;/code&gt; on a fresh checkout. The database container starts, the API container starts a moment later, and the API dies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;api-1  | psycopg2.OperationalError: connection to server at "db" (172.19.0.2), port 5432 failed: Connection refused
api-1  |     Is the server running on that host and accepting TCP/IP connections?
db-1   | PostgreSQL init process complete; ready for start up.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it again and it works, because the data volume now exists and Postgres boots in a second instead of ten. That "works on the second try" pattern is the signature of a startup race. Teammates on a clean clone hit it, and CI hits it every time.&lt;/p&gt;

&lt;p&gt;The compose file that causes it usually looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;example&lt;/span&gt;
  &lt;span class="na"&gt;api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;db&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Short-form &lt;code&gt;depends_on&lt;/code&gt; controls start order. Compose starts &lt;code&gt;db&lt;/code&gt;, sees the container is running, and starts &lt;code&gt;api&lt;/code&gt;. It never asks whether Postgres is listening.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: "container running" and "service ready" are different states, and short-form &lt;code&gt;depends_on&lt;/code&gt; only knows the first.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you make Compose wait for Postgres?
&lt;/h2&gt;

&lt;p&gt;Define a healthcheck on the database and make the dependency conditional on it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;example&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pg_isready&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-h&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;127.0.0.1&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;app&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;app"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;
      &lt;span class="na"&gt;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;

  &lt;span class="na"&gt;api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compose now holds &lt;code&gt;api&lt;/code&gt; in "created" state until &lt;code&gt;db&lt;/code&gt; reports healthy. If the database never becomes healthy within the retries, &lt;code&gt;docker compose up&lt;/code&gt; exits with &lt;code&gt;dependency failed to start: container db is unhealthy&lt;/code&gt;, which is a far better error than a stack trace from your app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: the long-form &lt;code&gt;depends_on&lt;/code&gt; with &lt;code&gt;condition: service_healthy&lt;/code&gt; turns "start order" into "readiness order".&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does the healthcheck pass too early on a fresh volume?
&lt;/h2&gt;

&lt;p&gt;This is the part most tutorials skip, and it bit me. The official &lt;code&gt;postgres&lt;/code&gt; image, on the very first start with an empty data directory, runs its init scripts against a &lt;em&gt;temporary&lt;/em&gt; server. As I understand the entrypoint script, that temporary server is configured to listen only on the Unix socket, not on TCP. When it finishes, it shuts down and the real server starts.&lt;/p&gt;

&lt;p&gt;A healthcheck of just &lt;code&gt;pg_isready -U app&lt;/code&gt; talks to the local Unix socket by default. During the init phase it can report "accepting connections" even though no TCP listener exists yet, and the server is about to restart. The result is a database that looks healthy, an app that connects over TCP to &lt;code&gt;db:5432&lt;/code&gt;, and then either &lt;code&gt;Connection refused&lt;/code&gt; or &lt;code&gt;the database system is shutting down&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Adding &lt;code&gt;-h 127.0.0.1&lt;/code&gt; forces the check over TCP, which only succeeds once the final server is up. That one flag is why the example above uses it. If you use the socket form and only see failures on first boot (never on later boots with an existing volume), this is the cause.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Healthcheck command&lt;/th&gt;
&lt;th&gt;Passes during init temp server?&lt;/th&gt;
&lt;th&gt;Safe to gate the app on?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pg_isready -U app&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Can, via Unix socket&lt;/td&gt;
&lt;td&gt;No, flaky on first boot&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pg_isready -h 127.0.0.1 -U app -d app&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No, needs TCP listener&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;psql -h 127.0.0.1 -U app -d app -c 'select 1'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No, and proves auth works&lt;/td&gt;
&lt;td&gt;Yes, slightly heavier&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: check readiness over the same transport your app will use, or the healthcheck can lie on first boot.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What about migrations that must finish before the app starts?
&lt;/h2&gt;

&lt;p&gt;Waiting for the database is half of the problem. If a migration job must complete before the API serves traffic, Compose has a condition for that too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;migrate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;python"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-m"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alembic"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;upgrade"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;head"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;

  &lt;span class="na"&gt;api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;migrate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_completed_successfully&lt;/span&gt;
      &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;service_completed_successfully&lt;/code&gt; waits for the dependency to exit with code 0. If the migration fails, the API never starts. That is the behavior you want in a dev stack and in CI. (Alembic is just the example here; any one-shot command works.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: model one-shot setup work as its own service and gate on &lt;code&gt;service_completed_successfully&lt;/code&gt;, instead of chaining shell commands in the app's entrypoint.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Do you still need retries in the app?
&lt;/h2&gt;

&lt;p&gt;Yes. The healthcheck only governs the &lt;em&gt;initial&lt;/em&gt; &lt;code&gt;docker compose up&lt;/code&gt;. It does nothing when Postgres restarts later, when you run the app outside Compose, or in production on an orchestrator that has its own readiness semantics. An app that exits on its first failed connect is fragile everywhere except your laptop.&lt;/p&gt;

&lt;p&gt;A minimal retry loop in Python:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;psycopg2&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;connect_with_retry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dsn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base_delay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;psycopg2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dsn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;connect_timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;psycopg2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OperationalError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;raise&lt;/span&gt;
            &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base_delay&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep the loop bounded and let the final error surface, so a genuinely broken config fails loudly instead of retrying forever. Pair it with &lt;code&gt;restart: on-failure&lt;/code&gt; on the service if you want Compose to give the app another chance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which approach should you pick?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Handles first-boot race&lt;/th&gt;
&lt;th&gt;Handles later DB restarts&lt;/th&gt;
&lt;th&gt;Main drawback&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Short &lt;code&gt;depends_on&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Only start order&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;service_healthy&lt;/code&gt; + healthcheck&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Compose-only; healthcheck must be written correctly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;wait-for-it&lt;/code&gt; style script in entrypoint&lt;/td&gt;
&lt;td&gt;Mostly&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Extra script to maintain; TCP-open is not the same as ready&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App-level retry with backoff&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Needs a code change in every service&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you live in Docker Compose for local development and CI, Docker Compose's &lt;code&gt;service_healthy&lt;/code&gt; condition is the cleanest first fix because it needs no code changes. The durable answer is both layers: the healthcheck for a clean startup log, and retries in the app for everything the healthcheck cannot see. A drawback of leaning only on the healthcheck: it adds seconds to every cold start, and a too-tight &lt;code&gt;retries&lt;/code&gt; value will mark a slow CI runner's database as unhealthy.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why does docker compose depends_on not wait for the database?&lt;/strong&gt;&lt;br&gt;
Short-form &lt;code&gt;depends_on&lt;/code&gt; only waits for the dependency container to start, not for the process inside to be ready. Use &lt;code&gt;condition: service_healthy&lt;/code&gt; with a healthcheck on the database.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the right pg_isready healthcheck for the Postgres Docker image?&lt;/strong&gt;&lt;br&gt;
Use &lt;code&gt;pg_isready -h 127.0.0.1 -U &amp;lt;user&amp;gt; -d &amp;lt;db&amp;gt;&lt;/code&gt;. Forcing TCP avoids passing during the image's first-boot init phase, when only a temporary socket-only server is running.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does depends_on condition service_healthy work with docker compose v2?&lt;/strong&gt;&lt;br&gt;
Yes. It is part of the Compose Specification supported by Docker Compose v2, as of late 2026, and it is not available in the legacy v1 file format with &lt;code&gt;version: "2"&lt;/code&gt;/&lt;code&gt;"3"&lt;/code&gt; semantics.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;If your stack runs in Docker Compose, add a TCP-based &lt;code&gt;pg_isready&lt;/code&gt; healthcheck and gate dependents on &lt;code&gt;service_healthy&lt;/code&gt;, then put one-shot migrations in their own service with &lt;code&gt;service_completed_successfully&lt;/code&gt;. Keep a bounded retry with backoff in the app so restarts and non-Compose environments behave too. Skip &lt;code&gt;wait-for-it&lt;/code&gt; scripts unless you cannot use healthchecks, since they only prove a port is open.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/chatgpt-free-vs-go-vs-plus-vs-pro-which-tier-should-a-coding-beginner-actually-pay-for-jke"&gt;ChatGPT Free vs Go vs Plus vs Pro: Which Tier Should a Coding Beginner Actually Pay For?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/terraform-vs-opentofu-vs-pulumi-which-one-should-a-small-team-actually-commit-to-15b0"&gt;Terraform vs OpenTofu vs Pulumi: Which One Should a Small Team Actually Commit To?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/monorepo-or-polyrepo-for-a-three-person-startup-a-decision-framework-51nd"&gt;Monorepo or Polyrepo for a Three-Person Startup? A Decision Framework&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>docker</category>
      <category>postgres</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>httpx.PoolTimeout: Why Your Async Python Client Runs Out of Connections (and How to Fix It)</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Wed, 28 Oct 2026 15:35:07 +0000</pubDate>
      <link>https://dev.to/libme/httpxpooltimeout-why-your-async-python-client-runs-out-of-connections-and-how-to-fix-it-2hm0</link>
      <guid>https://dev.to/libme/httpxpooltimeout-why-your-async-python-client-runs-out-of-connections-and-how-to-fix-it-2hm0</guid>
      <description>&lt;p&gt;&lt;code&gt;httpx.PoolTimeout&lt;/code&gt; means a request waited for a free connection in the client's pool and never got one before the pool timeout expired. The server is usually fine. The cause is almost always one of three things: you fire more concurrent requests than the pool allows, you leak responses by never closing them, or you create a client per request and defeat pooling altogether. The fixes are a semaphore, &lt;code&gt;async with&lt;/code&gt; on streams, and one shared client.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does PoolTimeout actually mean?
&lt;/h2&gt;

&lt;p&gt;The traceback looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;httpx.PoolTimeout
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Often it has no message at all, which is why people go hunting in the network layer. It is different from &lt;code&gt;ConnectTimeout&lt;/code&gt; (TCP handshake too slow) and &lt;code&gt;ReadTimeout&lt;/code&gt; (server too slow to send bytes). &lt;code&gt;PoolTimeout&lt;/code&gt; is purely local: the client has no idle connection to hand out and is already at its connection ceiling.&lt;/p&gt;

&lt;p&gt;As of httpx 0.2x, the defaults are a 5 second timeout for each of connect, read, write, and pool, and &lt;code&gt;Limits(max_connections=100, max_keepalive_connections=20)&lt;/code&gt;. Check your installed version's docs, since defaults are the kind of thing that gets tuned between releases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: PoolTimeout is a queueing symptom on your side of the wire, not a slow-server symptom.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why do I hit it? Three causes, in order of likelihood
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Unbounded fan-out.&lt;/strong&gt; &lt;code&gt;asyncio.gather&lt;/code&gt; over thousands of coroutines sharing one client means thousands of requests competing for 100 slots. Everything past the first 100 waits, and anything waiting longer than 5 seconds raises. With a slow upstream, this happens fast.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Leaked responses.&lt;/strong&gt; A streamed response holds its connection until the body is fully read or the response is closed. If you open a stream, hit an exception or an early &lt;code&gt;return&lt;/code&gt;, and never close it, that connection is gone from the pool permanently. After 100 leaks, every request times out, even though traffic looks low. This is the one that bites in production, because it only shows up hours after deploy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. A client per request.&lt;/strong&gt; Creating &lt;code&gt;httpx.AsyncClient()&lt;/code&gt; inside a handler gives you a fresh pool each time, a fresh TLS handshake each time, and sockets that linger until garbage collection. It rarely raises &lt;code&gt;PoolTimeout&lt;/code&gt;, but it causes the slow, flaky behavior people blame on the network.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I reproduce the leak?
&lt;/h2&gt;

&lt;p&gt;This script leaks on purpose against any reachable URL. It never closes the streamed responses, then tries a normal request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;

&lt;span class="n"&gt;URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://example.com/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;limits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Limits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_connections&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_keepalive_connections&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;5.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AsyncClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;limits&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;limits&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;leaked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build_request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="c1"&gt;# stream=True returns before the body is read; we never close it
&lt;/span&gt;            &lt;span class="n"&gt;leaked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PoolTimeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PoolTimeout: all 5 connections are held by unclosed responses&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;leaked&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;aclose&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tell in real systems is that the error rate does not track traffic. If &lt;code&gt;PoolTimeout&lt;/code&gt; climbs steadily and never recovers until restart, suspect a leak, not load.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: a leak looks like a pool that shrinks over time; overload looks like a pool that is full only during bursts.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I fix it?
&lt;/h2&gt;

&lt;p&gt;Cap concurrency below the pool size with a semaphore, and always close streams with a context manager:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;concurrency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;limits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Limits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;max_connections&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;concurrency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;max_keepalive_connections&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;concurrency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;10.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;30.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sem&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Semaphore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;concurrency&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AsyncClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;limits&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;limits&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;

        &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;sem&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;aiter_bytes&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
                        &lt;span class="k"&gt;pass&lt;/span&gt;
                    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gather&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="nf"&gt;one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter. The semaphore is acquired before the request starts, so waiting for a slot is not counted against any httpx timeout. And &lt;code&gt;async with client.stream(...)&lt;/code&gt; guarantees the connection is released even if the loop body raises.&lt;/p&gt;

&lt;p&gt;Resist the urge to just raise &lt;code&gt;max_connections&lt;/code&gt; to 10,000. That moves the queue from your process to the upstream, and many APIs will answer with 429s or reset connections instead. Pair this with the retry handling you would use anyway for rate limits.&lt;/p&gt;

&lt;h2&gt;
  
  
  httpx vs aiohttp vs requests: how do their pools compare?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Client&lt;/th&gt;
&lt;th&gt;Pool knob&lt;/th&gt;
&lt;th&gt;Default behavior when full&lt;/th&gt;
&lt;th&gt;Gotcha&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;httpx (async)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Limits(max_connections=...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Waits, then raises &lt;code&gt;PoolTimeout&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Unclosed streams hold slots forever&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;aiohttp&lt;/td&gt;
&lt;td&gt;&lt;code&gt;TCPConnector(limit=...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Waits indefinitely unless you set a total timeout&lt;/td&gt;
&lt;td&gt;No error at all, just a silent hang&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;requests&lt;/td&gt;
&lt;td&gt;&lt;code&gt;HTTPAdapter(pool_maxsize=...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Opens extra connections and discards them, logging "Connection pool is full, discarding connection"&lt;/td&gt;
&lt;td&gt;Only a warning, so thread pools degrade quietly&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I prefer httpx's behavior here. A loud &lt;code&gt;PoolTimeout&lt;/code&gt; is easier to alert on than aiohttp's silent wait. If you are choosing a client today and need HTTP/2 or a sync and async API from one library, httpx is the one that gives you both. The real drawback is that its per-request overhead is higher than aiohttp's in high-throughput fan-out. I have not benchmarked that myself, so measure with your own workload before switching for speed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: all three clients cap connections by default; they differ in whether you find out loudly (httpx), silently (aiohttp), or as a log line (requests).&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I confirm the fix?
&lt;/h2&gt;

&lt;p&gt;Log pool pressure instead of guessing. Count in-flight requests with a counter around the semaphore, and alert when it sits at the cap for long periods. If in-flight stays at the cap while throughput is flat, the upstream is the bottleneck. If in-flight creeps up while traffic is steady, you still have a leak, so search for every &lt;code&gt;client.send(..., stream=True)&lt;/code&gt; and every early &lt;code&gt;return&lt;/code&gt; inside a stream block.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is the default connection limit in httpx?
&lt;/h3&gt;

&lt;p&gt;As of recent 0.2x releases, &lt;code&gt;max_connections&lt;/code&gt; is 100 and &lt;code&gt;max_keepalive_connections&lt;/code&gt; is 20, with a 5 second pool timeout. Verify against the docs for your installed version.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I increase the pool timeout to fix PoolTimeout?
&lt;/h3&gt;

&lt;p&gt;Only if requests are legitimately bursty and short-lived. If the pool is exhausted by leaks, a longer timeout just makes each failure slower.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is it safe to share one httpx.AsyncClient across tasks?
&lt;/h3&gt;

&lt;p&gt;Yes. It is designed to be shared and reused for the life of your application. Create it once at startup, close it at shutdown.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;If you see &lt;code&gt;PoolTimeout&lt;/code&gt;, check for leaked streams first, then bound your fan-out with a semaphore sized at or below &lt;code&gt;max_connections&lt;/code&gt;. Use one long-lived client per upstream host group. Raise limits only after confirming the upstream can absorb the extra concurrency.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/cloudflare-for-developers-what-its-great-at-where-it-bites-and-how-to-actually-use-it-16k6"&gt;Cloudflare for Developers: What It's Great At, Where It Bites, and How to Actually Use It&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/webhook-reliability-101-retries-idempotency-and-the-bugs-that-bite-at-2-am-3e5e"&gt;Webhook Reliability 101: Retries, Idempotency, and the Bugs That Bite at 2 A.M.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/why-your-postgres-migration-locked-the-whole-table-and-the-pattern-that-doesnt-38k4"&gt;Why Your Postgres Migration Locked the Whole Table (and the Pattern That Doesn't)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>programming</category>
      <category>backend</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Git "detected dubious ownership in repository": Fix It in Docker and CI Without Trusting Everything</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Tue, 27 Oct 2026 22:06:54 +0000</pubDate>
      <link>https://dev.to/libme/git-detected-dubious-ownership-in-repository-fix-it-in-docker-and-ci-without-trusting-everything-1a6f</link>
      <guid>https://dev.to/libme/git-detected-dubious-ownership-in-repository-fix-it-in-docker-and-ci-without-trusting-everything-1a6f</guid>
      <description>&lt;p&gt;The error means the user running Git does not own the repository directory, and since Git 2.35.2 Git refuses to run there as a security measure. The quick fix is &lt;code&gt;git config --global --add safe.directory &amp;lt;path&amp;gt;&lt;/code&gt;, but in containers and CI the better fix is usually to make the UIDs match, and a blanket &lt;code&gt;safe.directory '*'&lt;/code&gt; is only acceptable on throwaway machines.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the error actually look like?
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fatal: detected dubious ownership in repository at '/workspace'
To add an exception for this directory, call:

    git config --global --add safe.directory /workspace
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You tend to meet it in four places: a bind-mounted source directory inside Docker, a CI job that runs inside a container image, a repo checked out by one user and used by another (a deploy user versus root, say), and network or Windows-shared drives. In every case the cause is identical: the owner of the &lt;code&gt;.git&lt;/code&gt; directory's parent differs from the effective user running &lt;code&gt;git&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The check exists because of CVE-2022-24765. A malicious repo placed in a shared location such as &lt;code&gt;C:\&lt;/code&gt; or &lt;code&gt;/tmp&lt;/code&gt; could carry a config that executes code when someone else ran a harmless-looking &lt;code&gt;git status&lt;/code&gt; there. Git now refuses unless you say that directory is trusted. Takeaway: the error is a security check working as designed, not a corrupted repository.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does &lt;code&gt;git config --global&lt;/code&gt; seem to do nothing in my container?
&lt;/h2&gt;

&lt;p&gt;This is the dead end that cost me the most time. Three things commonly go wrong:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The setting is read only from trusted scopes.&lt;/strong&gt; &lt;code&gt;safe.directory&lt;/code&gt; is honored in system, global and command-line config. A &lt;code&gt;safe.directory&lt;/code&gt; line inside the repository's own &lt;code&gt;.git/config&lt;/code&gt; is ignored on purpose, otherwise the untrusted repo could vouch for itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--global&lt;/code&gt; writes to &lt;code&gt;$HOME/.gitconfig&lt;/code&gt;.&lt;/strong&gt; If your container runs as an arbitrary UID with no home directory, or with a read-only filesystem, the write fails or lands somewhere Git never reads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The path must match what Git sees.&lt;/strong&gt; Trusting &lt;code&gt;/app&lt;/code&gt; does not help if the process runs in &lt;code&gt;/app/&lt;/code&gt; through a symlink, or if the mount point differs between build and run steps.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Takeaway: when &lt;code&gt;safe.directory&lt;/code&gt; "doesn't work", check which config file you actually wrote to with &lt;code&gt;git config --show-origin --get-all safe.directory&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which fix should I use?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;th&gt;Trade-off&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Local Docker bind mount&lt;/td&gt;
&lt;td&gt;Run the container as your host UID/GID&lt;/td&gt;
&lt;td&gt;Cleanest; no Git config needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI container job, ephemeral runner&lt;/td&gt;
&lt;td&gt;Trust the one workspace path&lt;/td&gt;
&lt;td&gt;Fine, the runner is disposable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shared build box or long-lived server&lt;/td&gt;
&lt;td&gt;Trust specific paths in system config&lt;/td&gt;
&lt;td&gt;Wildcards widen the attack surface&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read-only or random-UID container&lt;/td&gt;
&lt;td&gt;Pass config via environment variables&lt;/td&gt;
&lt;td&gt;No file write required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Quick throwaway debugging&lt;/td&gt;
&lt;td&gt;&lt;code&gt;safe.directory '*'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Trusts every repo this user will ever touch&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Match the UID instead of trusting the directory
&lt;/h3&gt;

&lt;p&gt;For a bind mount, the root cause is ownership, so fix ownership:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--user&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;:/workspace &lt;span class="nt"&gt;-w&lt;/span&gt; /workspace &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;HOME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/tmp &lt;span class="se"&gt;\&lt;/span&gt;
  alpine/git status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Setting &lt;code&gt;HOME&lt;/code&gt; matters: an arbitrary UID usually has no home, and Git will complain about a missing &lt;code&gt;$HOME/.gitconfig&lt;/code&gt; location or about tools that cache there. On Linux hosts this is the standard answer. On Docker Desktop for Mac, file ownership is translated by the file-sharing layer, so you may never see this error locally and then hit it for the first time in Linux CI. That mismatch is why the failure feels random.&lt;/p&gt;

&lt;h3&gt;
  
  
  Trust one explicit path in a throwaway job
&lt;/h3&gt;

&lt;p&gt;In a CI container where the workspace is mounted by the runner as a different user than the job runs as, trust that single path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;container&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node:22&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Allow Git in the workspace&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;git config --global --add safe.directory "$GITHUB_WORKSPACE"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;git describe --tags --always&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As of late 2026, &lt;code&gt;actions/checkout&lt;/code&gt; registers the workspace as safe for its own operations, but later steps that run &lt;code&gt;git&lt;/code&gt; as a different user, or in a different path, can still hit the error. Trusting &lt;code&gt;$GITHUB_WORKSPACE&lt;/code&gt; explicitly is a one-line, auditable fix. Takeaway: in disposable CI, trusting exactly one named path costs nothing and tells the next reader why it is there.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pass the setting through the environment
&lt;/h3&gt;

&lt;p&gt;When you cannot write a config file at all, Git 2.31 and later reads config from environment variables:&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="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;GIT_CONFIG_COUNT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;GIT_CONFIG_KEY_0&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;safe.directory
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;GIT_CONFIG_VALUE_0&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/workspace
git status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the approach I use for containers running under platforms that assign a random UID. Nothing is persisted, so nothing leaks into the next job that reuses the image. The same effect works per command with &lt;code&gt;git -c safe.directory=/workspace status&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is &lt;code&gt;safe.directory '*'&lt;/code&gt; ever okay?
&lt;/h2&gt;

&lt;p&gt;Newer Git versions accept &lt;code&gt;*&lt;/code&gt; to mean "trust every directory". On a single-purpose CI runner that is destroyed after each job, the risk is small because there is no second user whose repos you could be tricked into running hooks from. On a developer laptop or a shared build server it removes the very protection the check provides, since any repo that lands in a world-writable location becomes trusted. I would not bake &lt;code&gt;'*'&lt;/code&gt; into a base image that people also use interactively. Takeaway: &lt;code&gt;'*'&lt;/code&gt; is a property of the machine's lifetime, not of how annoying the error is.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about sudo and root?
&lt;/h2&gt;

&lt;p&gt;If you run &lt;code&gt;sudo git ...&lt;/code&gt; in a repo owned by your normal user, recent Git versions consult &lt;code&gt;SUDO_UID&lt;/code&gt; and let it through when it matches the owner, so the common "sudo deploy" case often works. Plain &lt;code&gt;su&lt;/code&gt; or a service running as root against a repo owned by a deploy user does not set that variable, and you will see the error. For deployment scripts, run Git as the owning user (&lt;code&gt;sudo -u deploy git pull&lt;/code&gt;) rather than widening trust for root.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How do I fix "fatal: detected dubious ownership in repository" in Docker?
&lt;/h3&gt;

&lt;p&gt;Run the container with &lt;code&gt;--user "$(id -u):$(id -g)"&lt;/code&gt; so the process owns the mounted files. If that is not possible, add the exact mount path with &lt;code&gt;git config --global --add safe.directory /path&lt;/code&gt;, or pass it through &lt;code&gt;GIT_CONFIG_COUNT&lt;/code&gt;, &lt;code&gt;GIT_CONFIG_KEY_0&lt;/code&gt; and &lt;code&gt;GIT_CONFIG_VALUE_0&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does safe.directory in .git/config not work?
&lt;/h3&gt;

&lt;p&gt;Git deliberately ignores &lt;code&gt;safe.directory&lt;/code&gt; in repository-local config, because an untrusted repo could otherwise mark itself as safe. Put it in the global, system or command-line config instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is it safe to set safe.directory to &lt;code&gt;*&lt;/code&gt;?
&lt;/h3&gt;

&lt;p&gt;Only on short-lived machines where you are the only user. Elsewhere it makes Git trust every repository on the system, which defeats the check added for CVE-2022-24765.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;If the cause is a bind mount, fix ownership with a matching UID and skip Git config entirely. In disposable CI, trust the one workspace path and leave a comment explaining why. For random-UID platforms with read-only filesystems, use the &lt;code&gt;GIT_CONFIG_*&lt;/code&gt; environment variables, and keep &lt;code&gt;'*'&lt;/code&gt; for machines you will delete in ten minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/railway-vs-render-vs-flyio-deploying-a-hobby-app-without-a-devops-team-10d3"&gt;Railway vs Render vs Fly.io: Deploying a Hobby App Without a DevOps Team&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/supabase-vs-firebase-in-2026-the-migration-questions-nobody-answers-35h8"&gt;Supabase vs Firebase in 2026: The Migration Questions Nobody Answers&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/preview-environments-per-pull-request-how-to-seed-them-without-cloning-production-1f08"&gt;Preview Environments Per Pull Request: How to Seed Them Without Cloning Production&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>github</category>
      <category>docker</category>
      <category>devops</category>
      <category>cicd</category>
    </item>
    <item>
      <title>Is Paying for Bigger CI Runners Worth It? Break-Even Math Against Caching and Test Sharding</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Mon, 26 Oct 2026 15:29:42 +0000</pubDate>
      <link>https://dev.to/libme/is-paying-for-bigger-ci-runners-worth-it-break-even-math-against-caching-and-test-sharding-1da0</link>
      <guid>https://dev.to/libme/is-paying-for-bigger-ci-runners-worth-it-break-even-math-against-caching-and-test-sharding-1da0</guid>
      <description>&lt;p&gt;&lt;strong&gt;Short answer:&lt;/strong&gt; pay for larger runners only after you have removed the minutes that should not exist (redundant runs, cold dependency installs) and only for the steps that actually scale with cores. For most small teams, cancelling superseded runs and caching come first, sharding second, and bigger machines last, because a machine with twice the cores almost never finishes a pipeline in half the time.&lt;/p&gt;

&lt;p&gt;This post gives you a way to decide with your own numbers instead of a vendor's "up to Nx faster" claim. Pricing models vary by provider and change often, so I describe the shape of the costs rather than quoting rates. Check your provider's current price page for the per-minute multiplier before plugging numbers in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where do CI minutes actually go?
&lt;/h2&gt;

&lt;p&gt;Before comparing options, split one representative run into phases. On GitHub Actions the per-step durations are visible in the run's log view; every other CI system has an equivalent. I group them into four buckets:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Setup&lt;/strong&gt; (checkout, toolchain, dependency install): usually serial, usually cacheable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build/compile&lt;/strong&gt;: partly parallel, depends on your toolchain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tests&lt;/strong&gt;: the most parallelizable bucket if tests are independent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Waste&lt;/strong&gt;: runs nobody needed, such as a push-after-push where the first run is already obsolete.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What tripped me up the first time I did this exercise was the waste bucket. It does not show up inside any single run, only across runs. Listing the last few dozen runs and counting how many were cancelled-or-superseded by a newer commit on the same branch is more informative than any single profile.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: you cannot choose between caching, sharding, and bigger runners until you know which bucket your minutes live in.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What should I fix before paying for anything?
&lt;/h2&gt;

&lt;p&gt;Two changes cost nothing and remove minutes without touching speed.&lt;/p&gt;

&lt;p&gt;First, cancel runs that a newer push has made obsolete:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ci-${{ github.workflow }}-${{ github.ref }}&lt;/span&gt;
  &lt;span class="na"&gt;cancel-in-progress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.ref != 'refs/heads/main' }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keeping &lt;code&gt;cancel-in-progress&lt;/code&gt; off for &lt;code&gt;main&lt;/code&gt; is deliberate: you generally want every merge commit to finish its checks, while feature branches only care about the latest push.&lt;/p&gt;

&lt;p&gt;Second, stop cold-installing dependencies. Most official setup actions for Node, Python, and others have a built-in cache option keyed on your lockfile:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;22&lt;/span&gt;
    &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Caching helps only if the install is a meaningful share of the run. If it is 20 seconds out of 10 minutes, move on. Also note the limitation: caches are evicted under storage limits and are scoped by branch, so a cache restored on a feature branch comes from that branch or the default branch, not from a sibling. A cache that mysteriously misses is usually a key problem, not an outage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: cancelling superseded runs and caching lockfile-keyed dependencies are free, and they change what the later options are worth.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Does a bigger runner make CI proportionally faster?
&lt;/h2&gt;

&lt;p&gt;No, and the reason is Amdahl's law: if a fraction &lt;code&gt;p&lt;/code&gt; of the run can use extra cores and the rest is serial, the speedup from &lt;code&gt;n&lt;/code&gt; times the compute is &lt;code&gt;1 / ((1 - p) + p / n)&lt;/code&gt;. Checkout, installs, a single-threaded bundler step, and a database migration are all serial. A pipeline that is 60% parallelizable and runs on a machine with four times the cores speeds up by roughly 1.8x, not 4x. Meanwhile the per-minute price of a larger runner typically scales with its size, so you pay a multiple of the rate for a smaller multiple of the speed.&lt;/p&gt;

&lt;p&gt;Here is a script that turns your phase measurements into a break-even check. It uses only the standard library:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run_cost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rate_multiplier&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Billed cost in &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;standard-runner-minutes&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;minutes&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;rate_multiplier&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;speedup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parallel_fraction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cores_multiplier&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;parallel_fraction&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;parallel_fraction&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;cores_multiplier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;compare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base_minutes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;parallel_fraction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cores_multiplier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rate_multiplier&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;new_minutes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base_minutes&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;speedup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parallel_fraction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cores_multiplier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;new_minutes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;new_minutes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;billed_before&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;run_cost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base_minutes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;billed_after&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;run_cost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;new_minutes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rate_multiplier&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Replace these with measurements from your own pipeline:
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;compare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base_minutes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;parallel_fraction&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="n"&gt;cores_multiplier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rate_multiplier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With those placeholder inputs (they are illustrative, not measured), the run gets shorter but the billed cost goes up. The only thing you are buying is developer waiting time. That can still be a good trade, which is the next question.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: a larger runner is a purchase of wall-clock time at a worse unit price, so the only valid justification is that wall-clock time is worth more than the extra billed minutes.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When is the faster pipeline worth the extra spend?
&lt;/h2&gt;

&lt;p&gt;Convert waiting time into a number. Take the number of runs per day that a human actually waits on, multiply by minutes saved per run, and compare with the added billed cost. If five engineers each wait on the pipeline several times a day, shaving a few minutes per run is real focus time. If most runs are bot-triggered or nobody watches them, the saved minutes are worth close to nothing.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Cuts billed minutes&lt;/th&gt;
&lt;th&gt;Cuts wall-clock time&lt;/th&gt;
&lt;th&gt;Main drawback&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cancel superseded runs&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Needs care on &lt;code&gt;main&lt;/code&gt; and release branches&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dependency/build caching&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Key mistakes cause silent misses or stale builds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test sharding (matrix)&lt;/td&gt;
&lt;td&gt;No, usually slightly up&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Setup cost repeats per shard; uneven shards&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Larger runner&lt;/td&gt;
&lt;td&gt;No, usually up&lt;/td&gt;
&lt;td&gt;Yes, sub-linearly&lt;/td&gt;
&lt;td&gt;Higher unit price for serial steps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-hosted runner&lt;/td&gt;
&lt;td&gt;Shifts cost to your infra&lt;/td&gt;
&lt;td&gt;Depends on hardware&lt;/td&gt;
&lt;td&gt;You own patching, isolation, and scaling&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Sharding is the middle path, and it is worth showing because the tooling is built in. Jest and Playwright both support splitting a suite with a &lt;code&gt;--shard&lt;/code&gt; flag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;fail-fast&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
      &lt;span class="na"&gt;matrix&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;shard&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;2&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;3&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;4&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;22&lt;/span&gt;
          &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx jest --shard=${{ matrix.shard }}/4&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The catch: each shard repeats checkout and install, so four shards cost more billed minutes than one run, and the slowest shard sets the finish time. Shard counts beyond what your install overhead justifies give diminishing returns quickly.&lt;/p&gt;

&lt;p&gt;If you decide the managed option is the right call for the serial-heavy build step, GitHub Actions larger runners are the version of this that needs no infrastructure work from you, with the tradeoff that you pay the higher rate for every minute including the serial ones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: shard the parallel part and keep the serial part on cheap machines, rather than upgrading the whole job.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What about self-hosted runners?
&lt;/h2&gt;

&lt;p&gt;Self-hosted runners trade a bill for an operations job. You pay for the hardware (or cloud instances) and for the time to patch, isolate untrusted code from forks, and scale them. Billing rules for self-hosted runners on hosted CI platforms have been under revision, so verify the current policy before building a business case on "self-hosted is free." For a team under roughly a handful of engineers, the maintenance time usually exceeds the saving unless builds are very heavy or need special hardware.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: self-hosting is a hardware-and-ops decision, not a discount.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Are larger GitHub Actions runners worth it?&lt;/strong&gt;&lt;br&gt;
Only for jobs that are mostly parallel, such as compiling or running a big independent test suite, and only when engineers are actually waiting on the result. For serial-heavy pipelines you pay a higher per-minute rate for little speedup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why is my CI slower after adding more shards?&lt;/strong&gt;&lt;br&gt;
Each shard repeats checkout and dependency install, and the pipeline finishes when the slowest shard does. If shards are uneven or setup is a large share of the run, more shards add overhead faster than they remove test time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I reduce CI minutes without slowing builds down?&lt;/strong&gt;&lt;br&gt;
Cancel superseded runs on feature branches, cache dependencies keyed on your lockfile, and skip jobs whose paths did not change. These remove work instead of buying speed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;Measure one representative run by phase, then count how many runs in the last month were obsolete. Fix cancellation and caching first because they lower the bill and the wait. Shard tests when the test phase dominates and the install is small. Buy larger runners only for steps you have shown scale with cores, and only when someone's waiting time is worth more than the extra billed minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/cursor-vs-github-copilot-vs-windsurf-which-ai-editor-fits-how-you-actually-work-1ih9"&gt;Cursor vs GitHub Copilot vs Windsurf: Which AI Editor Fits How You Actually Work?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/pgvector-vs-pinecone-vs-qdrant-when-is-a-dedicated-vector-database-actually-worth-it-2d3o"&gt;pgvector vs Pinecone vs Qdrant: When Is a Dedicated Vector Database Actually Worth It?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/postgres-says-too-many-clients-already-diagnose-it-before-you-add-a-pooler-5dbb"&gt;Postgres Says "Too Many Clients Already": Diagnose It Before You Add a Pooler&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>cicd</category>
      <category>github</category>
      <category>devops</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Redis vs Valkey vs Dragonfly: Which In-Memory Store Should a New Project Use After the License Changes?</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Sun, 25 Oct 2026 21:30:35 +0000</pubDate>
      <link>https://dev.to/libme/redis-vs-valkey-vs-dragonfly-which-in-memory-store-should-a-new-project-use-after-the-license-4g52</link>
      <guid>https://dev.to/libme/redis-vs-valkey-vs-dragonfly-which-in-memory-store-should-a-new-project-use-after-the-license-4g52</guid>
      <description>&lt;p&gt;For most new projects, the answer is to speak the Redis protocol and keep the server swappable: Valkey is the low-risk default if you want a permissively licensed fork, Redis itself is fine if you use a managed offering or its AGPL option fits your situation, and Dragonfly is worth testing only if a single node's throughput or memory efficiency is your actual bottleneck. The license matters mostly if you redistribute the server or resell it as a service. For an app that merely connects to a cache, the client code is identical in all three cases.&lt;/p&gt;

&lt;p&gt;This post covers what changed, how to test compatibility in an afternoon, and where each option has real limits. Details are as of late 2026; check the projects' own license files before making a legal decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually changed with Redis licensing?
&lt;/h2&gt;

&lt;p&gt;In 2024, Redis moved from the BSD license to a dual source-available arrangement (RSALv2 and SSPL). That prompted the Linux Foundation to host Valkey, a fork of the last BSD-licensed Redis code (the 7.2 line), with backing from several large cloud vendors. In 2025, Redis added AGPLv3 as a licensing option for newer versions, which is OSI-approved open source again, though AGPL carries its own obligations.&lt;/p&gt;

&lt;p&gt;What this means in practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If your app only connects to the server over the network, none of these licenses restrict you from running it internally.&lt;/li&gt;
&lt;li&gt;If you ship the server inside a product you distribute, or offer it as a managed service to others, the license is a real constraint, and you should read it with a lawyer rather than a blog post.&lt;/li&gt;
&lt;li&gt;If your company has a blanket "no AGPL / no SSPL" policy, that policy decides the question before technical merit does.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Takeaway: the license change is a procurement and distribution question, not a code-compatibility question for the application developer.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do Redis, Valkey, and Dragonfly differ?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Redis&lt;/th&gt;
&lt;th&gt;Valkey&lt;/th&gt;
&lt;th&gt;Dragonfly&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;License (as of late 2026)&lt;/td&gt;
&lt;td&gt;Source-available options plus AGPLv3&lt;/td&gt;
&lt;td&gt;BSD-style, Linux Foundation project&lt;/td&gt;
&lt;td&gt;Source-available (BSL-style)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lineage&lt;/td&gt;
&lt;td&gt;Original&lt;/td&gt;
&lt;td&gt;Fork of Redis 7.2&lt;/td&gt;
&lt;td&gt;Independent rewrite in C++&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Protocol&lt;/td&gt;
&lt;td&gt;Redis (RESP)&lt;/td&gt;
&lt;td&gt;Redis (RESP)&lt;/td&gt;
&lt;td&gt;Redis (RESP), most commands&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Threading model&lt;/td&gt;
&lt;td&gt;Mostly single-threaded command execution, I/O threads available&lt;/td&gt;
&lt;td&gt;Same base, with ongoing I/O threading work&lt;/td&gt;
&lt;td&gt;Multi-threaded, shared-nothing design&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Module ecosystem&lt;/td&gt;
&lt;td&gt;Largest (Redis modules, vendor features)&lt;/td&gt;
&lt;td&gt;Smaller, growing&lt;/td&gt;
&lt;td&gt;Not module-compatible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Managed offerings&lt;/td&gt;
&lt;td&gt;Redis's own cloud and others&lt;/td&gt;
&lt;td&gt;Offered by major clouds as a managed option&lt;/td&gt;
&lt;td&gt;Vendor cloud, or self-host&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Main drawback&lt;/td&gt;
&lt;td&gt;License complexity&lt;/td&gt;
&lt;td&gt;Feature set trails newer Redis-only additions&lt;/td&gt;
&lt;td&gt;Compatibility is "most", not "all"; younger project&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I would not trust any single row without checking it yourself, particularly the managed-offering row, which changes quickly. The point of the table is the shape of the trade-off: Valkey minimizes surprise, Redis maximizes features, Dragonfly changes the performance model.&lt;/p&gt;

&lt;p&gt;Takeaway: Valkey and Redis differ mainly in governance and newer vendor-specific features, while Dragonfly differs in architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you test compatibility before committing?
&lt;/h2&gt;

&lt;p&gt;Do not read compatibility pages; run your own command surface against each server. The usual trap is that your app uses a command, Lua script, or data type you forgot about. Here is a small script that records every command your test suite issues, then replays that set against a candidate server.&lt;/p&gt;

&lt;p&gt;Start the candidates locally (swap the image for the one you are evaluating):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; cache-valkey &lt;span class="nt"&gt;-p&lt;/span&gt; 6380:6379 valkey/valkey:latest
docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; cache-redis  &lt;span class="nt"&gt;-p&lt;/span&gt; 6381:6379 redis:latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then run your real integration tests against each port. For a fast sanity check of the specific features you rely on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Redis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;decode_responses&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flushall&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;k&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nx&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;set_nx_ex&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;k&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;xadd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;streams&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;xlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;

    &lt;span class="n"&gt;script&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;return redis.call(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;INCR&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, KEYS[1])&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lua_eval&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;script&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;counter&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;

    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;zadd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;z&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sorted_set&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;zrange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;z&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="c1"&gt;# Blocking pop with a short timeout, a common compatibility gap
&lt;/span&gt;    &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;blpop_timeout&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;blpop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;empty&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;valkey&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6380&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;redis&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6381&lt;/span&gt;&lt;span class="p"&gt;)]:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Extend it with the commands your code uses. To find those, run &lt;code&gt;MONITOR&lt;/code&gt; against a staging instance during a test run, or grep your codebase and your libraries (queue libraries and session stores often use Lua scripts and streams you never wrote yourself).&lt;/p&gt;

&lt;p&gt;What tripped me up in this kind of exercise: libraries that detect the server by version string. A client that checks &lt;code&gt;INFO server&lt;/code&gt; for a specific &lt;code&gt;redis_version&lt;/code&gt; can behave differently against a fork that reports its own version. Test with your actual client library versions, not just &lt;code&gt;redis-cli&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Takeaway: compatibility is a property of your command set plus your libraries, so replay both rather than trusting a feature list.&lt;/p&gt;

&lt;h2&gt;
  
  
  When is Dragonfly worth the extra evaluation?
&lt;/h2&gt;

&lt;p&gt;Dragonfly's pitch is that one multi-threaded process can use all cores on a large machine, where a single-threaded Redis-style process uses roughly one core for commands and you scale out with clustering. If you are running a big instance and sharding purely to get past one core, that is the situation where it deserves a benchmark. I have not run a controlled benchmark here and will not quote numbers; vendor benchmarks are measured on their chosen workloads, so run your own traffic shape.&lt;/p&gt;

&lt;p&gt;If you are at a few thousand operations per second on a small instance, none of this matters. The cache is not your bottleneck, and the extra variable of a newer, less widely deployed server costs more in operational unknowns than it saves.&lt;/p&gt;

&lt;p&gt;Limitations to weigh: Dragonfly does not support Redis modules, a few commands and behaviors differ, and the community and hosting options are smaller than for Redis or Valkey. If the managed service you want only offers Redis or Valkey, that constraint probably decides it. If you want the one with the strongest performance story on big single nodes and you are willing to self-host and test, Dragonfly is the one to put on your shortlist.&lt;/p&gt;

&lt;p&gt;Takeaway: choose Dragonfly because you measured a single-node scaling problem, not because the architecture sounds modern.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the safest way to keep your options open?
&lt;/h2&gt;

&lt;p&gt;Treat the cache as a protocol, not a product:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Connect through a standard client and a single &lt;code&gt;CACHE_URL&lt;/code&gt; environment variable (&lt;code&gt;redis://&lt;/code&gt; works for all three).&lt;/li&gt;
&lt;li&gt;Avoid vendor-specific modules unless the feature is worth being locked in for. If you need something like JSON documents or search inside the store, you are choosing a vendor, not a protocol.&lt;/li&gt;
&lt;li&gt;Keep Lua scripts and Streams usage in one module so a future migration has a single place to test.&lt;/li&gt;
&lt;li&gt;Run your integration suite in CI against two servers, so drift shows up in a pull request instead of a migration weekend.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.ci.yml&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;cache-a&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;valkey/valkey:latest&lt;/span&gt;
  &lt;span class="na"&gt;cache-b&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis:latest&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you want the managed version of the open-source fork without running servers yourself, check whether your existing cloud provider's managed Valkey offering covers your region and version needs before looking elsewhere. If you want Redis's own feature set and support, Redis Cloud is the managed route from the vendor itself, with the usual limitation that you are tied to its roadmap and pricing model.&lt;/p&gt;

&lt;p&gt;Takeaway: a &lt;code&gt;CACHE_URL&lt;/code&gt; variable and a two-server CI matrix make this decision reversible.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is Valkey a drop-in replacement for Redis?&lt;/strong&gt;&lt;br&gt;
For Redis 7.2-era features, largely yes: the same protocol, commands, and clients work. Features that Redis added after the fork, or Redis-specific modules, may be missing, so replay your own command set before switching.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I still use Redis for free after the license change?&lt;/strong&gt;&lt;br&gt;
Yes for running it internally as a cache or queue backend. The license restrictions bite mainly if you redistribute the server or offer it as a service, and the AGPL option adds its own source-sharing obligations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is Dragonfly faster than Redis?&lt;/strong&gt;&lt;br&gt;
On large multi-core machines it can be, because it uses multiple threads for command execution, but the gain depends entirely on your workload. Benchmark your own traffic before deciding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;Default to Valkey or managed Redis, whichever your cloud and legal team already accept, and write your application against the protocol so you can change your mind. Pick Redis when you need its newer or module-based features. Evaluate Dragonfly only when you have a measured single-node throughput or memory problem and can accept self-hosting and a smaller ecosystem. Everyone else should spend the afternoon on a two-server CI matrix, not on picking a winner.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/postgres-as-a-job-queue-vs-redis-vs-sqs-when-does-just-use-your-database-stop-working-1f24"&gt;Postgres as a Job Queue vs Redis vs SQS: When Does "Just Use Your Database" Stop Working?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/feature-flags-when-a-managed-service-beats-a-config-table-you-own-4f20"&gt;Feature Flags: When a Managed Service Beats a Config Table You Own&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/preview-environments-per-pull-request-how-to-seed-them-without-cloning-production-1f08"&gt;Preview Environments Per Pull Request: How to Seed Them Without Cloning Production&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>database</category>
      <category>backend</category>
      <category>devops</category>
      <category>opensource</category>
    </item>
    <item>
      <title>FastAPI Latency Spikes Under Load: How One Blocking Call in an async Endpoint Stalls Every Request</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Sat, 24 Oct 2026 22:08:17 +0000</pubDate>
      <link>https://dev.to/libme/fastapi-latency-spikes-under-load-how-one-blocking-call-in-an-async-endpoint-stalls-every-request-29e1</link>
      <guid>https://dev.to/libme/fastapi-latency-spikes-under-load-how-one-blocking-call-in-an-async-endpoint-stalls-every-request-29e1</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; An &lt;code&gt;async def&lt;/code&gt; endpoint runs directly on the event loop. If it calls anything that blocks (&lt;code&gt;time.sleep&lt;/code&gt;, &lt;code&gt;requests.get&lt;/code&gt;, a sync database driver, heavy CPU work), the loop cannot serve any other request until that call returns. The fix is to either declare the endpoint with plain &lt;code&gt;def&lt;/code&gt; so the framework runs it in a thread pool, push the blocking call into a thread with &lt;code&gt;asyncio.to_thread&lt;/code&gt;, or switch to a genuinely async client. The symptom that sends people searching is odd: health checks time out and p99 explodes while CPU looks fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the symptom look like?
&lt;/h2&gt;

&lt;p&gt;The pattern I see over and over: an endpoint is fast in isolation, and a load test or a traffic burst makes &lt;em&gt;unrelated&lt;/em&gt; endpoints slow, including &lt;code&gt;/health&lt;/code&gt;. Uptime probes fail, the load balancer marks the instance unhealthy, and the instance restarts under load, which makes things worse.&lt;/p&gt;

&lt;p&gt;Here is a minimal reproduction. Python 3.11+, with &lt;code&gt;fastapi&lt;/code&gt;, &lt;code&gt;uvicorn&lt;/code&gt; and &lt;code&gt;httpx&lt;/code&gt; installed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastAPI&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/health&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;health&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/slow-async&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;slow_async&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# blocks the event loop
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;done&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/slow-sync&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;slow_sync&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# runs in a worker thread
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;done&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it with &lt;code&gt;uvicorn app:app&lt;/code&gt; and fire ten concurrent requests at each slow route. Against &lt;code&gt;/slow-async&lt;/code&gt;, the ten requests are served one after another, so the last one waits roughly ten times 0.5 seconds. That is arithmetic, not a benchmark: a single-threaded loop can only do one blocking call at a time. During that window &lt;code&gt;/health&lt;/code&gt; also waits, because it is queued behind them on the same loop.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# hammer.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AsyncClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://127.0.0.1:8000&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="c1"&gt;# fire n slow requests, then probe /health while they are in flight
&lt;/span&gt;        &lt;span class="n"&gt;slow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;h0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/health&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/health took &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;h0&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;s while &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; was busy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gather&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;slow&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;all &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; done in &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;python hammer.py /slow-async&lt;/code&gt; and then &lt;code&gt;python hammer.py /slow-sync&lt;/code&gt;. The first shows &lt;code&gt;/health&lt;/code&gt; stuck for seconds; the second answers it almost immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: &lt;code&gt;async def&lt;/code&gt; is a promise that you never block, and the framework cannot enforce it for you.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does &lt;code&gt;def&lt;/code&gt; work when &lt;code&gt;async def&lt;/code&gt; doesn't?
&lt;/h2&gt;

&lt;p&gt;FastAPI (via Starlette and AnyIO) treats the two kinds differently. A plain &lt;code&gt;def&lt;/code&gt; endpoint is dispatched to a worker thread pool, so a blocking call occupies a thread, not the loop. An &lt;code&gt;async def&lt;/code&gt; endpoint is awaited on the loop itself. As of late 2026 the AnyIO thread limiter defaults to 40 concurrent threads, so a &lt;code&gt;def&lt;/code&gt; endpoint is not free of limits either: the 41st concurrent blocking request queues.&lt;/p&gt;

&lt;p&gt;The common mistake is the well-meaning "everything should be async" refactor. Converting &lt;code&gt;def&lt;/code&gt; to &lt;code&gt;async def&lt;/code&gt; without converting the calls inside it makes the endpoint &lt;em&gt;worse&lt;/em&gt;, because you moved it off the thread pool onto the loop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: converting &lt;code&gt;def&lt;/code&gt; to &lt;code&gt;async def&lt;/code&gt; is a performance regression unless every call inside it was converted too.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I find the blocking call?
&lt;/h2&gt;

&lt;p&gt;Do not guess. Two tools find it quickly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. asyncio debug mode.&lt;/strong&gt; Start the server with &lt;code&gt;PYTHONASYNCIODEBUG=1 uvicorn app:app&lt;/code&gt;. The loop logs any callback that holds it longer than &lt;code&gt;slow_callback_duration&lt;/code&gt; (0.1 s by default), with the task and the coroutine involved:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Executing &amp;lt;Task pending name='Task-7' coro=&amp;lt;slow_async() running at app.py:13&amp;gt; ...&amp;gt; took 0.501 seconds
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That line names the endpoint. Debug mode adds overhead, so use it in staging or a local repro, not on a hot production box.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A sampling profiler.&lt;/strong&gt; If you cannot reproduce locally, &lt;code&gt;py-spy dump --pid &amp;lt;pid&amp;gt;&lt;/code&gt; shows where each thread currently is. When the loop thread sits in &lt;code&gt;time.sleep&lt;/code&gt;, &lt;code&gt;socket.recv&lt;/code&gt;, or a driver's &lt;code&gt;execute&lt;/code&gt;, that is your culprit. The honest limitation of &lt;code&gt;py-spy&lt;/code&gt;: it needs permission to attach to the process (ptrace on Linux, root on macOS), which is often locked down in containers.&lt;/p&gt;

&lt;p&gt;The usual suspects, in the order I find them: &lt;code&gt;requests&lt;/code&gt; inside an async handler, a sync ORM session or &lt;code&gt;psycopg2&lt;/code&gt; call, &lt;code&gt;open().read()&lt;/code&gt; on large files, &lt;code&gt;json.loads&lt;/code&gt; on multi-megabyte payloads, password hashing (bcrypt/argon2 are deliberately slow), and image or PDF processing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: the slow-callback log turns "something is blocking" into a file and line number in one run.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Which fix should I use?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;th&gt;Trade-off&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Handler is mostly sync code (sync ORM, &lt;code&gt;requests&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Change &lt;code&gt;async def&lt;/code&gt; to &lt;code&gt;def&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Limited by the thread pool size; no &lt;code&gt;await&lt;/code&gt; inside&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One blocking call inside an otherwise async handler&lt;/td&gt;
&lt;td&gt;&lt;code&gt;await asyncio.to_thread(fn, ...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Same thread pool limits; code must be thread-safe&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTP calls to other services&lt;/td&gt;
&lt;td&gt;Use an async client such as &lt;code&gt;httpx.AsyncClient&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Needs one shared client, not one per request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU-bound work (hashing, image resize, big parsing)&lt;/td&gt;
&lt;td&gt;Process pool or a job queue&lt;/td&gt;
&lt;td&gt;Serialization cost; more moving parts&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For the one-call case:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;bcrypt&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastAPI&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;bcrypt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hashpw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bcrypt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gensalt&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/signup&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;signup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;hashed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_thread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_hash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hash_len&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hashed&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For outbound HTTP, create the client once at startup rather than per request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;contextlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asynccontextmanager&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt;

&lt;span class="nd"&gt;@asynccontextmanager&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;lifespan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AsyncClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;5.0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;yield&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;aclose&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lifespan&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;lifespan&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/proxy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;proxy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you want an HTTP client that works in both sync and async code with the same API, &lt;code&gt;httpx&lt;/code&gt; is the one I reach for; its drawback is that it is a bit slower than lower-level clients such as &lt;code&gt;aiohttp&lt;/code&gt; on very high request rates, and the default timeouts differ from &lt;code&gt;requests&lt;/code&gt;, so set them explicitly.&lt;/p&gt;

&lt;p&gt;CPU-bound work is the case threads do not rescue: threads share the GIL, so heavy pure-Python computation in &lt;code&gt;to_thread&lt;/code&gt; still starves other Python threads (C extensions that release the GIL, such as bcrypt, are fine). Use &lt;code&gt;ProcessPoolExecutor&lt;/code&gt; via &lt;code&gt;loop.run_in_executor&lt;/code&gt; or hand it to a worker queue.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: match the fix to the kind of blocking: threads for I/O and GIL-releasing libraries, processes or queues for pure-Python CPU work.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Can running more workers hide the problem?
&lt;/h2&gt;

&lt;p&gt;Yes, and that is why it survives so long. &lt;code&gt;uvicorn --workers 4&lt;/code&gt; (or Gunicorn with Uvicorn workers) gives you four independent loops, so one stuck loop no longer stalls everything. But four workers each blocked by one slow request is just a smaller version of the same outage. More workers are good capacity planning and bad bug-hiding; fix the block first, then size the workers.&lt;/p&gt;

&lt;p&gt;If you want the guard rail rather than the fix, Uvicorn's &lt;code&gt;--timeout-keep-alive&lt;/code&gt; and your proxy's upstream timeout will at least turn a silent stall into a visible 504, which is easier to alert on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: extra workers raise the number of requests a blocking call can hurt at once; they do not remove the block.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why is my FastAPI app slow when I use async def?&lt;/strong&gt;&lt;br&gt;
Almost always because something inside the handler blocks the event loop, such as &lt;code&gt;requests&lt;/code&gt;, a sync database driver, or &lt;code&gt;time.sleep&lt;/code&gt;. Run with &lt;code&gt;PYTHONASYNCIODEBUG=1&lt;/code&gt; to see which coroutine holds the loop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I use def or async def in FastAPI?&lt;/strong&gt;&lt;br&gt;
Use &lt;code&gt;async def&lt;/code&gt; only when every I/O call inside it is awaited. Use plain &lt;code&gt;def&lt;/code&gt; when you depend on sync libraries; FastAPI runs it in a thread pool.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does asyncio.to_thread make blocking code non-blocking?&lt;/strong&gt;&lt;br&gt;
It moves the call off the event loop onto a worker thread, so other requests keep being served. It does not speed up the call, and pure-Python CPU work still contends for the GIL.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;If you use sync libraries, write plain &lt;code&gt;def&lt;/code&gt; endpoints and stop worrying. If you are committed to &lt;code&gt;async def&lt;/code&gt;, treat every library call as guilty until proven awaitable, and keep asyncio debug mode in your staging setup so a new blocking dependency gets caught in review rather than at 2 a.m. For pure CPU work, move it out of the web process entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/an-ai-assisted-code-review-pipeline-that-catches-what-humans-skim-past-5hc0"&gt;An AI-Assisted Code Review Pipeline That Catches What Humans Skim Past&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/webhook-reliability-101-retries-idempotency-and-the-bugs-that-bite-at-2-am-3e5e"&gt;Webhook Reliability 101: Retries, Idempotency, and the Bugs That Bite at 2 A.M.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/your-logging-bill-is-a-volume-problem-model-the-cost-before-you-switch-vendors-4hmh"&gt;Your Logging Bill Is a Volume Problem: Model the Cost Before You Switch Vendors&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>backend</category>
      <category>performance</category>
      <category>api</category>
    </item>
    <item>
      <title>GitHub Push Rejected: "File Exceeds 100 MB" (GH001) — Fix It With filter-repo or LFS</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Fri, 23 Oct 2026 22:26:52 +0000</pubDate>
      <link>https://dev.to/libme/github-push-rejected-file-exceeds-100-mb-gh001-fix-it-with-filter-repo-or-lfs-f31</link>
      <guid>https://dev.to/libme/github-push-rejected-file-exceeds-100-mb-gh001-fix-it-with-filter-repo-or-lfs-f31</guid>
      <description>&lt;p&gt;Deleting the big file in a new commit does not fix this error, because Git still carries the file in an earlier commit and GitHub rejects the whole push. You have to either rewrite history so the blob never existed (&lt;code&gt;git filter-repo&lt;/code&gt;), or move the file to Git LFS and rewrite history so old commits point to LFS instead (&lt;code&gt;git lfs migrate&lt;/code&gt;). Pick filter-repo if the file should not be in the repo at all, LFS if it should be versioned but is legitimately large.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the error actually look like?
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;remote: error: Trace: 3f1c...
remote: error: See https://gh.io/lfs for more information.
remote: error: File data/embeddings.bin is 143.21 MB; this exceeds GitHub's file size limit of 100.00 MB
remote: error: GH001: Large files detected. You may want to try Git Large File Storage - https://git-lfs.github.com.
 ! [remote rejected] main -&amp;gt; main (pre-receive hook declined)
error: failed to push some refs to 'github.com:you/project.git'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As of late 2026, GitHub prints a warning for files over 50 MB and blocks pushes containing files over 100 MB. The check runs on every blob in the commits you are pushing, not just the tip of your branch. That is the part that surprises people.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: GH001 is about every commit in the push, so the fix has to change history, not just the working tree.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why doesn't &lt;code&gt;git rm&lt;/code&gt; and a new commit fix it?
&lt;/h2&gt;

&lt;p&gt;I have watched this dead-end happen more than once: the developer runs &lt;code&gt;git rm data/embeddings.bin&lt;/code&gt;, commits, pushes, and gets the identical error. The file is deleted from the latest snapshot, but the commit that added it is still an ancestor of your branch, and it is part of what you are pushing.&lt;/p&gt;

&lt;p&gt;Other dead ends that look plausible:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;git commit --amend&lt;/code&gt;&lt;/strong&gt; only works if the big file is in the very last commit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adding it to &lt;code&gt;.gitignore&lt;/code&gt;&lt;/strong&gt; after the fact does nothing for a file already tracked or already committed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;git reset --soft&lt;/code&gt; and recommit&lt;/strong&gt; works for unpushed local history and is actually fine for a small number of commits, but gets tedious fast and loses your commit messages.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;First, find out which commit and which path is the problem:&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;# list the 10 largest blobs anywhere in history&lt;/span&gt;
git rev-list &lt;span class="nt"&gt;--objects&lt;/span&gt; &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | git cat-file &lt;span class="nt"&gt;--batch-check&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'%(objecttype) %(objectname) %(objectsize) %(rest)'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'$1 == "blob" {print $3, $4}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first column is bytes, the second is the path. If the same path shows up in multiple versions, every version is in history and every one over the limit will be rejected.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: before touching anything, list the largest blobs in history so you know whether you are removing one file or twenty.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Option A: Remove the file entirely with git filter-repo
&lt;/h2&gt;

&lt;p&gt;Use this when the file was committed by accident (a dataset, a build artifact, a database dump, a model checkpoint) and does not belong in the repository.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;git filter-repo&lt;/code&gt; is the tool the Git project itself recommends over the older &lt;code&gt;git filter-branch&lt;/code&gt;. Install it with &lt;code&gt;pip install git-filter-repo&lt;/code&gt; or your package manager. Work on a fresh clone, because filter-repo deliberately refuses to run on a repo that looks like it has remotes and local work you could lose.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone git@github.com:you/project.git project-clean
&lt;span class="nb"&gt;cd &lt;/span&gt;project-clean

&lt;span class="c"&gt;# remove one path from all history&lt;/span&gt;
git filter-repo &lt;span class="nt"&gt;--path&lt;/span&gt; data/embeddings.bin &lt;span class="nt"&gt;--invert-paths&lt;/span&gt;

&lt;span class="c"&gt;# or: remove every blob over a size threshold, whatever its path&lt;/span&gt;
git filter-repo &lt;span class="nt"&gt;--strip-blobs-bigger-than&lt;/span&gt; 50M
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;filter-repo removes the &lt;code&gt;origin&lt;/code&gt; remote as a safety measure, so re-add it and force-push:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git remote add origin git@github.com:you/project.git
git push &lt;span class="nt"&gt;--force-with-lease&lt;/span&gt; origin main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two honest drawbacks. First, this rewrites every commit hash from the first affected commit onward, so every collaborator must re-clone or hard-reset, and open pull requests will break. Second, if the file contained a secret, rewriting history on GitHub does not make the secret safe: assume it leaked and rotate it. Cached views of old commits can persist on the platform even after a force push.&lt;/p&gt;

&lt;p&gt;If you want a single-purpose tool with a simpler interface, BFG Repo-Cleaner does the same job, but it is less flexible with path filtering than filter-repo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: use &lt;code&gt;git filter-repo --invert-paths&lt;/code&gt; when the file was a mistake, and treat every collaborator clone as needing a reset afterward.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Option B: Keep the file but move it to Git LFS
&lt;/h2&gt;

&lt;p&gt;Use this when the file is a legitimate part of the project, like design assets, test fixtures, or a small model, and you want it versioned.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git lfs &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;span class="c"&gt;# rewrite ALL history so matching files become LFS pointers&lt;/span&gt;
git lfs migrate import &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.bin,data/*.parquet"&lt;/span&gt; &lt;span class="nt"&gt;--everything&lt;/span&gt;
git push &lt;span class="nt"&gt;--force-with-lease&lt;/span&gt; origin main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--everything&lt;/code&gt; rewrites every branch and tag; without it only the current branch is migrated and the other refs still contain the big blobs. Check the result with &lt;code&gt;git lfs ls-files&lt;/code&gt;, and confirm &lt;code&gt;.gitattributes&lt;/code&gt; now contains the tracking rules, since that file has to be committed for teammates to get the same behavior.&lt;/p&gt;

&lt;p&gt;The drawback is that LFS is a separate storage and bandwidth quota on GitHub. The free allowance is limited, large files in CI re-download on every checkout unless you cache them, and forks have their own quirks with LFS objects. Check GitHub's current LFS billing page before committing a multi-gigabyte dataset to it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: LFS keeps big files versioned, but you are trading a hard push error for a storage and bandwidth quota you now have to watch.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Which fix should you use?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Best fix&lt;/th&gt;
&lt;th&gt;History rewritten?&lt;/th&gt;
&lt;th&gt;Ongoing cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Accidental commit of dump, build output, &lt;code&gt;.env&lt;/code&gt;, &lt;code&gt;node_modules&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git filter-repo --invert-paths&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Several oversized blobs, unsure which&lt;/td&gt;
&lt;td&gt;&lt;code&gt;filter-repo --strip-blobs-bigger-than&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Large assets that must be versioned&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git lfs migrate import&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;LFS quota&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Large file only in unpushed local commits&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;git reset --soft&lt;/code&gt; and recommit&lt;/td&gt;
&lt;td&gt;Local only&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dataset or model that changes often&lt;/td&gt;
&lt;td&gt;Object storage (S3, R2) plus a download script&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Storage fees&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last row is the one I would choose most often for ML-ish repos: store the artifact in a bucket and keep a small script plus a checksum in Git. It keeps clones fast and leaves no history problem to fix later.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you stop this from happening again?
&lt;/h2&gt;

&lt;p&gt;Add the pattern to &lt;code&gt;.gitignore&lt;/code&gt; &lt;em&gt;before&lt;/em&gt; the file exists, and add a pre-commit check so the failure happens on your laptop instead of at push time:&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;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# .git/hooks/pre-commit — block staged files over 50 MB&lt;/span&gt;
&lt;span class="nv"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;&lt;span class="m"&gt;50&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="m"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="m"&gt;1024&lt;/span&gt;&lt;span class="k"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;f &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;git diff &lt;span class="nt"&gt;--cached&lt;/span&gt; &lt;span class="nt"&gt;--name-only&lt;/span&gt; &lt;span class="nt"&gt;--diff-filter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;AM&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &amp;lt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$size&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$limit&lt;/span&gt;&lt;span class="s2"&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;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Refusing to commit &lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt; (&lt;/span&gt;&lt;span class="nv"&gt;$size&lt;/span&gt;&lt;span class="s2"&gt; bytes, limit &lt;/span&gt;&lt;span class="nv"&gt;$limit&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
    &lt;span class="nb"&gt;exit &lt;/span&gt;1
  &lt;span class="k"&gt;fi
done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Make it executable with &lt;code&gt;chmod +x .git/hooks/pre-commit&lt;/code&gt;. Hooks in &lt;code&gt;.git/&lt;/code&gt; are not shared, so for a team use a framework such as pre-commit, which has a built-in large-file check.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why does GitHub reject my push after I deleted the large file?&lt;/strong&gt;&lt;br&gt;
Because the commit that added the file is still in your branch history, and GitHub scans every commit being pushed. You must rewrite history with &lt;code&gt;git filter-repo&lt;/code&gt; or &lt;code&gt;git lfs migrate&lt;/code&gt; to remove the blob from those commits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is GitHub's maximum file size for a regular Git push?&lt;/strong&gt;&lt;br&gt;
GitHub blocks files larger than 100 MB and warns above 50 MB, as of late 2026. Larger files need Git LFS or external storage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is it safe to force-push after running git filter-repo?&lt;/strong&gt;&lt;br&gt;
It is safe for your repo if you use &lt;code&gt;--force-with-lease&lt;/code&gt; and coordinate with collaborators, who will need to re-clone. It is not a fix for leaked secrets, which must be rotated regardless.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;If the file should never have been committed, run &lt;code&gt;git filter-repo&lt;/code&gt; on a fresh clone and force-push once. If you genuinely need large files versioned alongside code, Git LFS is the standard route on GitHub, with a quota to monitor. For datasets and model weights that change often, keep them in object storage and let Git hold only the script that fetches them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/postgres-as-a-job-queue-vs-redis-vs-sqs-when-does-just-use-your-database-stop-working-1f24"&gt;Postgres as a Job Queue vs Redis vs SQS: When Does "Just Use Your Database" Stop Working?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/railway-vs-render-vs-flyio-deploying-a-hobby-app-without-a-devops-team-10d3"&gt;Railway vs Render vs Fly.io: Deploying a Hobby App Without a DevOps Team&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/monorepo-or-polyrepo-for-a-three-person-startup-a-decision-framework-51nd"&gt;Monorepo or Polyrepo for a Three-Person Startup? A Decision Framework&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>github</category>
      <category>tutorial</category>
      <category>tooling</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Sentry vs GlitchTip vs Self-Hosted Sentry: Error Tracking for a Small Team When the Event Quota Keeps Running Out</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Thu, 22 Oct 2026 21:31:45 +0000</pubDate>
      <link>https://dev.to/libme/sentry-vs-glitchtip-vs-self-hosted-sentry-error-tracking-for-a-small-team-when-the-event-quota-2bnn</link>
      <guid>https://dev.to/libme/sentry-vs-glitchtip-vs-self-hosted-sentry-error-tracking-for-a-small-team-when-the-event-quota-2bnn</guid>
      <description>&lt;p&gt;For most small teams, hosted Sentry is the right default, and the thing that decides whether it stays affordable is not the vendor but how many duplicate events your app emits. GlitchTip is a credible cheaper alternative if you only need error tracking and are happy with the Sentry SDKs. Self-hosting full Sentry is the option I would pick last, because the cost moves from a bill to your on-call time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does the error quota run out in the first week?
&lt;/h2&gt;

&lt;p&gt;The usual story: you add error tracking, everything looks calm, then a downstream API goes down for twenty minutes. A retry loop or a queue consumer throws the same exception on every attempt, and a plan priced by event volume absorbs thousands of identical events. The dashboard shows one issue with a huge count. The quota is gone and the useful errors that arrive afterwards are dropped or throttled.&lt;/p&gt;

&lt;p&gt;Hosted tools price by events (or similar units) per month, and exact tiers change, so check the current pricing page rather than trusting a blog post. What does not change is the shape of the problem: volume is driven by your worst incident, not your normal day. A single noisy failure mode can cost more quota than a month of ordinary errors.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: error-tracking cost is set by your noisiest incident, so cap duplicates in the client before comparing vendors.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix the volume first: client-side dedupe and sampling
&lt;/h2&gt;

&lt;p&gt;Before you switch tools, drop repeats at the source. The Sentry JavaScript/Node SDK exposes &lt;code&gt;beforeSend&lt;/code&gt;, &lt;code&gt;ignoreErrors&lt;/code&gt; and &lt;code&gt;sampleRate&lt;/code&gt;, and GlitchTip accepts events from the same SDKs, so this work carries over if you move.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Sentry&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@sentry/node&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;WINDOW_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MAX_PER_WINDOW&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fingerprintOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ErrorEvent&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;?.[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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;Sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;dsn&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;SENTRY_DSN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// Network noise you cannot act on&lt;/span&gt;
  &lt;span class="na"&gt;ignoreErrors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ECONNRESET&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;AbortError&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="nf"&gt;beforeSend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fingerprintOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;WINDOW_MS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// Returning null drops the event before it counts against quota&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;MAX_PER_WINDOW&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two caveats from using this pattern. The &lt;code&gt;Map&lt;/code&gt; grows with distinct messages, so if your error text embeds IDs you should key on the type and a normalized message, or add periodic cleanup. And a per-process limiter is per process: ten replicas each send their own five events per minute. That is still a large reduction from unbounded.&lt;/p&gt;

&lt;p&gt;Hosted Sentry also offers server-side controls such as spike protection and inbound filters (as of late 2026 these are plan-dependent in places, so verify on your tier). Server-side filtering is a safety net, but dropping events in the client means they never reach the quota at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: a ten-line &lt;code&gt;beforeSend&lt;/code&gt; limiter removes the failure mode that makes every error-tracking plan look too small.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Sentry vs GlitchTip vs self-hosted Sentry: what actually differs?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Hosted Sentry&lt;/th&gt;
&lt;th&gt;GlitchTip (hosted or self-hosted)&lt;/th&gt;
&lt;th&gt;Self-hosted Sentry&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SDK&lt;/td&gt;
&lt;td&gt;Native Sentry SDKs&lt;/td&gt;
&lt;td&gt;Sentry-compatible SDKs, change the DSN&lt;/td&gt;
&lt;td&gt;Native Sentry SDKs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scope&lt;/td&gt;
&lt;td&gt;Errors, performance, session replay, profiling, more&lt;/td&gt;
&lt;td&gt;Mostly error tracking, plus basic uptime and performance features&lt;/td&gt;
&lt;td&gt;Broad, close to hosted feature set&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ops burden&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Low: a small app plus Postgres and a queue&lt;/td&gt;
&lt;td&gt;High: many services to run and upgrade&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost driver&lt;/td&gt;
&lt;td&gt;Event volume and add-ons&lt;/td&gt;
&lt;td&gt;Hosted: plan tier; self-hosted: your server&lt;/td&gt;
&lt;td&gt;Server size and your time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Main drawback&lt;/td&gt;
&lt;td&gt;Quota anxiety, add-ons priced separately&lt;/td&gt;
&lt;td&gt;Smaller feature set, smaller ecosystem&lt;/td&gt;
&lt;td&gt;Upgrades and resource needs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;GlitchTip is open source, and because it speaks the Sentry ingestion protocol you can point an existing SDK at it by swapping the DSN. In my testing of that swap on a small Node service, errors, stack traces and breadcrumbs arrived without code changes. What you give up is breadth: if you rely on session replay, deep performance tracing or the newer AI-assisted triage features, expect gaps, and verify against GlitchTip's current docs before committing.&lt;/p&gt;

&lt;p&gt;Self-hosted Sentry is the full product, but it runs as a large set of containers (databases, queues, ingestion workers and more) and its own docs describe a substantial minimum resource footprint. Upgrades are guided by an install script and are not a casual weekend task. For a team of two to five people, I have seen the hidden cost land as "nobody wants to own the upgrade", which is how an error tracker quietly stops working.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: the SDK is portable across all three, so the real choice is how much of the platform you want to operate.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When is hosted Sentry worth paying for?
&lt;/h2&gt;

&lt;p&gt;If you want one place for errors, performance traces and release health with no infrastructure, hosted Sentry is the product that covers all of that out of the box, and a paid plan is justified once errors are tied to revenue and someone is on call. The drawbacks are real: pricing is usage-based, so a bad incident can push you into overage or throttling, and optional features are billed separately.&lt;/p&gt;

&lt;p&gt;If you only need "tell me when production throws and show me the stack trace", GlitchTip is the one that does that with the least overhead, and it is cheap or free to run on a server you already have. Its limitation is that anything beyond core error tracking is thinner than the original.&lt;/p&gt;

&lt;p&gt;Self-hosted Sentry makes sense mainly when data residency or policy forbids a third-party SaaS and you have someone who will own upgrades.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: pay for hosted Sentry when breadth matters, pick GlitchTip when errors are all you need, and self-host full Sentry only under a compliance constraint.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is GlitchTip compatible with the Sentry SDK?&lt;/strong&gt;&lt;br&gt;
Yes, GlitchTip accepts events from Sentry client SDKs, so you usually only change the DSN. Features beyond error ingestion, like replay, may not be available.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I stop one error from using my whole Sentry quota?&lt;/strong&gt;&lt;br&gt;
Drop repeats in the SDK with &lt;code&gt;beforeSend&lt;/code&gt; and &lt;code&gt;ignoreErrors&lt;/code&gt;, and use &lt;code&gt;sampleRate&lt;/code&gt; for high-volume noise. Server-side spike protection helps, but client-side filtering keeps events from being counted at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is self-hosting Sentry cheaper than the hosted plan?&lt;/strong&gt;&lt;br&gt;
Only if your time is free. The server cost is modest compared with the upgrade and maintenance effort of running a multi-service stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;Start with hosted Sentry and a &lt;code&gt;beforeSend&lt;/code&gt; limiter; most teams never need to leave. Move to GlitchTip if your bill is driven by volume and you only use error tracking. Self-host full Sentry only when policy forces it and an owner is named. Whatever you choose, test an incident: throw the same error 10,000 times in staging and see what the dashboard and your quota do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/how-to-grade-search-relevance-before-you-touch-the-ranking-weights-3554"&gt;How to Grade Search Relevance Before You Touch the Ranking Weights&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/the-real-break-even-for-ai-coding-tools-includes-review-time-not-just-typing-saved-48nb"&gt;The Real Break-Even for AI Coding Tools Includes Review Time, Not Just Typing Saved&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/your-metrics-bill-is-a-cardinality-bill-how-to-price-prometheus-grafana-cloud-and-datadog-5852"&gt;Your Metrics Bill Is a Cardinality Bill: How to Price Prometheus, Grafana Cloud, and Datadog&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
      <category>saas</category>
      <category>opensource</category>
      <category>tooling</category>
    </item>
    <item>
      <title>Docker "toomanyrequests: You have reached your pull rate limit" in CI: Log In, Mirror, or Run a Pull-Through Cache?</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Tue, 20 Oct 2026 21:10:08 +0000</pubDate>
      <link>https://dev.to/libme/docker-toomanyrequests-you-have-reached-your-pull-rate-limit-in-ci-log-in-mirror-or-run-a-4h3e</link>
      <guid>https://dev.to/libme/docker-toomanyrequests-you-have-reached-your-pull-rate-limit-in-ci-log-in-mirror-or-run-a-4h3e</guid>
      <description>&lt;p&gt;If your CI pipeline suddenly fails with &lt;code&gt;toomanyrequests&lt;/code&gt; on &lt;code&gt;docker pull&lt;/code&gt; or &lt;code&gt;FROM&lt;/code&gt;, Docker Hub is throttling the IP your runners share, not your code. The fastest fix is to authenticate the pull with &lt;code&gt;docker login&lt;/code&gt;; the durable fix is to stop pulling the same base images from Docker Hub on every run, using a registry mirror or a pull-through cache. Which one you need depends on how many pulls you make and who owns the IP.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the error actually look like?
&lt;/h2&gt;

&lt;p&gt;The text varies slightly by Docker version, but the shape is stable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR: failed to solve: ubuntu:24.04: failed to resolve source metadata for
docker.io/library/ubuntu:24.04: unexpected status from HEAD request to
https://registry-1.docker.io/v2/library/ubuntu/manifests/24.04: 429 Too Many Requests

toomanyrequests: You have reached your pull rate limit. You may increase the limit
by authenticating and upgrading: https://www.docker.com/increase-rate-limit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What tripped me up the first time: the job had passed for weeks and nothing in the repo changed. The limit is counted per source, not per project. Unauthenticated pulls are counted against the client IP, and hosted CI runners share IP ranges with many other tenants. Your pipeline can fail because a stranger's pipeline pulled too much. Once you authenticate, the count moves to your account.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Takeaway: a rate-limit failure that appears with no code change usually means the shared IP's quota ran out, not that your pipeline got heavier.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I check how many pulls I have left?
&lt;/h2&gt;

&lt;p&gt;Docker documents a special repository, &lt;code&gt;ratelimitpreview/test&lt;/code&gt;, that returns your remaining quota in response headers without spending a pull. Run this from the machine (or CI step) that is failing:&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="nv"&gt;TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://auth.docker.io/token?service=registry.docker.io&amp;amp;scope=repository:ratelimitpreview/test:pull"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; .token&lt;span class="si"&gt;)&lt;/span&gt;

curl &lt;span class="nt"&gt;-sI&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://registry-1.docker.io/v2/ratelimitpreview/test/manifests/latest &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; ratelimit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You will see &lt;code&gt;ratelimit-limit&lt;/code&gt; and &lt;code&gt;ratelimit-remaining&lt;/code&gt;, each with a window (for example &lt;code&gt;;w=21600&lt;/code&gt; for six hours). To see the authenticated numbers, pass your credentials to the token request with &lt;code&gt;curl -u "$USER:$PAT"&lt;/code&gt;. Comparing the two outputs is the quickest way to confirm that logging in actually changes your bucket.&lt;/p&gt;

&lt;p&gt;The exact limits and windows have changed over the years and Docker has revised the free-tier numbers more than once, so read the current figures on Docker's pull-usage documentation rather than trusting any blog post, including this one (as of October 2026, I would not hardcode a number anywhere).&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Takeaway: read &lt;code&gt;ratelimit-remaining&lt;/code&gt; before changing anything, so you know whether you are fixing the right problem.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 1: authenticate every pull (five minutes)
&lt;/h2&gt;

&lt;p&gt;In GitHub Actions, log in before anything touches the registry. Use a personal access token with read-only scope, not your account password:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/login-action@v3&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.DOCKERHUB_USERNAME }}&lt;/span&gt;
          &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.DOCKERHUB_TOKEN }}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/setup-buildx-action@v3&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/build-push-action@v6&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
          &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two gotchas. First, &lt;code&gt;docker/login-action&lt;/code&gt; makes the login visible to Buildx builds, but a plain &lt;code&gt;docker pull&lt;/code&gt; inside a nested container or a &lt;code&gt;services:&lt;/code&gt; image may use a different credential path. Second, secrets are empty in pull requests from forks (I wrote about that separately), so fork PRs fall back to anonymous pulls and can still hit the limit.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Takeaway: logging in is the cheapest fix, but it only helps jobs that can see the secret.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 2: stop pulling from Docker Hub at all
&lt;/h2&gt;

&lt;p&gt;Most of the pulls in a typical pipeline are a handful of official base images: &lt;code&gt;node&lt;/code&gt;, &lt;code&gt;python&lt;/code&gt;, &lt;code&gt;postgres&lt;/code&gt;, &lt;code&gt;redis&lt;/code&gt;. Two options remove them from Docker Hub's accounting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use a mirror of the official images.&lt;/strong&gt; Amazon publishes copies of Docker's official images on its public registry, so &lt;code&gt;FROM public.ecr.aws/docker/library/node:22-slim&lt;/code&gt; pulls the same library image from a different provider. The limitation is that only the official library images are mirrored there, not arbitrary third-party images, and you depend on that copy staying in sync (check the tag you pin actually exists before you rewrite every Dockerfile).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Run a pull-through cache.&lt;/strong&gt; A cache sits between your runners and Docker Hub, stores each layer once, and serves it to every later job. The open source &lt;code&gt;registry&lt;/code&gt; image can do this in a few lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config.yml for the registry:2 image&lt;/span&gt;
&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.1&lt;/span&gt;
&lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;filesystem&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;rootdirectory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/var/lib/registry&lt;/span&gt;
&lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;addr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;:5000&lt;/span&gt;
&lt;span class="na"&gt;proxy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;remoteurl&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://registry-1.docker.io&lt;/span&gt;
  &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;your-dockerhub-user&lt;/span&gt;
  &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;your-dockerhub-read-token&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then point the Docker daemon at it with &lt;code&gt;"registry-mirrors": ["http://your-cache:5000"]&lt;/code&gt; in &lt;code&gt;/etc/docker/daemon.json&lt;/code&gt;. Note that this mirror setting only applies to Docker Hub images, which is exactly the case you are fixing. Cloud registries offer managed versions of the same idea (Amazon ECR's pull-through cache can front Docker Hub when you give it credentials), which removes the server you would otherwise babysit.&lt;/p&gt;

&lt;p&gt;The drawbacks are real. A self-run cache is another service that can go down and block every build, it needs disk cleanup, and on ephemeral hosted runners there is no private network to put it on. If your builds run on self-hosted runners in one network, a cache pays off quickly; on hosted runners, prefer the mirror or login.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Takeaway: a cache turns N pulls per day into one, but only if your runners can actually reach it.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Which option fits which setup?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Best fix&lt;/th&gt;
&lt;th&gt;Main drawback&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hosted CI, few images, secrets available&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;docker login&lt;/code&gt; with a read-only token&lt;/td&gt;
&lt;td&gt;Fork PRs still anonymous&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hosted CI, mostly official base images&lt;/td&gt;
&lt;td&gt;Mirror via &lt;code&gt;public.ecr.aws/docker/library&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Official images only; tag sync&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-hosted runners, many pipelines&lt;/td&gt;
&lt;td&gt;Pull-through cache&lt;/td&gt;
&lt;td&gt;You run and patch another service&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Already on AWS with ECR&lt;/td&gt;
&lt;td&gt;ECR pull-through cache&lt;/td&gt;
&lt;td&gt;Setup of credentials and repo prefixes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Publishing your own images too&lt;/td&gt;
&lt;td&gt;Push to GHCR or ECR, keep Hub as a pull source&lt;/td&gt;
&lt;td&gt;Migration of image names&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you are choosing a place for your own images, GitHub's container registry is the lowest-friction choice when your code already lives on GitHub, since &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; can authenticate the push without another secret.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Takeaway: pick by where your runners live, not by which fix sounds most robust.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why does docker pull say toomanyrequests when I only pulled once?&lt;/strong&gt;&lt;br&gt;
Unauthenticated pulls are counted per IP address, so shared CI or office NAT addresses burn the quota across many users. Authenticating moves the count to your account.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do HEAD requests count toward the Docker Hub rate limit?&lt;/strong&gt;&lt;br&gt;
Docker's documentation says manifest HEAD requests do not count as pulls, which is why the &lt;code&gt;ratelimitpreview&lt;/code&gt; check above is free. The &lt;code&gt;429&lt;/code&gt; in a build log can still appear on a HEAD when you are already over the limit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I avoid Docker Hub rate limits without paying?&lt;/strong&gt;&lt;br&gt;
Authenticate with a free-account token, pin base images to a mirror such as the Amazon ECR public registry copy of the official images, and cache layers in CI so unchanged layers are not re-pulled.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;If you are on hosted CI with a handful of images, add &lt;code&gt;docker/login-action&lt;/code&gt; with a read-only token today and check &lt;code&gt;ratelimit-remaining&lt;/code&gt; to confirm it worked. If most of your pulls are official images, move the &lt;code&gt;FROM&lt;/code&gt; lines to a mirror and you are mostly done. Only build a pull-through cache when you run self-hosted runners at a scale where a mirror's gaps start to hurt, and plan for it being one more thing that can break your builds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/build-vs-buy-authentication-in-2026-auth0-clerk-supabase-auth-or-roll-your-own-3mc8"&gt;Build vs Buy: Authentication in 2026 (Auth0, Clerk, Supabase Auth, or Roll Your Own)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/curl-returns-200-but-your-http-client-gets-a-404-debugging-vary-and-cdn-cache-variants-38je"&gt;curl Returns 200 but Your HTTP Client Gets a 404: Debugging Vary and CDN Cache Variants&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/caddy-vs-nginx-vs-traefik-which-reverse-proxy-for-one-small-app-on-one-server-cc2"&gt;Caddy vs Nginx vs Traefik: Which Reverse Proxy for One Small App on One Server?&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>docker</category>
      <category>cicd</category>
      <category>devops</category>
      <category>tooling</category>
    </item>
  </channel>
</rss>
