<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Haunted Pavement</title><description>Evan is a geographer and environmental scientist who enjoys finding novel intersections of spatial data and natural sciences. This site is a living graveyard of past, present, and future projects and experiments that may entertain or educate.</description><link>https://hauntedpavement.com/</link><language>en</language><atom:link href="https://hauntedpavement.com/rss.xml" rel="self" type="application/rss+xml"/><item><title>Caddy and Astro on one small VPS</title><link>https://hauntedpavement.com/posts/caddy-and-astro-on-one-vps/</link><guid isPermaLink="true">https://hauntedpavement.com/posts/caddy-and-astro-on-one-vps/</guid><description>Serving a static Astro site with Caddy next to other apps, with a deploy that only swaps in a build that passed.</description><pubDate>Sat, 17 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;This site is a folder of static files on a small ARM server. The same server runs the coding agents that help build it, a PostgreSQL that every app shares, and a Node app. Caddy sits in front of all of it. This post covers how that fits together: the Caddy block, a deploy that can’t take the live site down with a bad build, and a build that refuses to pass with a warning in its output.&lt;/p&gt;
&lt;h2 id=&quot;one-server-several-apps&quot;&gt;One server, several apps&lt;/h2&gt;
&lt;p&gt;The rule for the box is that each app gets three things: a domain, a live copy in &lt;code&gt;/srv/apps/&amp;lt;app&amp;gt;&lt;/code&gt;, and a line in a server map that says what runs where. A static site like this one is served straight from files. A Node app runs as a systemd user unit and gets its own port, and Caddy proxies its domain to it.&lt;/p&gt;
&lt;p&gt;There’s no Docker and no connection pooler. PostgreSQL already runs natively on the server, one instance with a database per app, and plain systemd services are fewer moving parts on a box with one owner. When a new app needs a database, it gets &lt;code&gt;createdb&lt;/code&gt;, not a container.&lt;/p&gt;
&lt;p&gt;The live copy is a separate checkout from the ones where work happens. Agents and I edit in our own checkouts and worktrees, push a branch, and merge to &lt;code&gt;main&lt;/code&gt;. Only the deploy script touches &lt;code&gt;/srv/apps/&lt;/code&gt;. That separation is what lets several pieces of work go on at once without anyone breaking the live site halfway through an edit.&lt;/p&gt;
&lt;h2 id=&quot;the-caddy-block&quot;&gt;The Caddy block&lt;/h2&gt;
&lt;p&gt;At launch, all of hauntedpavement.com was this:&lt;/p&gt;
&lt;pre class=&quot;astro-code haunted-pavement&quot; style=&quot;background-color:var(--code-bg);color:var(--code-fg); overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;hauntedpavement.com {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;	root * /srv/apps/hauntedpavement/.live&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;	file_server&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;www.hauntedpavement.com {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;	redir https://hauntedpavement.com{uri} permanent&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Caddy gets and renews the HTTPS certificates by itself, so there’s nothing else to configure for TLS. The &lt;code&gt;www&lt;/code&gt; block sends everything to the bare domain, keeping the path.&lt;/p&gt;
&lt;p&gt;One detail of &lt;code&gt;file_server&lt;/code&gt; shaped the Astro config. A request for &lt;code&gt;/posts&lt;/code&gt; finds a directory, and Caddy redirects it to &lt;code&gt;/posts/&lt;/code&gt;. Astro builds every page as &lt;code&gt;&amp;lt;path&amp;gt;/index.html&lt;/code&gt;, so the site uses the trailing-slash form everywhere, and every link it prints is already the URL Caddy serves without a redirect:&lt;/p&gt;
&lt;pre class=&quot;astro-code haunted-pavement&quot; style=&quot;background-color:var(--code-bg);color:var(--code-fg); overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;js&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-keyword)&quot;&gt;export&lt;/span&gt;&lt;span style=&quot;color:var(--code-keyword)&quot;&gt; default&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt; defineConfig({&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;  site: &lt;/span&gt;&lt;span style=&quot;color:var(--code-string)&quot;&gt;&apos;https://hauntedpavement.com&apos;&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;  trailingSlash: &lt;/span&gt;&lt;span style=&quot;color:var(--code-string)&quot;&gt;&apos;always&apos;&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;  build: { format: &lt;/span&gt;&lt;span style=&quot;color:var(--code-string)&quot;&gt;&apos;directory&apos;&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;, inlineStylesheets: &lt;/span&gt;&lt;span style=&quot;color:var(--code-string)&quot;&gt;&apos;always&apos;&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt; },&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-comment);font-style:italic&quot;&gt;  // …&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;});&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;trailingSlash: &apos;always&apos;&lt;/code&gt; also makes the dev server reject the other form, so a link without the slash shows up while writing, not in production.&lt;/p&gt;
&lt;h2 id=&quot;a-deploy-that-cant-break-the-live-site&quot;&gt;A deploy that can’t break the live site&lt;/h2&gt;
&lt;p&gt;Publishing is one command, &lt;code&gt;deploy hauntedpavement&lt;/code&gt;. Each app has a small settings file that names its kind (&lt;code&gt;static&lt;/code&gt; or &lt;code&gt;node&lt;/code&gt;), its domain, and its build output folder. For a static app, the script does this:&lt;/p&gt;
&lt;pre class=&quot;astro-code haunted-pavement&quot; style=&quot;background-color:var(--code-bg);color:var(--code-fg); overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;git &lt;/span&gt;&lt;span style=&quot;color:var(--code-string)&quot;&gt;fetch&lt;/span&gt;&lt;span style=&quot;color:var(--code-constant)&quot;&gt; -q&lt;/span&gt;&lt;span style=&quot;color:var(--code-string)&quot;&gt; origin&lt;/span&gt;&lt;span style=&quot;color:var(--code-string)&quot;&gt; &quot;&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;$&lt;/span&gt;&lt;span style=&quot;color:var(--code-string)&quot;&gt;BRANCH&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;git &lt;/span&gt;&lt;span style=&quot;color:var(--code-string)&quot;&gt;reset&lt;/span&gt;&lt;span style=&quot;color:var(--code-constant)&quot;&gt; -q&lt;/span&gt;&lt;span style=&quot;color:var(--code-constant)&quot;&gt; --hard&lt;/span&gt;&lt;span style=&quot;color:var(--code-string)&quot;&gt; &quot;origin/&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;$&lt;/span&gt;&lt;span style=&quot;color:var(--code-string)&quot;&gt;BRANCH&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;build || die &lt;/span&gt;&lt;span style=&quot;color:var(--code-string)&quot;&gt;&quot;build failed; the live site is unchanged&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;mkdir &lt;/span&gt;&lt;span style=&quot;color:var(--code-constant)&quot;&gt;-p&lt;/span&gt;&lt;span style=&quot;color:var(--code-string)&quot;&gt; .live&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;rsync &lt;/span&gt;&lt;span style=&quot;color:var(--code-constant)&quot;&gt;-a&lt;/span&gt;&lt;span style=&quot;color:var(--code-constant)&quot;&gt; --delete&lt;/span&gt;&lt;span style=&quot;color:var(--code-string)&quot;&gt; &quot;&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;$&lt;/span&gt;&lt;span style=&quot;color:var(--code-string)&quot;&gt;OUT/&quot;&lt;/span&gt;&lt;span style=&quot;color:var(--code-string)&quot;&gt; .live/&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Caddy never serves &lt;code&gt;dist/&lt;/code&gt;. It serves &lt;code&gt;.live/&lt;/code&gt;, and &lt;code&gt;.live/&lt;/code&gt; only changes after &lt;code&gt;pnpm install --frozen-lockfile&lt;/code&gt; and &lt;code&gt;pnpm build&lt;/code&gt; have both passed. The script also checks that the output has a home page, and stops if it doesn’t. Astro empties &lt;code&gt;dist/&lt;/code&gt; at the start of every build, so serving it directly would mean a failed build leaves an empty site behind. With the copy step in between, a failed build leaves the live site exactly as it was, and the script says so.&lt;/p&gt;
&lt;p&gt;The copy itself isn’t atomic: for the moment &lt;code&gt;rsync&lt;/code&gt; runs, the folder holds a mix of the old and new files. For a personal site that’s a fair trade. If it ever matters, the next step is to build into a fresh folder per release and swap a symlink to it.&lt;/p&gt;
&lt;p&gt;A Node app gets the same idea in a different shape. A failed build resets the checkout to the commit that was live, rebuilds it and restarts the service, so the app keeps running the last version that worked.&lt;/p&gt;
&lt;h2 id=&quot;a-build-that-fails-on-any-warning&quot;&gt;A build that fails on any warning&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;deploy&lt;/code&gt; runs &lt;code&gt;pnpm build&lt;/code&gt;, which means whatever &lt;code&gt;pnpm build&lt;/code&gt; checks guards every deploy too. On this site it checks more than Astro does.&lt;/p&gt;
&lt;p&gt;First, a short script reads every code file under &lt;code&gt;src/&lt;/code&gt; and fails, naming the file and line, if anything outside one module reads the content collections. That module, &lt;code&gt;src/lib/content.ts&lt;/code&gt;, is the only place that applies the rule “drafts aren’t published”. A page that called &lt;code&gt;getCollection()&lt;/code&gt; itself would skip that rule, and a draft could leak through one route, such as a tag page.&lt;/p&gt;
&lt;p&gt;Then the build runs through a wrapper that watches its output line by line and fails if any line looks like a warning:&lt;/p&gt;
&lt;pre class=&quot;astro-code haunted-pavement&quot; style=&quot;background-color:var(--code-bg);color:var(--code-fg); overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;js&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-keyword)&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:var(--code-constant)&quot;&gt; WARNING_PATTERNS&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt; = [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-string)&quot;&gt;  /&lt;/span&gt;&lt;span style=&quot;color:var(--code-constant)&quot;&gt;\[&lt;/span&gt;&lt;span style=&quot;color:var(--code-string)&quot;&gt;WARN&lt;/span&gt;&lt;span style=&quot;color:var(--code-constant)&quot;&gt;\]&lt;/span&gt;&lt;span style=&quot;color:var(--code-string)&quot;&gt;/&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;color:var(--code-string)&quot;&gt; /&lt;/span&gt;&lt;span style=&quot;color:var(--code-constant)&quot;&gt;\[&lt;/span&gt;&lt;span style=&quot;color:var(--code-string)&quot;&gt;Shiki&lt;/span&gt;&lt;span style=&quot;color:var(--code-constant)&quot;&gt;\]&lt;/span&gt;&lt;span style=&quot;color:var(--code-string)&quot;&gt;/&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;color:var(--code-string)&quot;&gt; /&lt;/span&gt;&lt;span style=&quot;color:var(--code-keyword)&quot;&gt;^&lt;/span&gt;&lt;span style=&quot;color:var(--code-constant)&quot;&gt;\(&lt;/span&gt;&lt;span style=&quot;color:var(--code-string)&quot;&gt;!&lt;/span&gt;&lt;span style=&quot;color:var(--code-constant)&quot;&gt;\)&lt;/span&gt;&lt;span style=&quot;color:var(--code-string)&quot;&gt;/&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;color:var(--code-string)&quot;&gt; /Warning:/&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;];&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The patterns cover Astro’s own warnings, Shiki’s (an unknown language in a code fence), and Node’s (&lt;code&gt;DeprecationWarning:&lt;/code&gt;, &lt;code&gt;ExperimentalWarning:&lt;/code&gt; and the rest). Astro is happy to build a site with warnings, and that’s how a missing code language or a deprecated option can go unnoticed for months. Here the first such line fails the build, and so blocks the deploy, until its cause is fixed.&lt;/p&gt;
&lt;p&gt;The wrapper reads the output with &lt;code&gt;readline&lt;/code&gt;, so a pattern split across two chunks of output still matches. It sets &lt;code&gt;NO_COLOR&lt;/code&gt; so colour codes can’t split a pattern either. It also drops &lt;code&gt;FORCE_COLOR&lt;/code&gt;, because the test runner sets it, and with both set Node prints a warning of its own, which the gate would then catch.&lt;/p&gt;
&lt;p&gt;The end-to-end tests build a second copy of the site from fixture content, into a separate folder, and the production build refuses to start if the variable that points it at fixtures is set. Test content can’t reach a deploy by accident.&lt;/p&gt;
&lt;h2 id=&quot;added-after-launch&quot;&gt;Added after launch&lt;/h2&gt;
&lt;p&gt;The first block was the minimum. It didn’t compress responses, and a missing page got Caddy’s empty 404 instead of the site’s own. A few lines fixed both, and this is the block that runs now:&lt;/p&gt;
&lt;pre class=&quot;astro-code haunted-pavement&quot; style=&quot;background-color:var(--code-bg);color:var(--code-fg); overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;hauntedpavement.com {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;	root * /srv/apps/hauntedpavement/.live&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;	encode zstd gzip&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;	header /_astro/* Cache-Control &quot;max-age=31536000, immutable&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;	file_server&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;	handle_errors 404 {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;		rewrite * /404.html&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;		file_server&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;	}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Files under &lt;code&gt;/_astro/&lt;/code&gt; have a content hash in their name, so a changed file gets a new URL and the old one can be cached for a year. Pages keep their URLs, so they don’t get that header.&lt;/p&gt;
</content:encoded><category>caddy</category><category>astro</category></item><item><title>Lighthouse on an arm64 VPS with Playwright&apos;s Chromium</title><link>https://hauntedpavement.com/notes/lighthouse-on-arm64/</link><guid isPermaLink="true">https://hauntedpavement.com/notes/lighthouse-on-arm64/</guid><description>How this site runs Lighthouse from a Playwright test on an arm64 server, using Playwright&apos;s own Chromium and a debugging port the browser picks itself.</description><pubDate>Sat, 17 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;This site has to score at least 0.95 in every Lighthouse category (Lighthouse’s API scores from 0 to 1), on the home page and on a test post of a fixture build, checked on every test run rather than once by hand. The tests run on a small ARM server with Ubuntu 24.04 and no desktop browser, and Playwright’s own arm64 Chromium is already installed for the end-to-end tests. So Lighthouse runs inside a Playwright test, against that Chromium.&lt;/p&gt;
&lt;h2 id=&quot;why-not-the-lighthouse-clis-own-chrome&quot;&gt;Why not the Lighthouse CLI’s own Chrome&lt;/h2&gt;
&lt;p&gt;The Lighthouse CLI starts its own browser through &lt;code&gt;chrome-launcher&lt;/code&gt;. On this server it finds none:&lt;/p&gt;
&lt;pre class=&quot;astro-code haunted-pavement&quot; style=&quot;background-color:var(--code-bg);color:var(--code-fg); overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;The CHROME_PATH environment variable must be set to a&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Chrome/Chromium executable no older than Chrome stable.&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Pointing &lt;code&gt;CHROME_PATH&lt;/code&gt; at Playwright’s Chromium gets further, then stops at “waiting for dynamic debugging port”. Chromium’s error log says why:&lt;/p&gt;
&lt;pre class=&quot;astro-code haunted-pavement&quot; style=&quot;background-color:var(--code-bg);color:var(--code-fg); overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;FATAL: No usable sandbox! If you are running on Ubuntu 23.10+&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;or another Linux distro that has disabled unprivileged user&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;namespaces with AppArmor, see …&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Ubuntu 23.10 and later restrict unprivileged user namespaces through AppArmor, and Chromium’s sandbox needs them. Adding &lt;code&gt;--no-sandbox&lt;/code&gt; to &lt;code&gt;--chrome-flags&lt;/code&gt;, next to &lt;code&gt;--headless=new&lt;/code&gt; since there’s no display, makes the CLI work:&lt;/p&gt;
&lt;pre class=&quot;astro-code haunted-pavement&quot; style=&quot;background-color:var(--code-bg);color:var(--code-fg); overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;CHROME_PATH=&lt;/span&gt;&lt;span style=&quot;color:var(--code-string)&quot;&gt;~/.cache/ms-playwright/chromium-1208/chrome-linux/chrome&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-string)&quot;&gt;  lighthouse&lt;/span&gt;&lt;span style=&quot;color:var(--code-string)&quot;&gt; https://example.com&lt;/span&gt;&lt;span style=&quot;color:var(--code-constant)&quot;&gt; --chrome-flags=&lt;/span&gt;&lt;span style=&quot;color:var(--code-string)&quot;&gt;&quot;--headless=new --no-sandbox&quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Playwright already launches Chromium without the sandbox; its &lt;code&gt;chromiumSandbox&lt;/code&gt; option defaults to &lt;code&gt;false&lt;/code&gt;. Running Lighthouse from a test therefore needs no extra flag, uses the same browser build as the rest of the suite, and puts its reports in the same HTML report. Turning the sandbox off is fine for auditing your own pages; for untrusted ones, see &lt;a href=&quot;https://chromium.googlesource.com/chromium/src/+/main/docs/security/apparmor-userns-restrictions.md&quot;&gt;Chromium’s AppArmor notes&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;port-0-and-devtoolsactiveport&quot;&gt;Port 0 and DevToolsActivePort&lt;/h2&gt;
&lt;p&gt;Lighthouse drives the browser over the Chrome DevTools Protocol, so it needs a debugging port. A fixed port such as 9222 can collide with anything else on the server. Port 0 asks Chromium to pick a free one and write it to a file named &lt;code&gt;DevToolsActivePort&lt;/code&gt; in its profile directory.&lt;/p&gt;
&lt;p&gt;That’s why the test uses a persistent context: its profile directory is one the test chose, so it knows where the file will appear. &lt;code&gt;chromium.launch()&lt;/code&gt; uses a temporary profile whose path the test never sees.&lt;/p&gt;
&lt;pre class=&quot;astro-code haunted-pavement&quot; style=&quot;background-color:var(--code-bg);color:var(--code-fg); overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;ts&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;profileDir = &lt;/span&gt;&lt;span style=&quot;color:var(--code-keyword)&quot;&gt;await&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt; mkdtemp(join(tmpdir(), &lt;/span&gt;&lt;span style=&quot;color:var(--code-string)&quot;&gt;&apos;hp-lighthouse-&apos;&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;));&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;context = &lt;/span&gt;&lt;span style=&quot;color:var(--code-keyword)&quot;&gt;await&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt; chromium.launchPersistentContext(profileDir, {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;  channel: &lt;/span&gt;&lt;span style=&quot;color:var(--code-string)&quot;&gt;&apos;chromium&apos;&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;  args: [&lt;/span&gt;&lt;span style=&quot;color:var(--code-string)&quot;&gt;&apos;--remote-debugging-port=0&apos;&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;],&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;});&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;port = &lt;/span&gt;&lt;span style=&quot;color:var(--code-keyword)&quot;&gt;await&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt; readDevToolsPort(profileDir);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;channel: &apos;chromium&apos;&lt;/code&gt; selects the full Chromium build in its &lt;a href=&quot;https://playwright.dev/docs/browsers#chromium-new-headless-mode&quot;&gt;new headless mode&lt;/a&gt;, rather than the separate headless shell that Playwright uses by default.&lt;/p&gt;
&lt;p&gt;The file can appear a moment after the launch returns, so &lt;code&gt;readDevToolsPort()&lt;/code&gt; polls it every 100 ms for up to 10 seconds, and accepts only a first line that is all digits. Each run gets a fresh &lt;code&gt;mkdtemp&lt;/code&gt; directory, so a stale file from an earlier run can never be read. The directory is deleted afterwards.&lt;/p&gt;
&lt;p&gt;Lighthouse’s Node API then connects to that port:&lt;/p&gt;
&lt;pre class=&quot;astro-code haunted-pavement&quot; style=&quot;background-color:var(--code-bg);color:var(--code-fg); overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;ts&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-keyword)&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:var(--code-constant)&quot;&gt; result&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt; = &lt;/span&gt;&lt;span style=&quot;color:var(--code-keyword)&quot;&gt;await&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt; lighthouse(url, {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;  port,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;  output: &lt;/span&gt;&lt;span style=&quot;color:var(--code-string)&quot;&gt;&apos;html&apos;&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;  logLevel: &lt;/span&gt;&lt;span style=&quot;color:var(--code-string)&quot;&gt;&apos;error&apos;&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;  onlyCategories: [...&lt;/span&gt;&lt;span style=&quot;color:var(--code-constant)&quot;&gt;CATEGORIES&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;],&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;});&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No configuration is passed, so it audits with the default mobile settings: an emulated phone and simulated throttling.&lt;/p&gt;
&lt;h2 id=&quot;the-scores-as-test-annotations&quot;&gt;The scores as test annotations&lt;/h2&gt;
&lt;p&gt;Each page is one test. It writes the HTML report to &lt;code&gt;test-results/lighthouse/&lt;/code&gt; and attaches it to the test, so the full report opens from the Playwright report. Each category’s score becomes an annotation, which shows the numbers next to the test without opening anything. Then each category gets a soft assertion:&lt;/p&gt;
&lt;pre class=&quot;astro-code haunted-pavement&quot; style=&quot;background-color:var(--code-bg);color:var(--code-fg); overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;ts&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-keyword)&quot;&gt;for&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:var(--code-keyword)&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:var(--code-constant)&quot;&gt; id&lt;/span&gt;&lt;span style=&quot;color:var(--code-keyword)&quot;&gt; of&lt;/span&gt;&lt;span style=&quot;color:var(--code-constant)&quot;&gt; CATEGORIES&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;  expect.soft(categories[id].score, &lt;/span&gt;&lt;span style=&quot;color:var(--code-string)&quot;&gt;`&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;${&lt;/span&gt;&lt;span style=&quot;color:var(--code-string)&quot;&gt;id&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;}&lt;/span&gt;&lt;span style=&quot;color:var(--code-string)&quot;&gt; on &lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;${&lt;/span&gt;&lt;span style=&quot;color:var(--code-string)&quot;&gt;path&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;}&lt;/span&gt;&lt;span style=&quot;color:var(--code-string)&quot;&gt;`&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;    .toBeGreaterThanOrEqual(&lt;/span&gt;&lt;span style=&quot;color:var(--code-constant)&quot;&gt;MIN_SCORE&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;expect.soft&lt;/code&gt; keeps checking after a failure, so one run lists every category below 0.95, not only the first. A &lt;code&gt;runtimeError&lt;/code&gt; in the result fails the test outright, because a run that couldn’t load the page has no meaningful scores.&lt;/p&gt;
&lt;h2 id=&quot;what-it-caught&quot;&gt;What it caught&lt;/h2&gt;
&lt;p&gt;The first run failed. A post scored 0.93 on performance, with a Cumulative Layout Shift of 0.158. The display face used for titles arrived after the first paint. A size-adjusted fallback font can’t match a condensed face’s width, so when it swapped in, the title rewrapped and pushed the article down. Preloading that face next to the body face brought the shift to 0 and performance to 1.0. Every page already downloaded the file, so the fix changed only when it arrives, not how much is sent.&lt;/p&gt;
&lt;p&gt;Both pages now score 1.0 in all four categories, and the suite fails if either drops below 0.95.&lt;/p&gt;
</content:encoded><category>testing</category><category>lighthouse</category><category>playwright</category></item><item><title>Nearest neighbours in PostGIS</title><link>https://hauntedpavement.com/notes/postgis-nearest-neighbor/</link><guid isPermaLink="true">https://hauntedpavement.com/notes/postgis-nearest-neighbor/</guid><description>Nearest-neighbour queries in PostGIS with the &lt;-&gt; operator and a GiST index, LATERAL for one result per row, geometry versus geography, and ST_DWithin to cap the distance.</description><pubDate>Sat, 17 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;“Find the five places closest to this point” sounds simple, and the obvious way to write it is slow. Sorting every row by &lt;code&gt;ST_Distance&lt;/code&gt; makes PostgreSQL compute the distance to every row in the table, then sort them all, to return five. PostGIS has an operator that lets the index do the ordering instead.&lt;/p&gt;
&lt;h2 id=&quot;the---operator-and-a-gist-index&quot;&gt;The &amp;lt;-&amp;gt; operator and a GiST index&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;&amp;lt;-&amp;gt;&lt;/code&gt; returns the distance between two geometries. Its point is that a GiST index can return rows already in order of that distance, nearest first, so a query with &lt;code&gt;LIMIT 5&lt;/code&gt; reads only a handful of rows from the index and stops:&lt;/p&gt;
&lt;pre class=&quot;astro-code haunted-pavement&quot; style=&quot;background-color:var(--code-bg);color:var(--code-fg); overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-keyword)&quot;&gt;CREATE&lt;/span&gt;&lt;span style=&quot;color:var(--code-keyword)&quot;&gt; INDEX&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt; places_geom_idx &lt;/span&gt;&lt;span style=&quot;color:var(--code-keyword)&quot;&gt;ON&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt; places &lt;/span&gt;&lt;span style=&quot;color:var(--code-keyword)&quot;&gt;USING&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt; gist (geom);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-keyword)&quot;&gt;SELECT&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt; id, &lt;/span&gt;&lt;span style=&quot;color:var(--code-keyword)&quot;&gt;name&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-keyword)&quot;&gt;FROM&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt; places&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-keyword)&quot;&gt;ORDER BY&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt; geom &amp;lt;-&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;  ST_SetSRID(ST_MakePoint(-&lt;/span&gt;&lt;span style=&quot;color:var(--code-constant)&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:var(--code-constant)&quot;&gt;1276&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:var(--code-constant)&quot;&gt;51&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:var(--code-constant)&quot;&gt;5072&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;), &lt;/span&gt;&lt;span style=&quot;color:var(--code-constant)&quot;&gt;4326&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-keyword)&quot;&gt;LIMIT&lt;/span&gt;&lt;span style=&quot;color:var(--code-constant)&quot;&gt; 5&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two conditions decide whether the index is used:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The operator has to be in &lt;code&gt;ORDER BY&lt;/code&gt;. In a &lt;code&gt;WHERE&lt;/code&gt; clause or a &lt;code&gt;SELECT&lt;/code&gt; list it’s an ordinary distance function.&lt;/li&gt;
&lt;li&gt;One side has to be fixed for the whole scan: a literal, a parameter, or a scalar subquery that returns one value. A column from another table in a plain join doesn’t count.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;EXPLAIN&lt;/code&gt; shows which you got. The fast plan is an &lt;code&gt;Index Scan using places_geom_idx&lt;/code&gt; with an &lt;code&gt;Order By: (geom &amp;lt;-&amp;gt; ...)&lt;/code&gt; line under it. A &lt;code&gt;Sort&lt;/code&gt; node over a sequential scan means the index isn’t doing the ordering.&lt;/p&gt;
&lt;p&gt;That rule seems to rule out “the nearest hospital to each school”, but a &lt;code&gt;LATERAL&lt;/code&gt; join gets around it. For each outer row, the inner query runs with that row’s geometry as a fixed value, so every inner scan is an index-ordered &lt;code&gt;LIMIT 1&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;astro-code haunted-pavement&quot; style=&quot;background-color:var(--code-bg);color:var(--code-fg); overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-keyword)&quot;&gt;SELECT&lt;/span&gt;&lt;span style=&quot;color:var(--code-constant)&quot;&gt; s&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:var(--code-constant)&quot;&gt;name&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:var(--code-constant)&quot;&gt;h&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:var(--code-constant)&quot;&gt;name&lt;/span&gt;&lt;span style=&quot;color:var(--code-keyword)&quot;&gt; AS&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt; nearest_hospital&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-keyword)&quot;&gt;FROM&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt; schools s&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-keyword)&quot;&gt;CROSS JOIN&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt; LATERAL (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-keyword)&quot;&gt;  SELECT&lt;/span&gt;&lt;span style=&quot;color:var(--code-keyword)&quot;&gt; name&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-keyword)&quot;&gt;  FROM&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt; hospitals&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-keyword)&quot;&gt;  ORDER BY&lt;/span&gt;&lt;span style=&quot;color:var(--code-constant)&quot;&gt; hospitals&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:var(--code-constant)&quot;&gt;geom&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt; &amp;lt;-&amp;gt; &lt;/span&gt;&lt;span style=&quot;color:var(--code-constant)&quot;&gt;s&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:var(--code-constant)&quot;&gt;geom&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-keyword)&quot;&gt;  LIMIT&lt;/span&gt;&lt;span style=&quot;color:var(--code-constant)&quot;&gt; 1&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;) h;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One more trap sits in that first query: &lt;code&gt;ST_MakePoint&lt;/code&gt; takes longitude first, then latitude. Coordinates pasted from a map are usually the other way round, and the query still runs, just somewhere else on the planet.&lt;/p&gt;
&lt;h2 id=&quot;geometry-or-geography&quot;&gt;Geometry or geography&lt;/h2&gt;
&lt;p&gt;What &lt;code&gt;&amp;lt;-&amp;gt;&lt;/code&gt; measures depends on the column’s type.&lt;/p&gt;
&lt;p&gt;On &lt;code&gt;geometry&lt;/code&gt;, it’s the planar distance in the units of the coordinate system. For longitude and latitude in SRID 4326, that unit is degrees, and a degree of longitude shrinks from about 111 km at the equator to nothing at the poles. Ordering by degrees is close to ordering by metres near the equator and increasingly wrong away from it, so the “nearest” row can be the second or third nearest on the ground. That includes the London query above.&lt;/p&gt;
&lt;p&gt;There are two ways out:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A &lt;code&gt;geography&lt;/code&gt; column.&lt;/strong&gt; Distances are in metres. &lt;code&gt;&amp;lt;-&amp;gt;&lt;/code&gt; on geography uses a sphere, which is fast and good for ordering. &lt;code&gt;ST_Distance&lt;/code&gt; on geography uses the spheroid by default, which is more exact. Order with &lt;code&gt;&amp;lt;-&amp;gt;&lt;/code&gt;, and show &lt;code&gt;ST_Distance&lt;/code&gt; when the number itself matters; the two can disagree slightly on near ties.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A projected &lt;code&gt;geometry&lt;/code&gt; column&lt;/strong&gt; in a metric coordinate system that fits the area, such as the local UTM zone. Planar maths is then both fast and accurate, as long as the data stays inside that area.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For data spread across a country or the world, geography is the simpler choice. For a city or a region, a projected geometry is the usual one.&lt;/p&gt;
&lt;h2 id=&quot;st_dwithin-as-a-prefilter&quot;&gt;ST_DWithin as a prefilter&lt;/h2&gt;
&lt;p&gt;“Nearest” with no limit on distance will happily return a place 800 km away when nothing is closer. Usually the real question is “the nearest few, within 5 km”. The tempting way to write the radius uses &lt;code&gt;ST_Distance&lt;/code&gt; in the &lt;code&gt;WHERE&lt;/code&gt; clause, which computes every distance again. &lt;code&gt;ST_DWithin&lt;/code&gt; asks the same question in a way the index can answer, because it starts with a bounding-box test:&lt;/p&gt;
&lt;pre class=&quot;astro-code haunted-pavement&quot; style=&quot;background-color:var(--code-bg);color:var(--code-fg); overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-comment);font-style:italic&quot;&gt;-- $1 is the search point, as geography; places has a GiST index on geog&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-keyword)&quot;&gt;SELECT&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt; id, &lt;/span&gt;&lt;span style=&quot;color:var(--code-keyword)&quot;&gt;name&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;, ST_Distance(geog, $&lt;/span&gt;&lt;span style=&quot;color:var(--code-constant)&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:var(--code-keyword)&quot;&gt;AS&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt; metres&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-keyword)&quot;&gt;FROM&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt; places&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-keyword)&quot;&gt;WHERE&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt; ST_DWithin(geog, $&lt;/span&gt;&lt;span style=&quot;color:var(--code-constant)&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:var(--code-constant)&quot;&gt;5000&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-keyword)&quot;&gt;ORDER BY&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt; geog &amp;lt;-&amp;gt; $&lt;/span&gt;&lt;span style=&quot;color:var(--code-constant)&quot;&gt;1&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-keyword)&quot;&gt;LIMIT&lt;/span&gt;&lt;span style=&quot;color:var(--code-constant)&quot;&gt; 10&lt;/span&gt;&lt;span style=&quot;color:var(--code-fg)&quot;&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On geography the distance is in metres, and the spheroid is used by default. On a geometry column in SRID 4326 the same &lt;code&gt;5000&lt;/code&gt; means 5,000 degrees, which covers the whole planet and filters nothing. It returns results, just not the ones asked for.&lt;/p&gt;
&lt;p&gt;Sources: the PostGIS reference for &lt;a href=&quot;https://postgis.net/docs/geometry_distance_knn.html&quot;&gt;&lt;code&gt;&amp;lt;-&amp;gt;&lt;/code&gt;&lt;/a&gt; and &lt;a href=&quot;https://postgis.net/docs/ST_DWithin.html&quot;&gt;&lt;code&gt;ST_DWithin&lt;/code&gt;&lt;/a&gt;, and the PostGIS workshop’s &lt;a href=&quot;https://postgis.net/workshops/postgis-intro/knn.html&quot;&gt;nearest-neighbour chapter&lt;/a&gt;.&lt;/p&gt;
</content:encoded><category>postgres</category><category>gis</category></item></channel></rss>