{
  "version": "https://jsonfeed.org/version/1.1",
  "title": "Basant Bhattarai — Writing",
  "home_page_url": "https://basantbhattarai.com.np/writing/",
  "feed_url": "https://basantbhattarai.com.np/writing/feed.json",
  "description": "Reproducible field notes from operating data platforms, databases, and governed AI systems.",
  "language": "en",
  "icon": "https://basantbhattarai.com.np/assets/images/hero-portrait-v2-840.jpg",
  "favicon": "https://basantbhattarai.com.np/assets/favicon.svg",
  "authors": [
    {
      "name": "Basant Bhattarai",
      "url": "https://basantbhattarai.com.np",
      "avatar": "https://basantbhattarai.com.np/assets/images/hero-portrait-v2-840.jpg"
    }
  ],
  "items": [
    {
      "id": "https://basantbhattarai.com.np/writing/trino-iceberg-polaris-lakehouse-spine/",
      "url": "https://basantbhattarai.com.np/writing/trino-iceberg-polaris-lakehouse-spine/",
      "title": "A lakehouse spine: Trino, Iceberg and Polaris, with credentials that expire",
      "content_html": "<h2 id=\"what-this-is-and-when-it-was-built\">What this is, and when it was built</h2>\n\n<p>I first built this stack in January 2024 for a client who wanted to see whether Trino over Iceberg could replace a warehouse they were outgrowing. That build pinned Trino 437, <code class=\"language-plaintext highlighter-rouge\">tabulario/iceberg-rest:0.2.0</code> as the catalog, and Hive Metastore underneath it.</p>\n\n<p>Every one of those three has since been superseded. Trino is on 483. The <code class=\"language-plaintext highlighter-rouge\">tabulario</code> image is archived. Apache Polaris graduated incubation and is on 1.7.0. So this post is the same architecture, rebuilt on current versions and measured, and the interesting part turned out to be the piece the 2024 build did not have at all: <strong>credentials that expire</strong>.</p>\n\n<p>The claim I wanted to test is the one that justifies a REST catalog over a metastore. Hive Metastore tells you where a table is. Polaris decides whether you may touch it, and hands out short-lived S3 credentials scoped to that table’s prefix. If that works, Trino never holds a standing key. If it does not, you have added a service and gained a lookup.</p>\n\n<p>It works. Proving it took five failures, and four of them fail in a way that points somewhere else.</p>\n\n<h2 id=\"step-1--storage-and-a-catalog-that-survives-a-restart\">Step 1 — Storage, and a catalog that survives a restart</h2>\n\n<p>Three services before Trino enters the picture: MinIO for objects, Postgres for Polaris’s own metadata, and Polaris itself.</p>\n\n<div data-file=\"docker-compose.yaml\" class=\"language-yaml highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"na\">services</span><span class=\"pi\">:</span>\n  <span class=\"na\">minio</span><span class=\"pi\">:</span>\n    <span class=\"na\">image</span><span class=\"pi\">:</span> <span class=\"s\">minio/minio:RELEASE.2025-09-07T16-13-09Z</span>\n    <span class=\"na\">command</span><span class=\"pi\">:</span> <span class=\"s\">server /data --console-address \":9001\"</span>\n    <span class=\"na\">environment</span><span class=\"pi\">:</span>\n      <span class=\"na\">MINIO_ROOT_USER</span><span class=\"pi\">:</span> <span class=\"s\">lakehouse</span>\n      <span class=\"na\">MINIO_ROOT_PASSWORD</span><span class=\"pi\">:</span> <span class=\"s\">lakehouse-dev-only</span>\n    <span class=\"na\">ports</span><span class=\"pi\">:</span> <span class=\"pi\">[</span><span class=\"s2\">\"</span><span class=\"s\">9000:9000\"</span><span class=\"pi\">,</span> <span class=\"s2\">\"</span><span class=\"s\">9001:9001\"</span><span class=\"pi\">]</span>\n    <span class=\"na\">healthcheck</span><span class=\"pi\">:</span>\n      <span class=\"na\">test</span><span class=\"pi\">:</span> <span class=\"pi\">[</span><span class=\"s2\">\"</span><span class=\"s\">CMD\"</span><span class=\"pi\">,</span> <span class=\"s2\">\"</span><span class=\"s\">mc\"</span><span class=\"pi\">,</span> <span class=\"s2\">\"</span><span class=\"s\">ready\"</span><span class=\"pi\">,</span> <span class=\"s2\">\"</span><span class=\"s\">local\"</span><span class=\"pi\">]</span>\n      <span class=\"na\">interval</span><span class=\"pi\">:</span> <span class=\"s\">5s</span>\n      <span class=\"na\">retries</span><span class=\"pi\">:</span> <span class=\"m\">20</span>\n\n  <span class=\"na\">polaris-db</span><span class=\"pi\">:</span>\n    <span class=\"na\">image</span><span class=\"pi\">:</span> <span class=\"s\">postgres:18.1-alpine</span>\n    <span class=\"na\">environment</span><span class=\"pi\">:</span>\n      <span class=\"na\">POSTGRES_USER</span><span class=\"pi\">:</span> <span class=\"s\">polaris</span>\n      <span class=\"na\">POSTGRES_PASSWORD</span><span class=\"pi\">:</span> <span class=\"s\">polaris-dev-only</span>\n      <span class=\"na\">POSTGRES_DB</span><span class=\"pi\">:</span> <span class=\"s\">polaris</span>\n    <span class=\"na\">healthcheck</span><span class=\"pi\">:</span>\n      <span class=\"na\">test</span><span class=\"pi\">:</span> <span class=\"pi\">[</span><span class=\"s2\">\"</span><span class=\"s\">CMD-SHELL\"</span><span class=\"pi\">,</span> <span class=\"s2\">\"</span><span class=\"s\">pg_isready</span><span class=\"nv\"> </span><span class=\"s\">-U</span><span class=\"nv\"> </span><span class=\"s\">polaris</span><span class=\"nv\"> </span><span class=\"s\">-d</span><span class=\"nv\"> </span><span class=\"s\">polaris\"</span><span class=\"pi\">]</span>\n      <span class=\"na\">interval</span><span class=\"pi\">:</span> <span class=\"s\">5s</span>\n      <span class=\"na\">retries</span><span class=\"pi\">:</span> <span class=\"m\">20</span>\n</code></pre></div></div>\n\n<p>Polaris defaults to in-memory persistence, which loses every namespace on restart. A lab that forgets its catalog when you reboot is not reproducible, so this one runs on Postgres.</p>\n\n<div class=\"callout callout--gotcha\">\n  <p><strong><code class=\"language-plaintext highlighter-rouge\">POLARIS_BOOTSTRAP_CREDENTIALS</code> does not create the schema.</strong> Point Polaris at an empty Postgres with that variable set and it starts cleanly, passes its healthcheck, and then fails every single request:</p>\n\n  <div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>Failed to retrieve polaris entity due to Failed due to\n'ERROR: relation \"polaris_schema.entities\" does not exist'\n</code></pre></div>  </div>\n\n  <p>The schema is the admin tool’s job. And the admin tool does <strong>not</strong> read <code class=\"language-plaintext highlighter-rouge\">POLARIS_BOOTSTRAP_CREDENTIALS</code> — that variable belongs to the server. Passing only <code class=\"language-plaintext highlighter-rouge\">-r</code> exits 3 with <code class=\"language-plaintext highlighter-rouge\">Specify either --credentials or --print-credentials</code>, so the same credential has to be repeated in the tool’s own form.</p>\n</div>\n\n<div data-file=\"docker-compose.yaml\" class=\"language-yaml highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>  <span class=\"na\">polaris-bootstrap</span><span class=\"pi\">:</span>\n    <span class=\"na\">image</span><span class=\"pi\">:</span> <span class=\"s\">apache/polaris-admin-tool:1.7.0</span>\n    <span class=\"na\">depends_on</span><span class=\"pi\">:</span>\n      <span class=\"na\">polaris-db</span><span class=\"pi\">:</span> <span class=\"pi\">{</span> <span class=\"nv\">condition</span><span class=\"pi\">:</span> <span class=\"nv\">service_healthy</span> <span class=\"pi\">}</span>\n    <span class=\"na\">environment</span><span class=\"pi\">:</span>\n      <span class=\"na\">polaris.persistence.type</span><span class=\"pi\">:</span> <span class=\"s\">relational-jdbc</span>\n      <span class=\"na\">quarkus.datasource.jdbc.url</span><span class=\"pi\">:</span> <span class=\"s\">jdbc:postgresql://polaris-db:5432/polaris</span>\n      <span class=\"na\">quarkus.datasource.username</span><span class=\"pi\">:</span> <span class=\"s\">polaris</span>\n      <span class=\"na\">quarkus.datasource.password</span><span class=\"pi\">:</span> <span class=\"s\">polaris-dev-only</span>\n    <span class=\"na\">command</span><span class=\"pi\">:</span> <span class=\"pi\">[</span><span class=\"s2\">\"</span><span class=\"s\">bootstrap\"</span><span class=\"pi\">,</span> <span class=\"s2\">\"</span><span class=\"s\">-r\"</span><span class=\"pi\">,</span> <span class=\"s2\">\"</span><span class=\"s\">POLARIS\"</span><span class=\"pi\">,</span> <span class=\"s2\">\"</span><span class=\"s\">-c\"</span><span class=\"pi\">,</span> <span class=\"s2\">\"</span><span class=\"s\">POLARIS,root,s3cr3t-dev-only\"</span><span class=\"pi\">]</span>\n</code></pre></div></div>\n\n<p>Bootstrap is idempotent — running it against an already-bootstrapped realm is a no-op — which is why it can sit in <code class=\"language-plaintext highlighter-rouge\">depends_on</code> rather than in a script somebody has to remember not to run twice.</p>\n\n<p class=\"verify\"><strong>Verify.</strong> <code class=\"language-plaintext highlighter-rouge\">Realm 'POLARIS' successfully bootstrapped.</code> on first run, and no error on the second.</p>\n\n<p>Now the server itself:</p>\n\n<div data-file=\"docker-compose.yaml\" class=\"language-yaml highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>  <span class=\"na\">polaris</span><span class=\"pi\">:</span>\n    <span class=\"na\">image</span><span class=\"pi\">:</span> <span class=\"s\">apache/polaris:1.7.0</span>\n    <span class=\"na\">depends_on</span><span class=\"pi\">:</span>\n      <span class=\"na\">polaris-bootstrap</span><span class=\"pi\">:</span> <span class=\"pi\">{</span> <span class=\"nv\">condition</span><span class=\"pi\">:</span> <span class=\"nv\">service_completed_successfully</span> <span class=\"pi\">}</span>\n    <span class=\"na\">ports</span><span class=\"pi\">:</span> <span class=\"pi\">[</span><span class=\"s2\">\"</span><span class=\"s\">8181:8181\"</span><span class=\"pi\">,</span> <span class=\"s2\">\"</span><span class=\"s\">8182:8182\"</span><span class=\"pi\">]</span>\n    <span class=\"na\">environment</span><span class=\"pi\">:</span>\n      <span class=\"na\">POLARIS_BOOTSTRAP_CREDENTIALS</span><span class=\"pi\">:</span> <span class=\"s\">POLARIS,root,s3cr3t-dev-only</span>\n      <span class=\"na\">polaris.realm-context.realms</span><span class=\"pi\">:</span> <span class=\"s\">POLARIS</span>\n      <span class=\"na\">polaris.persistence.type</span><span class=\"pi\">:</span> <span class=\"s\">relational-jdbc</span>\n      <span class=\"na\">quarkus.datasource.jdbc.url</span><span class=\"pi\">:</span> <span class=\"s\">jdbc:postgresql://polaris-db:5432/polaris</span>\n      <span class=\"na\">quarkus.datasource.username</span><span class=\"pi\">:</span> <span class=\"s\">polaris</span>\n      <span class=\"na\">quarkus.datasource.password</span><span class=\"pi\">:</span> <span class=\"s\">polaris-dev-only</span>\n      <span class=\"s\">polaris.features.\"ALLOW_INSECURE_STORAGE_TYPES\"</span><span class=\"err\">:</span> <span class=\"s2\">\"</span><span class=\"s\">true\"</span>\n      <span class=\"s\">polaris.features.\"SUPPORTED_CATALOG_STORAGE_TYPES\"</span><span class=\"err\">:</span> <span class=\"s2\">\"</span><span class=\"s\">[</span><span class=\"se\">\\\"</span><span class=\"s\">S3</span><span class=\"se\">\\\"</span><span class=\"s\">]\"</span>\n      <span class=\"na\">polaris.readiness.ignore-severe-issues</span><span class=\"pi\">:</span> <span class=\"s2\">\"</span><span class=\"s\">true\"</span>\n    <span class=\"na\">healthcheck</span><span class=\"pi\">:</span>\n      <span class=\"na\">test</span><span class=\"pi\">:</span> <span class=\"pi\">[</span><span class=\"s2\">\"</span><span class=\"s\">CMD\"</span><span class=\"pi\">,</span> <span class=\"s2\">\"</span><span class=\"s\">curl\"</span><span class=\"pi\">,</span> <span class=\"s2\">\"</span><span class=\"s\">-f\"</span><span class=\"pi\">,</span> <span class=\"s2\">\"</span><span class=\"s\">http://localhost:8182/q/health/ready\"</span><span class=\"pi\">]</span>\n      <span class=\"na\">interval</span><span class=\"pi\">:</span> <span class=\"s\">10s</span>\n      <span class=\"na\">retries</span><span class=\"pi\">:</span> <span class=\"m\">30</span>\n</code></pre></div></div>\n\n<div class=\"callout callout--gotcha\">\n  <p><strong><code class=\"language-plaintext highlighter-rouge\">/healthcheck</code> 404s.</strong> The upstream Spark quickstart still uses <code class=\"language-plaintext highlighter-rouge\">http://localhost:8182/healthcheck</code>. On 1.7.0 that path is gone — Quarkus serves health under <code class=\"language-plaintext highlighter-rouge\">/q/health</code>. A healthcheck aimed at a 404 leaves the container in <code class=\"language-plaintext highlighter-rouge\">health: starting</code> forever, and every service with a <code class=\"language-plaintext highlighter-rouge\">service_healthy</code> dependency silently never starts. Nothing in the logs mentions it, because from Polaris’s point of view nothing is wrong.</p>\n</div>\n\n<h2 id=\"step-2--the-grant-chain\">Step 2 — The grant chain</h2>\n\n<p>Polaris is an authorisation boundary, and three objects have to line up before Trino can write a row:</p>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>principal  →  principal_role  →  catalog_role  →  catalog\n</code></pre></div></div>\n\n<p>Miss the last link and Trino authenticates perfectly, then sees no namespaces at all — which reads exactly like a connection problem and is not one.</p>\n\n<div data-file=\"scripts/create-catalog.sh\" class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nv\">TOKEN</span><span class=\"o\">=</span><span class=\"si\">$(</span>curl <span class=\"nt\">-s</span> <span class=\"nt\">-X</span> POST http://localhost:8181/api/catalog/v1/oauth/tokens <span class=\"se\">\\</span>\n  <span class=\"nt\">-d</span> <span class=\"nv\">grant_type</span><span class=\"o\">=</span>client_credentials <span class=\"nt\">-d</span> <span class=\"nv\">client_id</span><span class=\"o\">=</span>root <span class=\"se\">\\</span>\n  <span class=\"nt\">-d</span> <span class=\"nv\">client_secret</span><span class=\"o\">=</span>s3cr3t-dev-only <span class=\"nt\">-d</span> <span class=\"s1\">'scope=PRINCIPAL_ROLE:ALL'</span> <span class=\"se\">\\</span>\n  | <span class=\"nb\">sed</span> <span class=\"nt\">-n</span> <span class=\"s1\">'s/.*\"access_token\":\"\\([^\"]*\\)\".*/\\1/p'</span><span class=\"si\">)</span>\n\ncurl <span class=\"nt\">-X</span> POST http://localhost:8181/api/management/v1/catalogs <span class=\"se\">\\</span>\n  <span class=\"nt\">-H</span> <span class=\"s2\">\"Authorization: Bearer </span><span class=\"nv\">$TOKEN</span><span class=\"s2\">\"</span> <span class=\"nt\">-H</span> <span class=\"s1\">'Content-Type: application/json'</span> <span class=\"nt\">-d</span> <span class=\"s1\">'{\n  \"catalog\": {\n    \"name\": \"lakehouse\", \"type\": \"INTERNAL\",\n    \"properties\": {\"default-base-location\": \"s3://warehouse/lakehouse\"},\n    \"storageConfigInfo\": {\n      \"storageType\": \"S3\",\n      \"allowedLocations\": [\"s3://warehouse/lakehouse\"],\n      \"endpoint\": \"http://minio:9000\",\n      \"endpointInternal\": \"http://minio:9000\",\n      \"pathStyleAccess\": true,\n      \"stsUnavailable\": false,\n      \"stsEndpoint\": \"http://minio:9000\",\n      \"roleArn\": \"arn:aws:iam::123456789012:role/polaris\",\n      \"kmsUnavailable\": true,\n      \"region\": \"us-east-1\"\n    }}}'</span>\n</code></pre></div></div>\n\n<p>Four things in that storage config are worth naming, because three of them cost me time.</p>\n\n<p><strong>There is no <code class=\"language-plaintext highlighter-rouge\">S3_COMPATIBLE</code> storage type.</strong> The enum is <code class=\"language-plaintext highlighter-rouge\">S3 | GCS | AZURE | FILE</code>. Sending <code class=\"language-plaintext highlighter-rouge\">S3_COMPATIBLE</code> — which is what the name suggests for MinIO — returns a bare <code class=\"language-plaintext highlighter-rouge\">HTTP 400</code> with an <strong>empty body</strong>. No message, no field name.</p>\n\n<p><strong>The S3 knobs are flat camelCase.</strong> <code class=\"language-plaintext highlighter-rouge\">endpoint</code> and <code class=\"language-plaintext highlighter-rouge\">pathStyleAccess</code>, not the dotted <code class=\"language-plaintext highlighter-rouge\">s3.endpoint</code> keys you write in Iceberg <em>client</em> configuration. Same concepts, different layer, different spelling.</p>\n\n<p><strong><code class=\"language-plaintext highlighter-rouge\">roleArn</code> is required and ignored.</strong> MinIO returns an empty <code class=\"language-plaintext highlighter-rouge\">AssumedRoleUser.Arn</code>, but the STS API shape demands one and Polaris will not call STS without it.</p>\n\n<p><strong><code class=\"language-plaintext highlighter-rouge\">kmsUnavailable: true</code> is the one that is in no quickstart.</strong> More on that in a moment.</p>\n\n<p>Then the roles:</p>\n\n<div data-file=\"scripts/create-catalog.sh\" class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>curl <span class=\"nt\">-X</span> POST .../catalogs/lakehouse/catalog-roles      <span class=\"nt\">-d</span> <span class=\"s1\">'{\"catalogRole\":{\"name\":\"engineer\"}}'</span>\ncurl <span class=\"nt\">-X</span> PUT  .../catalog-roles/engineer/grants         <span class=\"nt\">-d</span> <span class=\"s1\">'{\"grant\":{\"type\":\"catalog\",\"privilege\":\"CATALOG_MANAGE_CONTENT\"}}'</span>\ncurl <span class=\"nt\">-X</span> POST .../principal-roles                       <span class=\"nt\">-d</span> <span class=\"s1\">'{\"principalRole\":{\"name\":\"data_engineer\"}}'</span>\ncurl <span class=\"nt\">-X</span> PUT  .../principal-roles/data_engineer/catalog-roles/lakehouse <span class=\"nt\">-d</span> <span class=\"s1\">'{\"catalogRole\":{\"name\":\"engineer\"}}'</span>\ncurl <span class=\"nt\">-X</span> PUT  .../principals/root/principal-roles       <span class=\"nt\">-d</span> <span class=\"s1\">'{\"principalRole\":{\"name\":\"data_engineer\"}}'</span>\n</code></pre></div></div>\n\n<p class=\"verify\"><strong>Verify.</strong> Every call returns <code class=\"language-plaintext highlighter-rouge\">201</code>, and a re-run returns <code class=\"language-plaintext highlighter-rouge\">409 Conflict</code> rather than erroring — the script should be safe to run twice.</p>\n\n<h2 id=\"step-3--trino-holding-no-s3-credential\">Step 3 — Trino, holding no S3 credential</h2>\n\n<div data-file=\"trino/coordinator/etc/catalog/lakehouse.properties\" class=\"language-properties highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"py\">connector.name</span><span class=\"p\">=</span><span class=\"s\">iceberg</span>\n<span class=\"py\">iceberg.catalog.type</span><span class=\"p\">=</span><span class=\"s\">rest</span>\n<span class=\"py\">iceberg.rest-catalog.uri</span><span class=\"p\">=</span><span class=\"s\">http://polaris:8181/api/catalog</span>\n<span class=\"py\">iceberg.rest-catalog.warehouse</span><span class=\"p\">=</span><span class=\"s\">lakehouse</span>\n<span class=\"py\">iceberg.rest-catalog.security</span><span class=\"p\">=</span><span class=\"s\">OAUTH2</span>\n<span class=\"py\">iceberg.rest-catalog.oauth2.credential</span><span class=\"p\">=</span><span class=\"s\">root:s3cr3t-dev-only</span>\n<span class=\"py\">iceberg.rest-catalog.oauth2.scope</span><span class=\"p\">=</span><span class=\"s\">PRINCIPAL_ROLE:ALL</span>\n<span class=\"py\">iceberg.rest-catalog.vended-credentials-enabled</span><span class=\"p\">=</span><span class=\"s\">true</span>\n\n<span class=\"py\">iceberg.file-format</span><span class=\"p\">=</span><span class=\"s\">PARQUET</span>\n\n<span class=\"py\">fs.native-s3.enabled</span><span class=\"p\">=</span><span class=\"s\">true</span>\n<span class=\"py\">s3.endpoint</span><span class=\"p\">=</span><span class=\"s\">http://minio:9000</span>\n<span class=\"py\">s3.region</span><span class=\"p\">=</span><span class=\"s\">us-east-1</span>\n<span class=\"py\">s3.path-style-access</span><span class=\"p\">=</span><span class=\"s\">true</span>\n</code></pre></div></div>\n\n<p>Note what is absent: any S3 access key. Trino authenticates to Polaris as an OAuth2 principal; Polaris does the rest.</p>\n\n<div class=\"callout callout--gotcha\">\n  <p><strong>Do not add <code class=\"language-plaintext highlighter-rouge\">iceberg.register-table-procedure.enabled=true</code>.</strong> With vended credentials it makes Trino <strong>refuse to start</strong>:</p>\n\n  <div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>Using the `register_table` procedure with vended credentials is currently not supported\n</code></pre></div>  </div>\n\n  <p>That is a startup failure, not a query failure. The coordinator exits 100, every dependent container fails its healthcheck, and the offending catalog is named only inside the stack trace.</p>\n</div>\n\n<h2 id=\"step-4--the-two-failures-that-made-vending-work\">Step 4 — The two failures that made vending work</h2>\n\n<p>This is the part worth reading twice, because the failure modes are actively misleading.</p>\n\n<h3 id=\"minio-refuses-assumerole-for-root\">MinIO refuses AssumeRole for root</h3>\n\n<p>Polaris mints scoped credentials by calling <code class=\"language-plaintext highlighter-rouge\">sts:AssumeRole</code>. Point it at MinIO with the root credentials and you get:</p>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>&lt;Code&gt;InvalidParameterValue&lt;/Code&gt;\n&lt;Message&gt;Unsupported action AssumeRole&lt;/Message&gt;\n</code></pre></div></div>\n\n<p>That message says the <em>action</em> is unsupported, so the obvious conclusion is that MinIO has no STS. It does. It refuses AssumeRole <strong>for the root account specifically</strong>. Create a MinIO user and the same call succeeds:</p>\n\n<div data-file=\"minio-init\" class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>mc admin user add <span class=\"nb\">local </span>polaris-svc polaris-svc-dev-only\nmc admin policy attach <span class=\"nb\">local </span>readwrite <span class=\"nt\">--user</span> polaris-svc\n</code></pre></div></div>\n\n<p>Then point Polaris at that identity rather than root:</p>\n\n<div data-file=\"docker-compose.yaml\" class=\"language-yaml highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>      <span class=\"na\">AWS_ACCESS_KEY_ID</span><span class=\"pi\">:</span> <span class=\"s\">polaris-svc</span>\n      <span class=\"na\">AWS_SECRET_ACCESS_KEY</span><span class=\"pi\">:</span> <span class=\"s\">polaris-svc-dev-only</span>\n</code></pre></div></div>\n\n<p class=\"verify\"><strong>Verify.</strong> <code class=\"language-plaintext highlighter-rouge\">aws sts assume-role --endpoint-url http://minio:9000 --role-arn arn:aws:iam::123456789012:role/polaris --role-session-name probe</code> returns a <code class=\"language-plaintext highlighter-rouge\">Credentials</code> block with an <code class=\"language-plaintext highlighter-rouge\">Expiration</code>.</p>\n\n<h3 id=\"the-kms-statement-that-breaks-the-session-policy\">The KMS statement that breaks the session policy</h3>\n\n<p>With STS reachable, <code class=\"language-plaintext highlighter-rouge\">CREATE TABLE AS SELECT</code> succeeded. 1.5 million rows, 84 objects in the bucket. Then every <code class=\"language-plaintext highlighter-rouge\">SELECT</code> failed:</p>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>Query ... failed: Failed to load table: orders in bronze namespace\n</code></pre></div></div>\n\n<p>The real error is four layers down, in Polaris’s log:</p>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>invalid resource 'arn:aws:kms:us-east-1:123456789012:key/*'\n(Service: Sts, Status Code: 400)\n</code></pre></div></div>\n\n<p>Polaris builds a session policy for the AssumeRole call and, by default, includes a KMS statement. MinIO’s policy engine does not understand KMS ARNs and rejects the whole request. <code class=\"language-plaintext highlighter-rouge\">kmsUnavailable: true</code> on the catalog stops it being added.</p>\n\n<p><strong>This is the worst failure in the series.</strong> The data lands. The bucket fills. Only reads break, and the message Trino prints mentions neither KMS, nor STS, nor Polaris.</p>\n\n<div class=\"callout callout--gotcha\">\n  <p><strong>Why writes survived and reads did not.</strong> The write path obtained credentials through a code path that did not need the offending policy; <code class=\"language-plaintext highlighter-rouge\">loadTable</code> did. If you are debugging this, the tell is that <code class=\"language-plaintext highlighter-rouge\">SHOW TABLES</code> works and <code class=\"language-plaintext highlighter-rouge\">SELECT</code> does not.</p>\n</div>\n\n<h2 id=\"step-5--prove-the-scoping-do-not-trust-the-label\">Step 5 — Prove the scoping, do not trust the label</h2>\n\n<p>Polaris returns a <code class=\"language-plaintext highlighter-rouge\">prefix</code> field with each vended credential. That is a label, not evidence. The only thing that settles it is taking the credential and trying to read something it should not reach.</p>\n\n<div data-file=\"scripts/verify-scoping.sh\" class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"c\"># credentials for bronze.orders only</span>\ncurl <span class=\"nt\">-H</span> <span class=\"s2\">\"Authorization: Bearer </span><span class=\"nv\">$TOKEN</span><span class=\"s2\">\"</span> <span class=\"se\">\\</span>\n     <span class=\"nt\">-H</span> <span class=\"s1\">'X-Iceberg-Access-Delegation: vended-credentials'</span> <span class=\"se\">\\</span>\n     <span class=\"s2\">\"</span><span class=\"nv\">$POLARIS</span><span class=\"s2\">/api/catalog/v1/lakehouse/namespaces/bronze/tables/orders\"</span>\n</code></pre></div></div>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>credential vended for bronze.orders\n  prefix: s3://warehouse/lakehouse/bronze/orders-964137655cf046eb8a7eeabf4b97c257\n\nusing only that credential:\n  own prefix                         ALLOW  84 objects\n  sibling bronze.customers           DENY   AccessDenied\n  whole warehouse bucket             DENY   AccessDenied\n</code></pre></div></div>\n\n<p>Alongside the keys, Polaris returns <code class=\"language-plaintext highlighter-rouge\">s3.session-token</code> and <code class=\"language-plaintext highlighter-rouge\">s3.session-token-expires-at-ms</code>. The credential is short-lived as well as narrow.</p>\n\n<p class=\"verify\"><strong>Verify.</strong> Your own prefix lists objects; the sibling table and the bucket root both return <code class=\"language-plaintext highlighter-rouge\">AccessDenied</code>.</p>\n\n<div class=\"callout callout--gotcha\">\n  <p><strong>Two things make this look broken when it is not.</strong> The vended prefix carries the table UUID Iceberg assigns at create time — <code class=\"language-plaintext highlighter-rouge\">bronze/orders-964137655cf0…</code>, not <code class=\"language-plaintext highlighter-rouge\">bronze/orders</code>. Probing the tidy path denies, and reads as a scoping failure. And <code class=\"language-plaintext highlighter-rouge\">aws s3 ls</code> needs both a trailing slash and a region set; without them it fails in a way indistinguishable from AccessDenied at a glance.</p>\n</div>\n\n<h2 id=\"step-6--what-it-costs\">Step 6 — What it costs</h2>\n\n<p>A partitioned table, written through the whole chain:</p>\n\n<div class=\"language-sql highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"k\">CREATE</span> <span class=\"k\">TABLE</span> <span class=\"n\">lakehouse</span><span class=\"p\">.</span><span class=\"n\">bronze</span><span class=\"p\">.</span><span class=\"n\">orders</span>\n<span class=\"k\">WITH</span> <span class=\"p\">(</span><span class=\"n\">partitioning</span> <span class=\"o\">=</span> <span class=\"n\">ARRAY</span><span class=\"p\">[</span><span class=\"s1\">'month(orderdate)'</span><span class=\"p\">])</span> <span class=\"k\">AS</span>\n<span class=\"k\">SELECT</span> <span class=\"n\">orderkey</span><span class=\"p\">,</span> <span class=\"n\">custkey</span><span class=\"p\">,</span> <span class=\"n\">orderstatus</span><span class=\"p\">,</span> <span class=\"n\">totalprice</span><span class=\"p\">,</span> <span class=\"n\">orderdate</span><span class=\"p\">,</span> <span class=\"n\">orderpriority</span><span class=\"p\">,</span> <span class=\"n\">clerk</span>\n<span class=\"k\">FROM</span> <span class=\"n\">tpch</span><span class=\"p\">.</span><span class=\"n\">sf1</span><span class=\"p\">.</span><span class=\"n\">orders</span><span class=\"p\">;</span>\n</code></pre></div></div>\n\n<p>1,500,000 rows in 5.4 s, landing as 80 Parquet files, 2 Avro manifests, one Puffin <code class=\"language-plaintext highlighter-rouge\">.stats</code> file and one <code class=\"language-plaintext highlighter-rouge\">metadata.json</code> — 16 MiB total.</p>\n\n<p>Query timings, median of 7 server-side runs read from <code class=\"language-plaintext highlighter-rouge\">system.runtime.queries</code>:</p>\n\n<table>\n  <thead>\n    <tr>\n      <th>query</th>\n      <th>tpch generator</th>\n      <th>Iceberg on MinIO</th>\n    </tr>\n  </thead>\n  <tbody>\n    <tr>\n      <td>scan + aggregate over 1.5M rows</td>\n      <td>238 ms</td>\n      <td>183 ms</td>\n    </tr>\n    <tr>\n      <td>one-month partition predicate</td>\n      <td>615 ms</td>\n      <td><strong>88 ms</strong></td>\n    </tr>\n    <tr>\n      <td>non-partition predicate</td>\n      <td>595 ms</td>\n      <td>202 ms</td>\n    </tr>\n  </tbody>\n</table>\n\n<p><code class=\"language-plaintext highlighter-rouge\">tpch</code> is a generator, not a storage format, and never touches the network — it is a floor rather than a fair “before”. The 7× gap on the partition predicate is pruning: Iceberg reads one manifest entry while tpch regenerates all 1.5 million rows.</p>\n\n<div class=\"callout callout--gotcha\">\n  <p><strong>Measure server-side.</strong> My first numbers were wall-clock around the Trino CLI: 603 ms for the query that actually takes 88 ms. The other 515 ms was <code class=\"language-plaintext highlighter-rouge\">docker compose exec</code> plus the CLI’s own JVM startup — six times the figure I was trying to measure.</p>\n</div>\n\n<h2 id=\"failure-modes\">Failure modes</h2>\n\n<table>\n  <thead>\n    <tr>\n      <th>Symptom</th>\n      <th>Cause</th>\n      <th>Repair</th>\n    </tr>\n  </thead>\n  <tbody>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">relation \"polaris_schema.entities\" does not exist</code></td>\n      <td>Schema never created</td>\n      <td>Run <code class=\"language-plaintext highlighter-rouge\">polaris-admin-tool bootstrap</code> before the server</td>\n    </tr>\n    <tr>\n      <td>Bootstrap exits 3</td>\n      <td>Admin tool ignores <code class=\"language-plaintext highlighter-rouge\">POLARIS_BOOTSTRAP_CREDENTIALS</code></td>\n      <td>Pass <code class=\"language-plaintext highlighter-rouge\">-c realm,clientId,clientSecret</code> explicitly</td>\n    </tr>\n    <tr>\n      <td>Container stuck <code class=\"language-plaintext highlighter-rouge\">health: starting</code></td>\n      <td>Healthcheck points at <code class=\"language-plaintext highlighter-rouge\">/healthcheck</code></td>\n      <td>Use <code class=\"language-plaintext highlighter-rouge\">/q/health/ready</code> on port 8182</td>\n    </tr>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">HTTP 400</code>, empty body, on catalog create</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">storageType: S3_COMPATIBLE</code></td>\n      <td>Use <code class=\"language-plaintext highlighter-rouge\">S3</code> with flat <code class=\"language-plaintext highlighter-rouge\">endpoint</code> / <code class=\"language-plaintext highlighter-rouge\">pathStyleAccess</code></td>\n    </tr>\n    <tr>\n      <td>Trino exits 100 at startup</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">register-table-procedure</code> + vending</td>\n      <td>Remove the procedure flag</td>\n    </tr>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">Unsupported action AssumeRole</code></td>\n      <td>Using MinIO root</td>\n      <td>Create a MinIO user and use it</td>\n    </tr>\n    <tr>\n      <td>Writes succeed, all reads fail</td>\n      <td>KMS statement in the session policy</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">kmsUnavailable: true</code> on the catalog</td>\n    </tr>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">Credential vending was requested … but no credentials are available</code></td>\n      <td><code class=\"language-plaintext highlighter-rouge\">stsUnavailable: true</code></td>\n      <td>Enable STS, or disable vending and accept standing keys</td>\n    </tr>\n  </tbody>\n</table>\n\n<h2 id=\"clean-up-and-operating-consequence\">Clean up and operating consequence</h2>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>docker compose down <span class=\"nt\">-v</span>\n</code></pre></div></div>\n\n<p>The operating rule this lab produced: <strong>a REST catalog is only worth the extra service if vending actually works.</strong> Turn vending off and Polaris becomes a metadata lookup with a permissions table in front of it, while every Trino user still holds a key to the whole bucket. That is not obviously better than Hive Metastore.</p>\n\n<p>Turn it on and the boundary is real, but you inherit an STS dependency that must be exercised in whatever object store you actually run. Against MinIO that meant a service user and one undocumented flag. Against a different S3-compatible store it will mean something else — and the way to find out is to take a vended credential and try to read the table next door.</p>\n\n<p><a href=\"/writing/\">Part 2</a> builds bronze, silver and gold on top of this with dbt-trino, where the interesting failure is a partition spec that dbt accepts and silently discards.</p>\n",
      "summary": "Build Trino 483 on Iceberg with Apache Polaris as the REST catalog, then prove the vended S3 credentials cannot read the table next door.",
      "date_published": "2026-08-16T12:00:00+05:45",
      "date_modified": "2026-08-16T00:00:00+05:45",
      "tags": ["iceberg-lakehouse","distributed-databases"],
      "authors": [
        {
          "name": "Basant Bhattarai",
          "url": "https://basantbhattarai.com.np"
        }
      ]
    },
    {
      "id": "https://basantbhattarai.com.np/writing/dbt-trino-iceberg-medallion-airflow/",
      "url": "https://basantbhattarai.com.np/writing/dbt-trino-iceberg-medallion-airflow/",
      "title": "Bronze, silver, gold on Iceberg: the dbt config that is silently ignored",
      "content_html": "<h2 id=\"the-claim-worth-testing\">The claim worth testing</h2>\n\n<p>Every lakehouse article asserts that Iceberg gives you row-level MERGE, so incremental models rewrite only what changed instead of replacing the table. That is the difference between a pipeline that scales and one that gets slower every day.</p>\n\n<p>It is also the kind of claim that is easy to state and rarely shown. dbt will happily print <code class=\"language-plaintext highlighter-rouge\">MERGE (25_000 rows)</code> whether it rewrote one file or all eighty. The evidence is not in dbt’s output — it is in Iceberg’s <code class=\"language-plaintext highlighter-rouge\">$snapshots</code> metadata table, which records exactly how many data files each commit added and deleted.</p>\n\n<p>The stack is a rebuild of a January 2024 client project on current versions — Trino 437 became 483, Hive Metastore became Apache Polaris. This post builds bronze → silver → gold on the <a href=\"/writing/\">spine from part one</a>, hands it to Airflow, and then goes and reads that table.</p>\n\n<p>Four things went wrong on the way. Three of them failed silently.</p>\n\n<h2 id=\"step-1--dbt-against-trino-holding-no-credentials\">Step 1 — dbt against Trino, holding no credentials</h2>\n\n<div data-file=\"dbt/profiles.yml\" class=\"language-yaml highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"na\">lakehouse</span><span class=\"pi\">:</span>\n  <span class=\"na\">target</span><span class=\"pi\">:</span> <span class=\"s\">dev</span>\n  <span class=\"na\">outputs</span><span class=\"pi\">:</span>\n    <span class=\"na\">dev</span><span class=\"pi\">:</span>\n      <span class=\"na\">type</span><span class=\"pi\">:</span> <span class=\"s\">trino</span>\n      <span class=\"na\">host</span><span class=\"pi\">:</span> <span class=\"s\">trino-coordinator</span>\n      <span class=\"na\">port</span><span class=\"pi\">:</span> <span class=\"m\">8080</span>\n      <span class=\"na\">user</span><span class=\"pi\">:</span> <span class=\"s\">dbt</span>\n      <span class=\"na\">catalog</span><span class=\"pi\">:</span> <span class=\"s\">lakehouse</span>\n      <span class=\"na\">schema</span><span class=\"pi\">:</span> <span class=\"s\">silver</span>\n      <span class=\"na\">http_scheme</span><span class=\"pi\">:</span> <span class=\"s\">http</span>\n      <span class=\"na\">threads</span><span class=\"pi\">:</span> <span class=\"m\">4</span>\n</code></pre></div></div>\n\n<p>Note what is not in this file: any S3 credential. dbt talks to Trino, Trino asks Polaris, Polaris vends per table. The whole chain from part one holds.</p>\n\n<div data-file=\"dbt/Dockerfile\" class=\"language-dockerfile highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"k\">FROM</span><span class=\"s\"> python:3.12-slim</span>\n<span class=\"c\"># git is not decoration: dbt shells out to it for package installs and to stamp</span>\n<span class=\"c\"># the run manifest, and `dbt debug` fails a check without it.</span>\n<span class=\"k\">RUN </span>apt-get update <span class=\"o\">&amp;&amp;</span> apt-get <span class=\"nb\">install</span> <span class=\"nt\">-y</span> <span class=\"nt\">--no-install-recommends</span> git <span class=\"se\">\\\n</span> <span class=\"o\">&amp;&amp;</span> <span class=\"nb\">rm</span> <span class=\"nt\">-rf</span> /var/lib/apt/lists/<span class=\"k\">*</span>\n<span class=\"k\">RUN </span>pip <span class=\"nb\">install</span> <span class=\"nt\">--no-cache-dir</span> <span class=\"s2\">\"dbt-trino==1.10.3\"</span>\n<span class=\"k\">WORKDIR</span><span class=\"s\"> /dbt</span>\n<span class=\"k\">ENTRYPOINT</span><span class=\"s\"> [\"dbt\"]</span>\n</code></pre></div></div>\n\n<p class=\"verify\"><strong>Verify.</strong> <code class=\"language-plaintext highlighter-rouge\">dbt debug</code> reports <code class=\"language-plaintext highlighter-rouge\">Connection test: OK connection ok</code>.</p>\n\n<h2 id=\"step-2--the-schema-names-that-were-not-what-i-wrote\">Step 2 — The schema names that were not what I wrote</h2>\n\n<div data-file=\"dbt/dbt_project.yml\" class=\"language-yaml highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"na\">models</span><span class=\"pi\">:</span>\n  <span class=\"na\">lakehouse</span><span class=\"pi\">:</span>\n    <span class=\"na\">+table_type</span><span class=\"pi\">:</span> <span class=\"s\">iceberg</span>\n    <span class=\"na\">silver</span><span class=\"pi\">:</span>\n      <span class=\"na\">+schema</span><span class=\"pi\">:</span> <span class=\"s\">silver</span>\n      <span class=\"na\">+materialized</span><span class=\"pi\">:</span> <span class=\"s\">incremental</span>\n    <span class=\"na\">gold</span><span class=\"pi\">:</span>\n      <span class=\"na\">+schema</span><span class=\"pi\">:</span> <span class=\"s\">gold</span>\n      <span class=\"na\">+materialized</span><span class=\"pi\">:</span> <span class=\"s\">table</span>\n</code></pre></div></div>\n\n<p>First run:</p>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>1 of 2 OK created sql incremental model silver_silver.orders_enriched\n2 of 2 OK created sql table model silver_gold.daily_revenue\n</code></pre></div></div>\n\n<p>Both models built. Both queries worked. Both namespaces in Polaris were wrong.</p>\n\n<p>dbt’s default <code class=\"language-plaintext highlighter-rouge\">generate_schema_name</code> returns <code class=\"language-plaintext highlighter-rouge\">&lt;profile_schema&gt;_&lt;custom_schema&gt;</code>. That default exists so several developers can share a warehouse without colliding, and it is the right default for that. A lakehouse whose layer names <em>are</em> the contract does not want it:</p>\n\n<div class=\"language-sql highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"p\">{</span><span class=\"o\">%</span> <span class=\"n\">macro</span> <span class=\"n\">generate_schema_name</span><span class=\"p\">(</span><span class=\"n\">custom_schema_name</span><span class=\"p\">,</span> <span class=\"n\">node</span><span class=\"p\">)</span> <span class=\"o\">-%</span><span class=\"p\">}</span>\n    <span class=\"p\">{</span><span class=\"o\">%-</span> <span class=\"n\">if</span> <span class=\"n\">custom_schema_name</span> <span class=\"k\">is</span> <span class=\"k\">none</span> <span class=\"o\">-%</span><span class=\"p\">}</span>\n        <span class=\"p\">{{</span> <span class=\"n\">target</span><span class=\"p\">.</span><span class=\"k\">schema</span> <span class=\"p\">}}</span>\n    <span class=\"p\">{</span><span class=\"o\">%-</span> <span class=\"k\">else</span> <span class=\"o\">-%</span><span class=\"p\">}</span>\n        <span class=\"p\">{{</span> <span class=\"n\">custom_schema_name</span> <span class=\"o\">|</span> <span class=\"k\">trim</span> <span class=\"p\">}}</span>\n    <span class=\"p\">{</span><span class=\"o\">%-</span> <span class=\"n\">endif</span> <span class=\"o\">-%</span><span class=\"p\">}</span>\n<span class=\"p\">{</span><span class=\"o\">%-</span> <span class=\"n\">endmacro</span> <span class=\"o\">%</span><span class=\"p\">}</span>\n</code></pre></div></div>\n\n<p data-file=\"dbt/macros/generate_schema_name.sql\" class=\"verify\"><strong>Verify.</strong> <code class=\"language-plaintext highlighter-rouge\">SHOW SCHEMAS FROM lakehouse</code> lists <code class=\"language-plaintext highlighter-rouge\">bronze</code>, <code class=\"language-plaintext highlighter-rouge\">silver</code>, <code class=\"language-plaintext highlighter-rouge\">gold</code> — and not <code class=\"language-plaintext highlighter-rouge\">silver_silver</code>.</p>\n\n<h2 id=\"step-3--the-partition-spec-dbt-threw-away\">Step 3 — The partition spec dbt threw away</h2>\n\n<p>This is the one that matters.</p>\n\n<div class=\"language-sql highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"p\">{{</span>\n  <span class=\"n\">config</span><span class=\"p\">(</span>\n    <span class=\"n\">materialized</span>   <span class=\"o\">=</span> <span class=\"s1\">'incremental'</span><span class=\"p\">,</span>\n    <span class=\"n\">incremental_strategy</span> <span class=\"o\">=</span> <span class=\"s1\">'merge'</span><span class=\"p\">,</span>\n    <span class=\"n\">unique_key</span>     <span class=\"o\">=</span> <span class=\"s1\">'orderkey'</span><span class=\"p\">,</span>\n    <span class=\"n\">partitioning</span>   <span class=\"o\">=</span> <span class=\"p\">[</span><span class=\"nv\">\"month(orderdate)\"</span><span class=\"p\">],</span>\n    <span class=\"n\">table_type</span>     <span class=\"o\">=</span> <span class=\"s1\">'iceberg'</span>\n  <span class=\"p\">)</span>\n<span class=\"p\">}}</span>\n</code></pre></div></div>\n\n<p>That runs. dbt reports success. The model builds 1.5 million rows. And the table is <strong>not partitioned</strong>:</p>\n\n<div class=\"language-sql highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"k\">SHOW</span> <span class=\"k\">CREATE</span> <span class=\"k\">TABLE</span> <span class=\"n\">lakehouse</span><span class=\"p\">.</span><span class=\"n\">silver</span><span class=\"p\">.</span><span class=\"n\">orders_enriched</span><span class=\"p\">;</span>\n</code></pre></div></div>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>WITH (\n   format = 'PARQUET',\n   format_version = 2,\n   location = 's3://warehouse/lakehouse/silver/orders_enriched-09054d82…'\n)\n</code></pre></div></div>\n\n<p>No <code class=\"language-plaintext highlighter-rouge\">partitioning</code> property. dbt-trino expects it inside <code class=\"language-plaintext highlighter-rouge\">properties</code>, as a literal Trino SQL fragment:</p>\n\n<div class=\"language-sql highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"p\">{{</span>\n  <span class=\"n\">config</span><span class=\"p\">(</span>\n    <span class=\"n\">materialized</span>         <span class=\"o\">=</span> <span class=\"s1\">'incremental'</span><span class=\"p\">,</span>\n    <span class=\"n\">incremental_strategy</span> <span class=\"o\">=</span> <span class=\"s1\">'merge'</span><span class=\"p\">,</span>\n    <span class=\"n\">unique_key</span>           <span class=\"o\">=</span> <span class=\"s1\">'orderkey'</span><span class=\"p\">,</span>\n    <span class=\"n\">table_type</span>           <span class=\"o\">=</span> <span class=\"s1\">'iceberg'</span><span class=\"p\">,</span>\n    <span class=\"n\">properties</span>           <span class=\"o\">=</span> <span class=\"p\">{</span>\n      <span class=\"nv\">\"partitioning\"</span><span class=\"p\">:</span> <span class=\"nv\">\"ARRAY['month(orderdate)']\"</span>\n    <span class=\"p\">}</span>\n  <span class=\"p\">)</span>\n<span class=\"p\">}}</span>\n</code></pre></div></div>\n\n<p data-file=\"dbt/models/silver/orders_enriched.sql\">The cost of getting it wrong, on the same rows and the same predicate:</p>\n\n<table>\n  <thead>\n    <tr>\n      <th>table</th>\n      <th>data files</th>\n      <th>partitions</th>\n      <th>median query</th>\n    </tr>\n  </thead>\n  <tbody>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">partitioning</code> at config level (ignored)</td>\n      <td>2</td>\n      <td>0</td>\n      <td>131 ms</td>\n    </tr>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">properties = {\"partitioning\": …}</code></td>\n      <td>80</td>\n      <td>80</td>\n      <td>99 ms</td>\n    </tr>\n  </tbody>\n</table>\n\n<div class=\"callout callout--gotcha\">\n  <p><strong><code class=\"language-plaintext highlighter-rouge\">--</code> comments are a compilation error inside <code class=\"language-plaintext highlighter-rouge\">config()</code>.</strong> That block is a Jinja expression, not SQL. A <code class=\"language-plaintext highlighter-rouge\">-- like this</code> line inside it produces <code class=\"language-plaintext highlighter-rouge\">invalid syntax for function call expression</code>, pointing at the <code class=\"language-plaintext highlighter-rouge\">config(</code> line rather than the comment. Use <code class=\"language-plaintext highlighter-rouge\">{# … #}</code> outside the call.</p>\n</div>\n\n<p class=\"verify\"><strong>Verify.</strong> <code class=\"language-plaintext highlighter-rouge\">SHOW CREATE TABLE</code> includes <code class=\"language-plaintext highlighter-rouge\">partitioning = ARRAY['month(orderdate)']</code>, and <code class=\"language-plaintext highlighter-rouge\">SELECT count(*) FROM \"orders_enriched$partitions\"</code> returns 80 rather than 0.</p>\n\n<h2 id=\"step-4--403-on-a-drop-you-did-not-know-you-were-doing\">Step 4 — 403 on a drop you did not know you were doing</h2>\n\n<p>With partitioning fixed, <code class=\"language-plaintext highlighter-rouge\">dbt run --full-refresh</code> failed:</p>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>TrinoExternalError(type=EXTERNAL, name=ICEBERG_CATALOG_ERROR,\n  message=\"Failed to drop table 'orders_enriched__dbt_backup'\")\n</code></pre></div></div>\n\n<p>Nothing in that message is the cause. It is in Polaris’s log:</p>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>Unable to purge entity: orders_enriched__dbt_backup. To enable this feature, set\nthe Polaris configuration DROP_WITH_PURGE_ENABLED\n…\n\"DELETE /api/catalog/v1/lakehouse/namespaces/silver/tables/\n orders_enriched__dbt_backup?purgeRequested=true HTTP/1.1\" 403\n</code></pre></div></div>\n\n<p><code class=\"language-plaintext highlighter-rouge\">--full-refresh</code> on an incremental model builds an <code class=\"language-plaintext highlighter-rouge\">&lt;model&gt;__dbt_backup</code> table and then drops it. Trino asks for a purging drop; Polaris refuses by default, because purge deletes the data files and not merely the catalog entry. That is a defensible default. It is also one that leaves a stuck <code class=\"language-plaintext highlighter-rouge\">__dbt_backup</code> table you cannot remove by hand either.</p>\n\n<div data-file=\"docker-compose.yaml\" class=\"language-yaml highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>      <span class=\"s\">polaris.features.\"DROP_WITH_PURGE_ENABLED\"</span><span class=\"err\">:</span> <span class=\"s2\">\"</span><span class=\"s\">true\"</span>\n</code></pre></div></div>\n\n<p class=\"verify\"><strong>Verify.</strong> <code class=\"language-plaintext highlighter-rouge\">DROP TABLE lakehouse.silver.orders_enriched__dbt_backup</code> succeeds, and <code class=\"language-plaintext highlighter-rouge\">dbt run --full-refresh</code> completes.</p>\n\n<h2 id=\"step-5--prove-the-merge-touched-one-file\">Step 5 — Prove the MERGE touched one file</h2>\n\n<p>Now the actual claim. Baseline after a full refresh, then 25,000 new rows landed in bronze, then an incremental run:</p>\n\n<div class=\"language-sql highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"k\">SELECT</span> <span class=\"k\">operation</span><span class=\"p\">,</span>\n       <span class=\"n\">element_at</span><span class=\"p\">(</span><span class=\"n\">summary</span><span class=\"p\">,</span><span class=\"s1\">'added-data-files'</span><span class=\"p\">)</span>   <span class=\"k\">AS</span> <span class=\"n\">added</span><span class=\"p\">,</span>\n       <span class=\"n\">element_at</span><span class=\"p\">(</span><span class=\"n\">summary</span><span class=\"p\">,</span><span class=\"s1\">'deleted-data-files'</span><span class=\"p\">)</span> <span class=\"k\">AS</span> <span class=\"n\">deleted</span><span class=\"p\">,</span>\n       <span class=\"n\">element_at</span><span class=\"p\">(</span><span class=\"n\">summary</span><span class=\"p\">,</span><span class=\"s1\">'added-records'</span><span class=\"p\">)</span>      <span class=\"k\">AS</span> <span class=\"n\">added_rows</span><span class=\"p\">,</span>\n       <span class=\"n\">element_at</span><span class=\"p\">(</span><span class=\"n\">summary</span><span class=\"p\">,</span><span class=\"s1\">'total-data-files'</span><span class=\"p\">)</span>   <span class=\"k\">AS</span> <span class=\"n\">total</span>\n<span class=\"k\">FROM</span> <span class=\"n\">lakehouse</span><span class=\"p\">.</span><span class=\"n\">silver</span><span class=\"p\">.</span><span class=\"nv\">\"orders_enriched$snapshots\"</span>\n<span class=\"k\">ORDER</span> <span class=\"k\">BY</span> <span class=\"n\">committed_at</span><span class=\"p\">;</span>\n</code></pre></div></div>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code> operation | added | deleted | added_rows | total\n-----------+-------+---------+------------+-------\n append    | 80    | NULL    | 1500000    | 80\n append    | 1     | NULL    | 25000      | 81\n</code></pre></div></div>\n\n<p>dbt reported <code class=\"language-plaintext highlighter-rouge\">MERGE (25_000 rows) in 2.66s</code>. The commit added <strong>one data file out of eighty-one</strong> and deleted none. That is the claim, verified from the table’s own metadata rather than from the tool’s summary line.</p>\n\n<div class=\"callout callout--gotcha\">\n  <p><strong>Note the operation is <code class=\"language-plaintext highlighter-rouge\">append</code>, not <code class=\"language-plaintext highlighter-rouge\">overwrite</code>.</strong> Every incoming key was new, so Iceberg degraded the MERGE to an append — it had nothing to rewrite. A MERGE that genuinely matches existing rows shows a non-null <code class=\"language-plaintext highlighter-rouge\">deleted-data-files</code>, because Iceberg rewrites the files containing the matched rows. If you are testing MERGE behaviour, feed it keys that already exist, or you will measure the wrong thing and conclude MERGE is free.</p>\n\n  <p>Use <code class=\"language-plaintext highlighter-rouge\">element_at(summary, …)</code> rather than <code class=\"language-plaintext highlighter-rouge\">summary['…']</code>: the bracket form throws <code class=\"language-plaintext highlighter-rouge\">Key not present in map</code> on any snapshot where that key is absent, which is most of them.</p>\n</div>\n\n<h2 id=\"step-6--airflow-3-and-a-date-that-is-no-longer-guaranteed\">Step 6 — Airflow 3, and a date that is no longer guaranteed</h2>\n\n<div data-file=\"airflow/dags/lakehouse_pipeline.py\" class=\"language-python highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nd\">@dag</span><span class=\"p\">(</span>\n    <span class=\"n\">dag_id</span><span class=\"o\">=</span><span class=\"sh\">\"</span><span class=\"s\">lakehouse_pipeline</span><span class=\"sh\">\"</span><span class=\"p\">,</span>\n    <span class=\"n\">schedule</span><span class=\"o\">=</span><span class=\"sh\">\"</span><span class=\"s\">@daily</span><span class=\"sh\">\"</span><span class=\"p\">,</span>\n    <span class=\"n\">start_date</span><span class=\"o\">=</span><span class=\"n\">pendulum</span><span class=\"p\">.</span><span class=\"nf\">datetime</span><span class=\"p\">(</span><span class=\"mi\">2026</span><span class=\"p\">,</span> <span class=\"mi\">8</span><span class=\"p\">,</span> <span class=\"mi\">1</span><span class=\"p\">,</span> <span class=\"n\">tz</span><span class=\"o\">=</span><span class=\"sh\">\"</span><span class=\"s\">UTC</span><span class=\"sh\">\"</span><span class=\"p\">),</span>\n    <span class=\"n\">catchup</span><span class=\"o\">=</span><span class=\"bp\">False</span><span class=\"p\">,</span>\n    <span class=\"n\">max_active_runs</span><span class=\"o\">=</span><span class=\"mi\">1</span><span class=\"p\">,</span>\n<span class=\"p\">)</span>\n<span class=\"k\">def</span> <span class=\"nf\">lakehouse_pipeline</span><span class=\"p\">():</span>\n    <span class=\"nd\">@task</span>\n    <span class=\"k\">def</span> <span class=\"nf\">land</span><span class=\"p\">(</span><span class=\"o\">**</span><span class=\"n\">context</span><span class=\"p\">)</span> <span class=\"o\">-&gt;</span> <span class=\"nb\">int</span><span class=\"p\">:</span>\n        <span class=\"n\">run</span> <span class=\"o\">=</span> <span class=\"n\">context</span><span class=\"p\">[</span><span class=\"sh\">\"</span><span class=\"s\">dag_run</span><span class=\"sh\">\"</span><span class=\"p\">]</span>\n        <span class=\"n\">stamp</span> <span class=\"o\">=</span> <span class=\"nf\">getattr</span><span class=\"p\">(</span><span class=\"n\">run</span><span class=\"p\">,</span> <span class=\"sh\">\"</span><span class=\"s\">logical_date</span><span class=\"sh\">\"</span><span class=\"p\">,</span> <span class=\"bp\">None</span><span class=\"p\">)</span> <span class=\"ow\">or</span> <span class=\"n\">run</span><span class=\"p\">.</span><span class=\"n\">run_after</span>\n        <span class=\"bp\">...</span>\n\n    <span class=\"nd\">@task.bash</span><span class=\"p\">(</span><span class=\"n\">cwd</span><span class=\"o\">=</span><span class=\"sh\">\"</span><span class=\"s\">/opt/airflow/dbt</span><span class=\"sh\">\"</span><span class=\"p\">)</span>\n    <span class=\"k\">def</span> <span class=\"nf\">transform</span><span class=\"p\">()</span> <span class=\"o\">-&gt;</span> <span class=\"nb\">str</span><span class=\"p\">:</span>\n        <span class=\"k\">return</span> <span class=\"sh\">\"</span><span class=\"s\">dbt build --profiles-dir /opt/airflow/dbt</span><span class=\"sh\">\"</span>\n\n    <span class=\"nd\">@task</span>\n    <span class=\"k\">def</span> <span class=\"nf\">maintain</span><span class=\"p\">()</span> <span class=\"o\">-&gt;</span> <span class=\"nb\">dict</span><span class=\"p\">[</span><span class=\"nb\">str</span><span class=\"p\">,</span> <span class=\"nb\">int</span><span class=\"p\">]:</span>\n        <span class=\"bp\">...</span>\n\n    <span class=\"nf\">land</span><span class=\"p\">()</span> <span class=\"o\">&gt;&gt;</span> <span class=\"nf\">transform</span><span class=\"p\">()</span> <span class=\"o\">&gt;&gt;</span> <span class=\"nf\">maintain</span><span class=\"p\">()</span>\n</code></pre></div></div>\n\n<div class=\"callout callout--gotcha\">\n  <p><strong><code class=\"language-plaintext highlighter-rouge\">context[\"logical_date\"]</code> raises <code class=\"language-plaintext highlighter-rouge\">KeyError</code> on Airflow 3.</strong> AIP-83 made logical date optional: a manually triggered run has none at all, so the key is simply absent and the task dies before it reaches Trino. <code class=\"language-plaintext highlighter-rouge\">dag_run.run_after</code> is always populated. This is a quiet migration break — the DAG parses, imports cleanly, and only fails when someone presses Trigger.</p>\n</div>\n\n<p>dbt lives inside the Airflow image rather than behind a <code class=\"language-plaintext highlighter-rouge\">DockerOperator</code>. The alternative means mounting the host Docker socket into Airflow, which is a real privilege escalation for the sake of avoiding one <code class=\"language-plaintext highlighter-rouge\">pip install</code>. Pinning the same <code class=\"language-plaintext highlighter-rouge\">dbt-trino==1.10.3</code> in both images means a DAG run and a manual <code class=\"language-plaintext highlighter-rouge\">dbt run</code> cannot drift.</p>\n\n<p class=\"verify\"><strong>Verify.</strong> All three tasks report <code class=\"language-plaintext highlighter-rouge\">success</code>, and <code class=\"language-plaintext highlighter-rouge\">land</code>, <code class=\"language-plaintext highlighter-rouge\">transform</code>, <code class=\"language-plaintext highlighter-rouge\">maintain</code> appear in that order.</p>\n\n<h2 id=\"failure-modes\">Failure modes</h2>\n\n<table>\n  <thead>\n    <tr>\n      <th>Symptom</th>\n      <th>Cause</th>\n      <th>Repair</th>\n    </tr>\n  </thead>\n  <tbody>\n    <tr>\n      <td>Models built into <code class=\"language-plaintext highlighter-rouge\">silver_silver</code></td>\n      <td>dbt’s default schema concatenation</td>\n      <td>Override <code class=\"language-plaintext highlighter-rouge\">generate_schema_name</code></td>\n    </tr>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">SHOW CREATE TABLE</code> has no partitioning</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">partitioning</code> at config level</td>\n      <td>Move it into <code class=\"language-plaintext highlighter-rouge\">properties</code> as a SQL fragment</td>\n    </tr>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">invalid syntax for function call expression</code></td>\n      <td><code class=\"language-plaintext highlighter-rouge\">--</code> comment inside <code class=\"language-plaintext highlighter-rouge\">config()</code></td>\n      <td>Use <code class=\"language-plaintext highlighter-rouge\">{# … #}</code> outside the call</td>\n    </tr>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">Failed to drop table '…__dbt_backup'</code></td>\n      <td>Polaris refuses purging drops</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">DROP_WITH_PURGE_ENABLED: \"true\"</code></td>\n    </tr>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">KeyError: 'logical_date'</code></td>\n      <td>AIP-83 made it optional</td>\n      <td>Fall back to <code class=\"language-plaintext highlighter-rouge\">dag_run.run_after</code></td>\n    </tr>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">Key not present in map</code> on <code class=\"language-plaintext highlighter-rouge\">$snapshots</code></td>\n      <td>Bracket access to an absent summary key</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">element_at(summary, '…')</code></td>\n    </tr>\n  </tbody>\n</table>\n\n<h2 id=\"clean-up-and-operating-consequence\">Clean up and operating consequence</h2>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>docker compose <span class=\"nt\">--profile</span> orchestration <span class=\"nt\">--profile</span> tools down <span class=\"nt\">-v</span>\n</code></pre></div></div>\n\n<p>The rule this produced: <strong>verify the physical layout, not the tool’s summary line.</strong> Three of the four failures here reported success. dbt said the model built, and it had — unpartitioned. dbt said <code class=\"language-plaintext highlighter-rouge\">MERGE</code>, and it was, but only because the keys happened not to collide.</p>\n\n<p><code class=\"language-plaintext highlighter-rouge\">SHOW CREATE TABLE</code> and the <code class=\"language-plaintext highlighter-rouge\">$snapshots</code> / <code class=\"language-plaintext highlighter-rouge\">$files</code> metadata tables are the ground truth, and they are cheap to query. A CI check that asserts a silver table has the partition count you expect costs one query and catches a config key that everything else in the stack was willing to ignore.</p>\n\n<p>Part 3 takes the same tables and asks the operational question: what happens after a few thousand of these runs, and can you get back a table somebody deleted.</p>\n",
      "summary": "Build a medallion pipeline with dbt-trino on Iceberg, orchestrate it with Airflow 3, and find the partition spec dbt accepts and discards without a warning.",
      "date_published": "2026-08-16T11:00:00+05:45",
      "date_modified": "2026-08-16T00:00:00+05:45",
      "tags": ["iceberg-lakehouse","data-quality"],
      "authors": [
        {
          "name": "Basant Bhattarai",
          "url": "https://basantbhattarai.com.np"
        }
      ]
    },
    {
      "id": "https://basantbhattarai.com.np/writing/iceberg-compaction-snapshots-rollback/",
      "url": "https://basantbhattarai.com.np/writing/iceberg-compaction-snapshots-rollback/",
      "title": "Iceberg maintenance: 20 files into 1, and a rollback I actually performed",
      "content_html": "<h2 id=\"the-question-the-first-two-parts-skipped\">The question the first two parts skipped</h2>\n\n<p>Parts one and two built a lakehouse and filled it. Both stopped at the point where it works, which is the point most lakehouse writing stops at.</p>\n\n<p>The question that decides whether the thing is still usable in a year is different: Iceberg appends a data file per write and a snapshot per commit. A table fed by an hourly pipeline accumulates both. Nobody notices for a month. Then queries get slow, and the cause is not the query.</p>\n\n<p>This post creates that problem deliberately, measures it, repairs it, and then does the other thing worth knowing: deletes most of a table and gets it back. The rig is the January 2024 client stack rebuilt on current versions; the maintenance questions are the ones that stack never got old enough to face.</p>\n\n<h2 id=\"step-1--manufacture-the-small-files-problem\">Step 1 — Manufacture the small-files problem</h2>\n\n<p>Twenty small inserts into one partition — which is exactly what a five-minute micro-batch does over an hour and a half:</p>\n\n<div class=\"language-sql highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"c1\">-- repeated 20x with a varying key offset</span>\n<span class=\"k\">INSERT</span> <span class=\"k\">INTO</span> <span class=\"n\">lakehouse</span><span class=\"p\">.</span><span class=\"n\">silver</span><span class=\"p\">.</span><span class=\"n\">orders_enriched</span>\n<span class=\"k\">SELECT</span> <span class=\"n\">orderkey</span> <span class=\"o\">+</span> <span class=\"mi\">200000000</span> <span class=\"o\">+</span> <span class=\"p\">:</span><span class=\"n\">i</span> <span class=\"o\">*</span> <span class=\"mi\">100000</span><span class=\"p\">,</span> <span class=\"n\">custkey</span><span class=\"p\">,</span> <span class=\"s1\">'micro-batch'</span><span class=\"p\">,</span>\n       <span class=\"k\">CAST</span><span class=\"p\">(</span><span class=\"mi\">1</span> <span class=\"k\">AS</span> <span class=\"nb\">bigint</span><span class=\"p\">),</span> <span class=\"s1\">'O'</span><span class=\"p\">,</span> <span class=\"n\">totalprice</span><span class=\"p\">,</span> <span class=\"nb\">DATE</span> <span class=\"s1\">'2001-06-15'</span><span class=\"p\">,</span> <span class=\"mi\">1</span><span class=\"p\">,</span> <span class=\"s1\">'URGENT'</span><span class=\"p\">,</span> <span class=\"n\">clerk</span>\n<span class=\"k\">FROM</span> <span class=\"n\">tpch</span><span class=\"p\">.</span><span class=\"n\">sf1</span><span class=\"p\">.</span><span class=\"n\">orders</span> <span class=\"k\">LIMIT</span> <span class=\"mi\">5000</span><span class=\"p\">;</span>\n</code></pre></div></div>\n\n<p>The damage, read from Iceberg’s own metadata:</p>\n\n<div class=\"language-sql highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"k\">SELECT</span> <span class=\"k\">count</span><span class=\"p\">(</span><span class=\"o\">*</span><span class=\"p\">)</span> <span class=\"k\">AS</span> <span class=\"n\">files</span><span class=\"p\">,</span>\n       <span class=\"k\">cast</span><span class=\"p\">(</span><span class=\"k\">sum</span><span class=\"p\">(</span><span class=\"n\">file_size_in_bytes</span><span class=\"p\">)</span><span class=\"o\">/</span><span class=\"mi\">1024</span> <span class=\"k\">AS</span> <span class=\"nb\">bigint</span><span class=\"p\">)</span> <span class=\"k\">AS</span> <span class=\"n\">total_kb</span><span class=\"p\">,</span>\n       <span class=\"k\">cast</span><span class=\"p\">(</span><span class=\"k\">avg</span><span class=\"p\">(</span><span class=\"n\">file_size_in_bytes</span><span class=\"p\">)</span><span class=\"o\">/</span><span class=\"mi\">1024</span> <span class=\"k\">AS</span> <span class=\"nb\">bigint</span><span class=\"p\">)</span> <span class=\"k\">AS</span> <span class=\"n\">avg_kb</span><span class=\"p\">,</span>\n       <span class=\"k\">sum</span><span class=\"p\">(</span><span class=\"n\">record_count</span><span class=\"p\">)</span> <span class=\"k\">AS</span> <span class=\"k\">rows</span>\n<span class=\"k\">FROM</span> <span class=\"n\">lakehouse</span><span class=\"p\">.</span><span class=\"n\">silver</span><span class=\"p\">.</span><span class=\"nv\">\"orders_enriched$files\"</span>\n<span class=\"k\">WHERE</span> <span class=\"k\">partition</span><span class=\"p\">.</span><span class=\"n\">orderdate_month</span> <span class=\"o\">=</span> <span class=\"n\">date_diff</span><span class=\"p\">(</span><span class=\"s1\">'month'</span><span class=\"p\">,</span> <span class=\"nb\">DATE</span> <span class=\"s1\">'1970-01-01'</span><span class=\"p\">,</span> <span class=\"nb\">DATE</span> <span class=\"s1\">'2001-06-01'</span><span class=\"p\">);</span>\n</code></pre></div></div>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code> files | total_kb | avg_kb |  rows\n-------+----------+--------+--------\n    20 |     1006 |     50 | 100000\n</code></pre></div></div>\n\n<p>Twenty files averaging 50 KB. Every query touching that month opens twenty objects, reads twenty sets of Parquet footers, and plans twenty splits — for 100,000 rows that would fit comfortably in one.</p>\n\n<p class=\"verify\"><strong>Verify.</strong> Median query time on that partition, seven server-side runs after two warm-ups: <strong>184 ms</strong>.</p>\n\n<h2 id=\"step-2--compact-and-measure-what-it-bought\">Step 2 — Compact, and measure what it bought</h2>\n\n<div class=\"language-sql highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"k\">ALTER</span> <span class=\"k\">TABLE</span> <span class=\"n\">lakehouse</span><span class=\"p\">.</span><span class=\"n\">silver</span><span class=\"p\">.</span><span class=\"n\">orders_enriched</span> <span class=\"k\">EXECUTE</span> <span class=\"n\">optimize</span><span class=\"p\">;</span>\n</code></pre></div></div>\n\n<p>1.4 seconds.</p>\n\n<table>\n  <thead>\n    <tr>\n      <th> </th>\n      <th>before</th>\n      <th>after</th>\n    </tr>\n  </thead>\n  <tbody>\n    <tr>\n      <td>data files in partition</td>\n      <td>20</td>\n      <td><strong>1</strong></td>\n    </tr>\n    <tr>\n      <td>total bytes</td>\n      <td>1,006 KB</td>\n      <td><strong>430 KB</strong></td>\n    </tr>\n    <tr>\n      <td>average file</td>\n      <td>50 KB</td>\n      <td>430 KB</td>\n    </tr>\n    <tr>\n      <td>median query on that partition</td>\n      <td>184 ms</td>\n      <td><strong>69 ms</strong></td>\n    </tr>\n  </tbody>\n</table>\n\n<p>The byte reduction is the part that surprised me. Compaction is usually described as a file-count fix, but total size fell 57% on identical rows — Parquet’s dictionary and run-length encodings work far better with more rows per row-group. You get the planning win <em>and</em> less to read.</p>\n\n<div class=\"callout callout--gotcha\">\n  <p><strong><code class=\"language-plaintext highlighter-rouge\">optimize</code> only merges files within a partition.</strong> Run against the full table — 82 partitions holding one file each — it completes successfully and changes nothing, producing no new snapshot at all. That looks exactly like a broken command. It is correct: there is nothing to combine, because compaction never merges across partition boundaries.</p>\n\n  <p>This is worth knowing before you schedule it, because it means a table partitioned too finely cannot be compacted out of a small-files problem. The repair there is the partition spec, not maintenance.</p>\n</div>\n\n<p class=\"verify\"><strong>Verify.</strong> <code class=\"language-plaintext highlighter-rouge\">SELECT count(*) FROM \"orders_enriched$files\" WHERE partition.orderdate_month = …</code> returns 1, and the <code class=\"language-plaintext highlighter-rouge\">$snapshots</code> table has a new <code class=\"language-plaintext highlighter-rouge\">replace</code> commit.</p>\n\n<h2 id=\"step-3--the-accident\">Step 3 — The accident</h2>\n\n<p>An unqualified <code class=\"language-plaintext highlighter-rouge\">DELETE</code> — the kind that happens when a <code class=\"language-plaintext highlighter-rouge\">WHERE</code> clause is pasted one line too high:</p>\n\n<div class=\"language-sql highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"c1\">-- 1,645,000 rows before this</span>\n<span class=\"k\">DELETE</span> <span class=\"k\">FROM</span> <span class=\"n\">lakehouse</span><span class=\"p\">.</span><span class=\"n\">silver</span><span class=\"p\">.</span><span class=\"n\">orders_enriched</span> <span class=\"k\">WHERE</span> <span class=\"n\">orderdate</span> <span class=\"o\">&lt;</span> <span class=\"nb\">DATE</span> <span class=\"s1\">'1996-01-01'</span><span class=\"p\">;</span>\n</code></pre></div></div>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>DELETE: 909968 rows\n</code></pre></div></div>\n\n<div class=\"language-sql highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"k\">SELECT</span> <span class=\"k\">count</span><span class=\"p\">(</span><span class=\"o\">*</span><span class=\"p\">)</span> <span class=\"k\">FROM</span> <span class=\"n\">lakehouse</span><span class=\"p\">.</span><span class=\"n\">silver</span><span class=\"p\">.</span><span class=\"n\">orders_enriched</span><span class=\"p\">;</span>  <span class=\"c1\">-- 735,032</span>\n</code></pre></div></div>\n\n<p>Just over 55% of the table, gone, committed, with no transaction to roll back.</p>\n\n<h2 id=\"step-4--read-the-past-then-return-to-it\">Step 4 — Read the past, then return to it</h2>\n\n<p>Iceberg’s answer has two halves, and the first one is the one people forget. Before changing anything, you can <em>read</em> the old state:</p>\n\n<div class=\"language-sql highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"k\">SELECT</span> <span class=\"k\">count</span><span class=\"p\">(</span><span class=\"o\">*</span><span class=\"p\">)</span> <span class=\"k\">FROM</span> <span class=\"n\">lakehouse</span><span class=\"p\">.</span><span class=\"n\">silver</span><span class=\"p\">.</span><span class=\"n\">orders_enriched</span>\n<span class=\"k\">FOR</span> <span class=\"k\">VERSION</span> <span class=\"k\">AS</span> <span class=\"k\">OF</span> <span class=\"mi\">307623642966580244</span><span class=\"p\">;</span>\n</code></pre></div></div>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>1645000\n</code></pre></div></div>\n\n<p>That is a read against a historical snapshot. Nothing is modified; you can diff the two, confirm the damage is what you think it is, and copy out a subset if a full rollback is too blunt.</p>\n\n<p>Then the rollback:</p>\n\n<div class=\"language-sql highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"k\">ALTER</span> <span class=\"k\">TABLE</span> <span class=\"n\">lakehouse</span><span class=\"p\">.</span><span class=\"n\">silver</span><span class=\"p\">.</span><span class=\"n\">orders_enriched</span>\n<span class=\"k\">EXECUTE</span> <span class=\"n\">rollback_to_snapshot</span><span class=\"p\">(</span><span class=\"mi\">307623642966580244</span><span class=\"p\">);</span>\n</code></pre></div></div>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>real    0m0.589s\n</code></pre></div></div>\n\n<div class=\"language-sql highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"k\">SELECT</span> <span class=\"k\">count</span><span class=\"p\">(</span><span class=\"o\">*</span><span class=\"p\">)</span> <span class=\"k\">FROM</span> <span class=\"n\">lakehouse</span><span class=\"p\">.</span><span class=\"n\">silver</span><span class=\"p\">.</span><span class=\"n\">orders_enriched</span><span class=\"p\">;</span>  <span class=\"c1\">-- 1,645,000</span>\n</code></pre></div></div>\n\n<p><strong>909,968 rows recovered in 589 milliseconds.</strong></p>\n\n<p>The speed is the point, and it is worth understanding why. No data moved. The deleted files were never removed from storage — the <code class=\"language-plaintext highlighter-rouge\">DELETE</code> wrote a new snapshot that stopped referencing them. Rolling back rewrites the current-snapshot pointer in the catalog to an earlier manifest list. It is three orders of magnitude faster than the equivalent point-in-time restore in <a href=\"/writing/\">the pgBackRest lab</a>, which had to physically restore a backup and replay WAL.</p>\n\n<p class=\"verify\"><strong>Verify.</strong> Row count matches the pre-incident total exactly, and <code class=\"language-plaintext highlighter-rouge\">$snapshots</code> shows the rollback as the current snapshot.</p>\n\n<h2 id=\"step-5--where-the-safety-net-ends\">Step 5 — Where the safety net ends</h2>\n\n<p>The files survived the <code class=\"language-plaintext highlighter-rouge\">DELETE</code> because a snapshot still referenced them. So the obvious question is what happens when that snapshot goes away.</p>\n\n<div class=\"language-sql highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"k\">ALTER</span> <span class=\"k\">TABLE</span> <span class=\"n\">lakehouse</span><span class=\"p\">.</span><span class=\"n\">silver</span><span class=\"p\">.</span><span class=\"n\">orders_enriched</span>\n<span class=\"k\">EXECUTE</span> <span class=\"n\">expire_snapshots</span><span class=\"p\">(</span><span class=\"n\">retention_threshold</span> <span class=\"o\">=&gt;</span> <span class=\"s1\">'0s'</span><span class=\"p\">);</span>\n</code></pre></div></div>\n\n<p>Snapshot count went from <strong>25 to 1</strong>. Then:</p>\n\n<div class=\"language-sql highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"k\">SELECT</span> <span class=\"k\">count</span><span class=\"p\">(</span><span class=\"o\">*</span><span class=\"p\">)</span> <span class=\"k\">FROM</span> <span class=\"n\">lakehouse</span><span class=\"p\">.</span><span class=\"n\">silver</span><span class=\"p\">.</span><span class=\"n\">orders_enriched</span>\n<span class=\"k\">FOR</span> <span class=\"k\">VERSION</span> <span class=\"k\">AS</span> <span class=\"k\">OF</span> <span class=\"mi\">307623642966580244</span><span class=\"p\">;</span>\n</code></pre></div></div>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>Query … failed: Iceberg snapshot ID does not exists: 307623642966580244\n</code></pre></div></div>\n\n<p>That is the whole lesson in one error message. <strong><code class=\"language-plaintext highlighter-rouge\">expire_snapshots</code> is the operation that destroys your ability to undo.</strong> Compaction leaves the old files behind, still referenced by history; expiry is what actually releases them, and it takes the rollback with it.</p>\n\n<p>Which means the retention threshold is not a housekeeping setting. It is your recovery window, exactly as a pgBackRest retention policy is, and it should be chosen by the same conversation: how long before someone notices a bad load?</p>\n\n<div class=\"callout callout--gotcha\">\n  <p><strong>Trino guards this with a 7-day floor.</strong> Asking for less returns:</p>\n\n  <div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>Retention specified (1.00m) is shorter than the minimum retention configured\nin the system (7.00d)\n</code></pre></div>  </div>\n\n  <p>Overriding it needs a session property — and the error message names the <strong>connector</strong>, <code class=\"language-plaintext highlighter-rouge\">iceberg.expire_snapshots_min_retention</code>, while the property must be namespaced by your <strong>catalog</strong>. My catalog is called <code class=\"language-plaintext highlighter-rouge\">lakehouse</code>, so:</p>\n\n  <div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>trino <span class=\"nt\">--session</span> lakehouse.expire_snapshots_min_retention<span class=\"o\">=</span>0s\n</code></pre></div>  </div>\n\n  <p>Using the name from the error message gets <code class=\"language-plaintext highlighter-rouge\">Catalog 'iceberg' not found</code>.</p>\n</div>\n\n<h2 id=\"step-6--order-the-maintenance-correctly\">Step 6 — Order the maintenance correctly</h2>\n\n<p>Both operations belong on a schedule, and the order is not arbitrary:</p>\n\n<div data-file=\"airflow/dags/lakehouse_pipeline.py\" class=\"language-python highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nd\">@task</span>\n<span class=\"k\">def</span> <span class=\"nf\">maintain</span><span class=\"p\">()</span> <span class=\"o\">-&gt;</span> <span class=\"nb\">dict</span><span class=\"p\">[</span><span class=\"nb\">str</span><span class=\"p\">,</span> <span class=\"nb\">int</span><span class=\"p\">]:</span>\n    <span class=\"sh\">\"\"\"</span><span class=\"s\">Compact, then expire.\n\n    `optimize` writes new compacted files and leaves the originals behind,\n    still referenced by older snapshots. Expiring afterwards is what actually\n    releases them. Run expire first and compaction</span><span class=\"sh\">'</span><span class=\"s\">s own garbage stays until\n    the next cycle.\n    </span><span class=\"sh\">\"\"\"</span>\n    <span class=\"nf\">_run</span><span class=\"p\">(</span><span class=\"sh\">\"</span><span class=\"s\">ALTER TABLE … EXECUTE optimize</span><span class=\"sh\">\"</span><span class=\"p\">)</span>\n    <span class=\"nf\">_run</span><span class=\"p\">(</span><span class=\"sh\">\"</span><span class=\"s\">ALTER TABLE … EXECUTE expire_snapshots(retention_threshold =&gt; </span><span class=\"sh\">'</span><span class=\"s\">7d</span><span class=\"sh\">'</span><span class=\"s\">)</span><span class=\"sh\">\"</span><span class=\"p\">)</span>\n</code></pre></div></div>\n\n<p>In this lab <code class=\"language-plaintext highlighter-rouge\">maintain</code> runs on every pass so the effect is visible quickly. In production it belongs on its own schedule: compaction rewrites files and competes with the pipeline for the same workers, and there is no reason for it to contend with the load it is cleaning up after.</p>\n\n<h2 id=\"failure-modes\">Failure modes</h2>\n\n<table>\n  <thead>\n    <tr>\n      <th>Symptom</th>\n      <th>Cause</th>\n      <th>Repair</th>\n    </tr>\n  </thead>\n  <tbody>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">optimize</code> succeeds, nothing changes</td>\n      <td>One file per partition already</td>\n      <td>Nothing to fix; check the partition spec instead</td>\n    </tr>\n    <tr>\n      <td>Queries slowing with no query change</td>\n      <td>Small files accumulating</td>\n      <td>Schedule <code class=\"language-plaintext highlighter-rouge\">optimize</code>, measure <code class=\"language-plaintext highlighter-rouge\">$files</code> count</td>\n    </tr>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">Retention specified … shorter than the minimum</code></td>\n      <td>Trino’s 7-day floor</td>\n      <td>Session property namespaced by <strong>catalog</strong> name</td>\n    </tr>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">Catalog 'iceberg' not found</code></td>\n      <td>Used the connector name from the error text</td>\n      <td>Use your catalog’s actual name</td>\n    </tr>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">Iceberg snapshot ID does not exists</code></td>\n      <td>Snapshot already expired</td>\n      <td>Nothing to do — the window has closed</td>\n    </tr>\n    <tr>\n      <td>Storage growing despite <code class=\"language-plaintext highlighter-rouge\">optimize</code></td>\n      <td>Old files still referenced by history</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">expire_snapshots</code>, after compaction</td>\n    </tr>\n  </tbody>\n</table>\n\n<h2 id=\"clean-up-and-operating-consequence\">Clean up and operating consequence</h2>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>docker compose down <span class=\"nt\">-v</span>\n</code></pre></div></div>\n\n<p>Three rules came out of this, and only one of them is about performance.</p>\n\n<p><strong>Compaction is a query-latency control, and it is cheap.</strong> 1.4 seconds to take a partition from 184 ms to 69 ms, and to shed 57% of its bytes. It should be scheduled from the day the table exists, not the day someone complains.</p>\n\n<p><strong><code class=\"language-plaintext highlighter-rouge\">optimize</code> cannot fix a bad partition spec.</strong> It never merges across partitions, so a table partitioned by day when it should be by month has a small-files problem that maintenance cannot reach.</p>\n\n<p><strong>Retention is a recovery policy, not housekeeping.</strong> The rollback in step 4 took 589 ms and recovered 909,968 rows — but only because the snapshot still existed. Whoever sets <code class=\"language-plaintext highlighter-rouge\">expire_snapshots(retention_threshold =&gt; …)</code> is deciding how long you have to notice a mistake, and that is a decision worth making on purpose rather than inheriting from an example config.</p>\n",
      "summary": "Measure what compaction buys on a small-files partition, then delete 909,968 rows and get them back — and find where the safety net stops.",
      "date_published": "2026-08-16T10:00:00+05:45",
      "date_modified": "2026-08-16T00:00:00+05:45",
      "tags": ["iceberg-lakehouse","observability-slo"],
      "authors": [
        {
          "name": "Basant Bhattarai",
          "url": "https://basantbhattarai.com.np"
        }
      ]
    },
    {
      "id": "https://basantbhattarai.com.np/writing/starrocks-trino-one-iceberg-catalog/",
      "url": "https://basantbhattarai.com.np/writing/starrocks-trino-one-iceberg-catalog/",
      "title": "Two engines, one Iceberg catalog: StarRocks beside Trino, neither holding a key",
      "content_html": "<h2 id=\"the-claim-a-rest-catalog-is-actually-making\">The claim a REST catalog is actually making</h2>\n\n<p>Parts one to three built a lakehouse on Trino and never left it. That is a fine way to test Iceberg, and a poor way to test the <em>catalog</em>, because the argument for Polaris over Hive Metastore is not that it looks tables up faster.</p>\n\n<p>The argument is that the catalog is the system of record, so a second engine should see the tables the first one wrote — without an export, a copy, a <code class=\"language-plaintext highlighter-rouge\">CREATE EXTERNAL TABLE</code> per table, or a second set of credentials.</p>\n\n<p>That last clause is the part worth checking. <a href=\"/writing/\">Part one</a> went to some trouble to make Trino hold no standing S3 key. If adding StarRocks means pasting the MinIO keys into a second config file, the boundary is gone and the whole exercise was decoration.</p>\n\n<p>So: attach StarRocks 4.1.4 to the same catalog, read the same tables, and see. The original 2024 build ran StarRocks 3.2 beside Trino 437 against Hive Metastore — same question, and the answer has changed.</p>\n\n<h2 id=\"step-1--starrocks-on-the-same-network\">Step 1 — StarRocks on the same network</h2>\n\n<div data-file=\"docker-compose.yaml\" class=\"language-yaml highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>  <span class=\"na\">starrocks</span><span class=\"pi\">:</span>\n    <span class=\"na\">image</span><span class=\"pi\">:</span> <span class=\"s\">starrocks/allin1-ubuntu:4.1.4</span>\n    <span class=\"na\">profiles</span><span class=\"pi\">:</span> <span class=\"pi\">[</span><span class=\"s2\">\"</span><span class=\"s\">engines\"</span><span class=\"pi\">]</span>\n    <span class=\"na\">depends_on</span><span class=\"pi\">:</span>\n      <span class=\"na\">polaris</span><span class=\"pi\">:</span>\n        <span class=\"na\">condition</span><span class=\"pi\">:</span> <span class=\"s\">service_healthy</span>\n    <span class=\"na\">ports</span><span class=\"pi\">:</span>\n      <span class=\"pi\">-</span> <span class=\"s2\">\"</span><span class=\"s\">9030:9030\"</span>   <span class=\"c1\"># MySQL protocol</span>\n      <span class=\"pi\">-</span> <span class=\"s2\">\"</span><span class=\"s\">8030:8030\"</span>   <span class=\"c1\"># FE HTTP</span>\n    <span class=\"na\">healthcheck</span><span class=\"pi\">:</span>\n      <span class=\"na\">test</span><span class=\"pi\">:</span> <span class=\"pi\">[</span><span class=\"s2\">\"</span><span class=\"s\">CMD-SHELL\"</span><span class=\"pi\">,</span> <span class=\"s2\">\"</span><span class=\"s\">mysql</span><span class=\"nv\"> </span><span class=\"s\">-h127.0.0.1</span><span class=\"nv\"> </span><span class=\"s\">-P9030</span><span class=\"nv\"> </span><span class=\"s\">-uroot</span><span class=\"nv\"> </span><span class=\"s\">-e</span><span class=\"nv\"> </span><span class=\"s\">'SELECT</span><span class=\"nv\"> </span><span class=\"s\">1'</span><span class=\"nv\"> </span><span class=\"s\">||</span><span class=\"nv\"> </span><span class=\"s\">exit</span><span class=\"nv\"> </span><span class=\"s\">1\"</span><span class=\"pi\">]</span>\n      <span class=\"na\">interval</span><span class=\"pi\">:</span> <span class=\"s\">15s</span>\n      <span class=\"na\">timeout</span><span class=\"pi\">:</span> <span class=\"s\">10s</span>\n      <span class=\"na\">retries</span><span class=\"pi\">:</span> <span class=\"m\">40</span>\n      <span class=\"na\">start_period</span><span class=\"pi\">:</span> <span class=\"s\">90s</span>\n</code></pre></div></div>\n\n<p><code class=\"language-plaintext highlighter-rouge\">allin1</code> bundles the frontend and backend in one container. Production splits them; a lab that splits them spends 2 GB teaching nothing this post is about.</p>\n\n<div class=\"callout callout--gotcha\">\n  <p><strong><code class=\"language-plaintext highlighter-rouge\">SELECT 1</code> succeeding does not mean StarRocks is ready.</strong> The FE accepts connections well before any BE registers, so the healthcheck above goes green and the first real query fails:</p>\n\n  <div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>ERROR 1064 (HY000): Backend node not found. Check if any backend node is down.\n</code></pre></div>  </div>\n\n  <p>The honest readiness signal is <code class=\"language-plaintext highlighter-rouge\">SHOW BACKENDS</code> reporting <code class=\"language-plaintext highlighter-rouge\">Alive: true</code>. If you are scripting a wait loop, poll that rather than a trivial query — otherwise you will write a retry that papers over a startup ordering bug you have not actually fixed.</p>\n</div>\n\n<p class=\"verify\"><strong>Verify.</strong> <code class=\"language-plaintext highlighter-rouge\">SELECT current_version()</code> returns <code class=\"language-plaintext highlighter-rouge\">4.1.4-…</code> rather than the backend error.</p>\n\n<h2 id=\"step-2--one-catalog-definition-no-table-definitions\">Step 2 — One catalog definition, no table definitions</h2>\n\n<div data-file=\"starrocks\" class=\"language-sql highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"k\">CREATE</span> <span class=\"k\">EXTERNAL</span> <span class=\"k\">CATALOG</span> <span class=\"n\">lakehouse</span> <span class=\"n\">PROPERTIES</span> <span class=\"p\">(</span>\n  <span class=\"s1\">'type'</span> <span class=\"o\">=</span> <span class=\"s1\">'iceberg'</span><span class=\"p\">,</span>\n  <span class=\"s1\">'iceberg.catalog.type'</span> <span class=\"o\">=</span> <span class=\"s1\">'rest'</span><span class=\"p\">,</span>\n  <span class=\"s1\">'iceberg.catalog.uri'</span> <span class=\"o\">=</span> <span class=\"s1\">'http://polaris:8181/api/catalog'</span><span class=\"p\">,</span>\n  <span class=\"s1\">'iceberg.catalog.warehouse'</span> <span class=\"o\">=</span> <span class=\"s1\">'lakehouse'</span><span class=\"p\">,</span>\n  <span class=\"s1\">'iceberg.catalog.security'</span> <span class=\"o\">=</span> <span class=\"s1\">'oauth2'</span><span class=\"p\">,</span>\n  <span class=\"s1\">'iceberg.catalog.oauth2.credential'</span> <span class=\"o\">=</span> <span class=\"s1\">'root:s3cr3t-dev-only'</span><span class=\"p\">,</span>\n  <span class=\"s1\">'iceberg.catalog.oauth2.scope'</span> <span class=\"o\">=</span> <span class=\"s1\">'PRINCIPAL_ROLE:ALL'</span><span class=\"p\">,</span>\n  <span class=\"s1\">'iceberg.catalog.vended-credentials-enabled'</span> <span class=\"o\">=</span> <span class=\"s1\">'true'</span><span class=\"p\">,</span>\n  <span class=\"s1\">'aws.s3.endpoint'</span> <span class=\"o\">=</span> <span class=\"s1\">'http://minio:9000'</span><span class=\"p\">,</span>\n  <span class=\"s1\">'aws.s3.enable_path_style_access'</span> <span class=\"o\">=</span> <span class=\"s1\">'true'</span>\n<span class=\"p\">);</span>\n</code></pre></div></div>\n\n<p>That is the entire integration. No table DDL, no schema mirroring, no sync job.</p>\n\n<p>Read it back:</p>\n\n<div class=\"language-sql highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"k\">SHOW</span> <span class=\"n\">DATABASES</span> <span class=\"k\">FROM</span> <span class=\"n\">lakehouse</span><span class=\"p\">;</span>\n</code></pre></div></div>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>bronze\ngold\ninformation_schema\nsilver\nsilver_gold\nsilver_silver\n</code></pre></div></div>\n\n<p>Every namespace Trino created, immediately. Including <code class=\"language-plaintext highlighter-rouge\">silver_gold</code> and <code class=\"language-plaintext highlighter-rouge\">silver_silver</code> — the debris from the schema-naming bug in <a href=\"/writing/\">part two</a>, which I had fixed in dbt but never cleaned out of the catalog.</p>\n\n<p>That is a small thing that says something real: <strong>the catalog is shared, so mistakes are shared too.</strong> A second engine is also a second pair of eyes on your namespace hygiene.</p>\n\n<div class=\"language-sql highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"k\">SELECT</span> <span class=\"k\">count</span><span class=\"p\">(</span><span class=\"o\">*</span><span class=\"p\">)</span> <span class=\"k\">FROM</span> <span class=\"n\">lakehouse</span><span class=\"p\">.</span><span class=\"n\">silver</span><span class=\"p\">.</span><span class=\"n\">orders_enriched</span><span class=\"p\">;</span>\n</code></pre></div></div>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>1645100\n</code></pre></div></div>\n\n<p>Trino reports the same number for the same table.</p>\n\n<p class=\"verify\"><strong>Verify.</strong> Row counts match between engines, and <code class=\"language-plaintext highlighter-rouge\">SHOW DATABASES FROM lakehouse</code> lists the namespaces you created in Trino.</p>\n\n<h2 id=\"step-3--does-the-second-engine-reintroduce-standing-keys\">Step 3 — Does the second engine reintroduce standing keys?</h2>\n\n<p>The catalog above declares <code class=\"language-plaintext highlighter-rouge\">vended-credentials-enabled</code> and — deliberately — <strong>no <code class=\"language-plaintext highlighter-rouge\">aws.s3.access_key</code> or <code class=\"language-plaintext highlighter-rouge\">aws.s3.secret_key</code> at all</strong>. If StarRocks did not support vending, this would fail at first read.</p>\n\n<p>It does not fail. 1,645,100 rows, with StarRocks obtaining short-lived credentials from Polaris exactly as Trino does: authenticate as an OAuth2 principal, receive a session token scoped to the table’s prefix, use it, discard it.</p>\n\n<p>This is the result that justifies the architecture. The authorisation boundary built in part one is <strong>engine independent</strong>. Adding a query engine does not add a place where a long-lived S3 key has to live, which is the failure mode that quietly undoes most “we use a catalog” stories.</p>\n\n<div class=\"callout callout--gotcha\">\n  <p><strong>Check this on your own store rather than assuming it.</strong> Vending depends on the object store exposing STS and on the engine implementing the delegation header. Part one showed both failing against MinIO in different ways. The five-minute test is the one above: define a catalog with no keys in it and see whether a read succeeds.</p>\n</div>\n\n<h2 id=\"step-4--measure-both-fairly\">Step 4 — Measure both, fairly</h2>\n\n<p>Same query, same table, same catalog:</p>\n\n<div class=\"language-sql highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"k\">SELECT</span> <span class=\"k\">count</span><span class=\"p\">(</span><span class=\"o\">*</span><span class=\"p\">),</span> <span class=\"k\">sum</span><span class=\"p\">(</span><span class=\"n\">totalprice</span><span class=\"p\">)</span>\n<span class=\"k\">FROM</span> <span class=\"n\">lakehouse</span><span class=\"p\">.</span><span class=\"n\">silver</span><span class=\"p\">.</span><span class=\"n\">orders_enriched</span>\n<span class=\"k\">WHERE</span> <span class=\"n\">orderdate</span> <span class=\"o\">&gt;=</span> <span class=\"s1\">'1995-03-01'</span> <span class=\"k\">AND</span> <span class=\"n\">orderdate</span> <span class=\"o\">&lt;</span> <span class=\"s1\">'1995-04-01'</span><span class=\"p\">;</span>\n</code></pre></div></div>\n\n<p>Getting a comparable number took two attempts. Wall-clock through each engine’s CLI is useless: the Trino CLI starts a JVM, the MySQL client does not. Subtracting a <code class=\"language-plaintext highlighter-rouge\">SELECT 1</code> baseline from each was worse — it gave Trino a <strong>negative</strong> query time, because JVM startup varies by more than the query costs.</p>\n\n<p>Both engines report their own timings, so use those. Trino from <code class=\"language-plaintext highlighter-rouge\">system.runtime.queries</code>; StarRocks from its FE audit log:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nb\">grep</span> <span class=\"s1\">'orders_enriched'</span> /data/deploy/starrocks/fe/log/fe.audit.log <span class=\"se\">\\</span>\n  | <span class=\"nb\">grep</span> <span class=\"nt\">-o</span> <span class=\"s1\">'Time=[0-9]*'</span> | <span class=\"nb\">tail</span> <span class=\"nt\">-7</span>\n</code></pre></div></div>\n\n<table>\n  <thead>\n    <tr>\n      <th>engine</th>\n      <th>server-side median</th>\n    </tr>\n  </thead>\n  <tbody>\n    <tr>\n      <td>Trino 483</td>\n      <td>99 ms</td>\n    </tr>\n    <tr>\n      <td>StarRocks 4.1.4</td>\n      <td><strong>34 ms</strong></td>\n    </tr>\n  </tbody>\n</table>\n\n<div class=\"callout callout--gotcha\">\n  <p><strong>This is a narrow test and the number should be read narrowly.</strong> One aggregate, one partition, 100,000 rows, on a laptop. That is close to the shape StarRocks’ vectorised MPP engine is built for, and it says nothing about the cases Trino is usually chosen for: federating across catalogs, joining a lakehouse table to a Postgres table, or long multi-table joins where the cost model matters more than the scan.</p>\n\n  <p>The useful conclusion is not “StarRocks is 2.9× faster”. It is that <strong>you can now measure that question on your own data without migrating anything</strong>, because both engines read the same tables. Before a REST catalog, answering it meant a copy.</p>\n</div>\n\n<h2 id=\"step-5--clean-up-the-shared-debris\">Step 5 — Clean up the shared debris</h2>\n\n<p>Since the catalog is shared, so is the tidying:</p>\n\n<div class=\"language-sql highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"k\">DROP</span> <span class=\"k\">TABLE</span> <span class=\"n\">IF</span> <span class=\"k\">EXISTS</span> <span class=\"n\">lakehouse</span><span class=\"p\">.</span><span class=\"n\">silver_silver</span><span class=\"p\">.</span><span class=\"n\">orders_enriched</span><span class=\"p\">;</span>\n<span class=\"k\">DROP</span> <span class=\"k\">TABLE</span> <span class=\"n\">IF</span> <span class=\"k\">EXISTS</span> <span class=\"n\">lakehouse</span><span class=\"p\">.</span><span class=\"n\">silver_gold</span><span class=\"p\">.</span><span class=\"n\">daily_revenue</span><span class=\"p\">;</span>\n<span class=\"k\">DROP</span> <span class=\"k\">SCHEMA</span> <span class=\"n\">IF</span> <span class=\"k\">EXISTS</span> <span class=\"n\">lakehouse</span><span class=\"p\">.</span><span class=\"n\">silver_silver</span><span class=\"p\">;</span>\n<span class=\"k\">DROP</span> <span class=\"k\">SCHEMA</span> <span class=\"n\">IF</span> <span class=\"k\">EXISTS</span> <span class=\"n\">lakehouse</span><span class=\"p\">.</span><span class=\"n\">silver_gold</span><span class=\"p\">;</span>\n</code></pre></div></div>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>bronze\ngold\ninformation_schema\nsilver\nsystem\n</code></pre></div></div>\n\n<p>Note this needs <code class=\"language-plaintext highlighter-rouge\">DROP_WITH_PURGE_ENABLED</code> from part two, or the drops fail with a Trino error that never mentions purge.</p>\n\n<h2 id=\"failure-modes\">Failure modes</h2>\n\n<table>\n  <thead>\n    <tr>\n      <th>Symptom</th>\n      <th>Cause</th>\n      <th>Repair</th>\n    </tr>\n  </thead>\n  <tbody>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">Backend node not found</code></td>\n      <td>FE up, BE not yet registered</td>\n      <td>Poll <code class=\"language-plaintext highlighter-rouge\">SHOW BACKENDS</code> for <code class=\"language-plaintext highlighter-rouge\">Alive: true</code></td>\n    </tr>\n    <tr>\n      <td>Catalog created, <code class=\"language-plaintext highlighter-rouge\">SHOW DATABASES</code> empty</td>\n      <td>OAuth2 scope or grant chain missing</td>\n      <td>Check <code class=\"language-plaintext highlighter-rouge\">PRINCIPAL_ROLE:ALL</code> and the part-one grant chain</td>\n    </tr>\n    <tr>\n      <td>Reads fail with no S3 keys set</td>\n      <td>Engine or store lacks vending support</td>\n      <td>Test with a keyless catalog before committing to the design</td>\n    </tr>\n    <tr>\n      <td>Unexpected namespaces appear</td>\n      <td>Shared catalog, shared history</td>\n      <td>They are real; clean them from either engine</td>\n    </tr>\n    <tr>\n      <td>Engine timings wildly inconsistent</td>\n      <td>Measuring client startup</td>\n      <td>Use <code class=\"language-plaintext highlighter-rouge\">system.runtime.queries</code> and <code class=\"language-plaintext highlighter-rouge\">fe.audit.log</code></td>\n    </tr>\n  </tbody>\n</table>\n\n<h2 id=\"clean-up-and-operating-consequence\">Clean up and operating consequence</h2>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>docker compose <span class=\"nt\">--profile</span> engines down\n</code></pre></div></div>\n\n<p>The rule: <strong>a catalog earns its place when the second engine costs one DDL statement and no new credentials.</strong></p>\n\n<p>That is a testable bar, and it is worth applying before committing to a catalog rather than after. Attach a second engine to a throwaway namespace, define it with no storage keys, and read a table the first engine wrote. If that works, the catalog is doing the job the architecture diagram claims. If it needs a copy, a manual table registration, or its own set of long-lived keys, you have a metadata service — which may still be worth running, but not for the reasons usually given.</p>\n\n<p>The rest of this series is on GitHub as a single <code class=\"language-plaintext highlighter-rouge\">docker compose</code> file: <a href=\"/work/\">MinIO, Polaris, Trino, dbt, Airflow and StarRocks</a>, with every figure in these four posts reproducible from it.</p>\n",
      "summary": "Attach StarRocks to the same Polaris REST catalog Trino writes through, measure both on identical data, and check for standing S3 keys.",
      "date_published": "2026-08-16T09:00:00+05:45",
      "date_modified": "2026-08-16T00:00:00+05:45",
      "tags": ["iceberg-lakehouse","clickhouse"],
      "authors": [
        {
          "name": "Basant Bhattarai",
          "url": "https://basantbhattarai.com.np"
        }
      ]
    },
    {
      "id": "https://basantbhattarai.com.np/writing/rag-lakehouse-delta-spark-chroma/",
      "url": "https://basantbhattarai.com.np/writing/rag-lakehouse-delta-spark-chroma/",
      "title": "A RAG pipeline on a Delta lakehouse, and the bug that survived being fixed",
      "content_html": "<h2 id=\"what-this-is\">What this is</h2>\n\n<p>A small end-to-end system: scrape a bounded set of pages, land them in object storage, refine them through bronze, silver and gold Delta tables on Spark, embed the result, and serve answers that cite the passages they came from — or decline.</p>\n\n<p>I built the first version in July 2025. Coming back to it in 2026, the most useful thing about it was not that it worked; it was <strong>why it had stopped</strong>.</p>\n\n<h2 id=\"step-0--why-a-2025-compose-file-does-not-start-in-2026\">Step 0 — Why a 2025 compose file does not start in 2026</h2>\n\n<p>Three reasons, and the first is not a version problem.</p>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>bitnami/spark:3.5                  GONE\nmarquezproject/marquez:latest      pulls\nchromadb/chroma:latest             pulls\n</code></pre></div></div>\n\n<p><strong><code class=\"language-plaintext highlighter-rouge\">bitnami/spark</code> is gone.</strong> Broadcom moved the Bitnami catalog behind a paid tier during 2025. A <code class=\"language-plaintext highlighter-rouge\">bitnamilegacy</code> namespace survives, but it publishes <strong>only sha256 digests</strong> — there is no <code class=\"language-plaintext highlighter-rouge\">3.5</code>, nothing you can write in a compose file and read six months later.</p>\n\n<p><strong>Nine images were pinned to <code class=\"language-plaintext highlighter-rouge\">:latest</code>.</strong> Which means the stack that ran in July 2025 is not recoverable, and no assertion about it is checkable. That is the actual cost of <code class=\"language-plaintext highlighter-rouge\">:latest</code>, and it is not a style preference.</p>\n\n<p><strong>Three JAR files, roughly 270 MB, had to be downloaded by hand</strong> — <code class=\"language-plaintext highlighter-rouge\">aws-java-sdk-bundle</code>, <code class=\"language-plaintext highlighter-rouge\">hadoop-aws</code>, <code class=\"language-plaintext highlighter-rouge\">hadoop-common</code> — and they were in neither the repository nor my own working copy. The public repo failed on first run for everyone who cloned it, including me. They were also the AWS SDK v1 S3A stack, which Spark 4 has moved past regardless.</p>\n\n<div class=\"callout callout--gotcha\">\n  <p><strong>A vendor’s free tier is a dependency.</strong> The image was pinned, the tag was specific, and it still evaporated. When a base image comes from a commercial vendor’s community edition, the supported exit is worth knowing before you need it — here it was the official <code class=\"language-plaintext highlighter-rouge\">apache/spark</code>, which also publishes arm64.</p>\n</div>\n\n<h2 id=\"step-1--spark-and-delta-with-nothing-downloaded-by-hand\">Step 1 — Spark and Delta, with nothing downloaded by hand</h2>\n\n<div data-file=\"docker-compose.yaml\" class=\"language-yaml highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>  <span class=\"na\">spark-master</span><span class=\"pi\">:</span>\n    <span class=\"na\">image</span><span class=\"pi\">:</span> <span class=\"s\">apache/spark:4.0.4-scala2.13-java17-python3-r-ubuntu</span>\n    <span class=\"na\">command</span><span class=\"pi\">:</span> <span class=\"s\">/opt/spark/bin/spark-class org.apache.spark.deploy.master.Master</span>\n    <span class=\"na\">volumes</span><span class=\"pi\">:</span>\n      <span class=\"pi\">-</span> <span class=\"s\">./spark:/opt/spark-lab:ro</span>\n      <span class=\"pi\">-</span> <span class=\"s\">./jobs:/opt/jobs:ro</span>\n      <span class=\"pi\">-</span> <span class=\"s\">ivy_cache:/tmp/ivy</span>\n</code></pre></div></div>\n\n<p>The <code class=\"language-plaintext highlighter-rouge\">jars/</code> directory is gone entirely. Spark resolves S3A and Delta from Maven at submit time:</p>\n\n<div data-file=\"spark/submit.sh\" class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nb\">exec</span> /opt/spark/bin/spark-submit <span class=\"se\">\\</span>\n  <span class=\"nt\">--master</span> <span class=\"s2\">\"</span><span class=\"k\">${</span><span class=\"nv\">SPARK_MASTER</span><span class=\"k\">:-</span><span class=\"nv\">spark</span>://spark-master:7077<span class=\"k\">}</span><span class=\"s2\">\"</span> <span class=\"se\">\\</span>\n  <span class=\"nt\">--conf</span> spark.jars.ivy<span class=\"o\">=</span><span class=\"s2\">\"</span><span class=\"k\">${</span><span class=\"nv\">IVY_DIR</span><span class=\"k\">:-</span><span class=\"p\">/tmp/ivy</span><span class=\"k\">}</span><span class=\"s2\">\"</span> <span class=\"se\">\\</span>\n  <span class=\"nt\">--packages</span> <span class=\"s2\">\"io.delta:delta-spark_2.13:</span><span class=\"k\">${</span><span class=\"nv\">DELTA_VERSION</span><span class=\"k\">}</span><span class=\"s2\">,org.apache.hadoop:hadoop-aws:</span><span class=\"k\">${</span><span class=\"nv\">HADOOP_AWS_VERSION</span><span class=\"k\">}</span><span class=\"s2\">\"</span> <span class=\"se\">\\</span>\n  <span class=\"nt\">--conf</span> spark.sql.extensions<span class=\"o\">=</span>io.delta.sql.DeltaSparkSessionExtension <span class=\"se\">\\</span>\n  <span class=\"nt\">--conf</span> spark.sql.catalog.spark_catalog<span class=\"o\">=</span>org.apache.spark.sql.delta.catalog.DeltaCatalog <span class=\"se\">\\</span>\n  <span class=\"nt\">--conf</span> spark.hadoop.fs.s3a.endpoint<span class=\"o\">=</span><span class=\"s2\">\"</span><span class=\"k\">${</span><span class=\"nv\">S3_ENDPOINT</span><span class=\"k\">}</span><span class=\"s2\">\"</span> <span class=\"se\">\\</span>\n  <span class=\"nt\">--conf</span> spark.hadoop.fs.s3a.path.style.access<span class=\"o\">=</span><span class=\"nb\">true</span> <span class=\"se\">\\</span>\n  <span class=\"s2\">\"</span><span class=\"nv\">$@</span><span class=\"s2\">\"</span>\n</code></pre></div></div>\n\n<p>Two things in there are load-bearing and neither is obvious.</p>\n\n<div class=\"callout callout--gotcha\">\n  <p><strong><code class=\"language-plaintext highlighter-rouge\">spark.jars.ivy</code> is not optional on <code class=\"language-plaintext highlighter-rouge\">apache/spark</code>.</strong> The <code class=\"language-plaintext highlighter-rouge\">spark</code> user has no home directory, so spark-submit dies before resolving anything:</p>\n\n  <div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>java.io.FileNotFoundException: /nonexistent/.ivy2.5.2/cache/resolved-org.apache.spark-…xml\n</code></pre></div>  </div>\n\n  <p>Bitnami’s image set a home; the official one does not. This is the single most likely thing to bite anyone migrating off Bitnami.</p>\n</div>\n\n<p><strong>Delta and Spark are pinned to each other, minor by minor.</strong> Same Spark, three Delta versions:</p>\n\n<table>\n  <thead>\n    <tr>\n      <th><code class=\"language-plaintext highlighter-rouge\">delta-spark_2.13</code></th>\n      <th>Spark 4.0.4</th>\n      <th>result</th>\n    </tr>\n  </thead>\n  <tbody>\n    <tr>\n      <td>4.3.1 (latest)</td>\n      <td>✗</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">NoSuchMethodError: ParserInterface.$init$</code></td>\n    </tr>\n    <tr>\n      <td>4.1.0</td>\n      <td>✗</td>\n      <td>same</td>\n    </tr>\n    <tr>\n      <td><strong>4.0.0</strong></td>\n      <td>✓</td>\n      <td>50,000 rows written</td>\n    </tr>\n  </tbody>\n</table>\n\n<p>All three resolve from Maven without a murmur. Maven has no idea which Spark you are running, so a wrong pairing is a class-load failure on the first DataFrame call, several seconds after everything looked fine. <strong>“Use the latest” is the wrong instinct here. Matching is.</strong></p>\n\n<div class=\"callout callout--gotcha\">\n  <p><strong>One from the container runtime rather than Spark.</strong> Mounting <code class=\"language-plaintext highlighter-rouge\">./jobs</code> at <code class=\"language-plaintext highlighter-rouge\">/opt/spark-lab/jobs</code> while <code class=\"language-plaintext highlighter-rouge\">/opt/spark-lab</code> was itself a <code class=\"language-plaintext highlighter-rouge\">:ro</code> mount left both Spark containers stuck in <code class=\"language-plaintext highlighter-rouge\">Created</code> with <strong>no logs at all</strong> — the failure happens before any process starts, so there is nothing to read and <code class=\"language-plaintext highlighter-rouge\">docker compose logs</code> returns empty. Use separate mount points.</p>\n</div>\n\n<p class=\"verify\"><strong>Verify.</strong> <code class=\"language-plaintext highlighter-rouge\">spark: 4.0.4</code>, <code class=\"language-plaintext highlighter-rouge\">rows_written: 50000</code>, and a <code class=\"language-plaintext highlighter-rouge\">_delta_log/</code> prefix in the bucket.</p>\n\n<h2 id=\"step-2--scraping-is-not-a-spark-job\">Step 2 — Scraping is not a Spark job</h2>\n\n<div data-file=\"ingest/scrape.py\" class=\"language-python highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"k\">def</span> <span class=\"nf\">allowed</span><span class=\"p\">(</span><span class=\"n\">url</span><span class=\"p\">:</span> <span class=\"nb\">str</span><span class=\"p\">)</span> <span class=\"o\">-&gt;</span> <span class=\"nb\">bool</span><span class=\"p\">:</span>\n    <span class=\"sh\">\"\"\"</span><span class=\"s\">Ask robots.txt per host, not once at the start.</span><span class=\"sh\">\"\"\"</span>\n    <span class=\"n\">parts</span> <span class=\"o\">=</span> <span class=\"n\">urllib</span><span class=\"p\">.</span><span class=\"n\">parse</span><span class=\"p\">.</span><span class=\"nf\">urlsplit</span><span class=\"p\">(</span><span class=\"n\">url</span><span class=\"p\">)</span>\n    <span class=\"n\">rp</span> <span class=\"o\">=</span> <span class=\"n\">robotparser</span><span class=\"p\">.</span><span class=\"nc\">RobotFileParser</span><span class=\"p\">()</span>\n    <span class=\"n\">rp</span><span class=\"p\">.</span><span class=\"nf\">set_url</span><span class=\"p\">(</span><span class=\"sa\">f</span><span class=\"sh\">\"</span><span class=\"si\">{</span><span class=\"n\">parts</span><span class=\"p\">.</span><span class=\"n\">scheme</span><span class=\"si\">}</span><span class=\"s\">://</span><span class=\"si\">{</span><span class=\"n\">parts</span><span class=\"p\">.</span><span class=\"n\">netloc</span><span class=\"si\">}</span><span class=\"s\">/robots.txt</span><span class=\"sh\">\"</span><span class=\"p\">)</span>\n    <span class=\"k\">try</span><span class=\"p\">:</span>\n        <span class=\"n\">rp</span><span class=\"p\">.</span><span class=\"nf\">read</span><span class=\"p\">()</span>\n    <span class=\"k\">except</span> <span class=\"nb\">Exception</span><span class=\"p\">:</span>\n        <span class=\"k\">return</span> <span class=\"bp\">False</span>   <span class=\"c1\"># unreadable robots.txt is a no, not a yes\n</span>    <span class=\"k\">return</span> <span class=\"n\">rp</span><span class=\"p\">.</span><span class=\"nf\">can_fetch</span><span class=\"p\">(</span><span class=\"n\">UA</span><span class=\"p\">,</span> <span class=\"n\">url</span><span class=\"p\">)</span>\n</code></pre></div></div>\n\n<p>Scraping is IO-bound and rate-limited by politeness. Putting it on the cluster buys nothing and makes the robots check harder to follow. It runs in a small Python container and lands append-only JSONL.</p>\n\n<p>Chunking is by <code class=\"language-plaintext highlighter-rouge\">&lt;h2&gt;</code>/<code class=\"language-plaintext highlighter-rouge\">&lt;h3&gt;</code> section rather than by page or by character count. A whole reference page is far too much context for one embedding; a fixed character window cuts sentences in half. A section is roughly one idea, which is the unit a question is usually about.</p>\n\n<p class=\"verify\"><strong>Verify.</strong> <code class=\"language-plaintext highlighter-rouge\">landed s3://lakehouse/raw/scrape/…jsonl sections=187 skipped=0 bytes=882663</code>.</p>\n\n<h2 id=\"step-3--bronze-silver-gold-and-what-each-layer-owes-you\">Step 3 — Bronze, silver, gold, and what each layer owes you</h2>\n\n<div data-file=\"jobs/etl.py\" class=\"language-python highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"c1\"># bronze: pure function of the raw prefix, overwritten each run\n</span><span class=\"n\">raw</span> <span class=\"o\">=</span> <span class=\"n\">spark</span><span class=\"p\">.</span><span class=\"n\">read</span><span class=\"p\">.</span><span class=\"nf\">json</span><span class=\"p\">(</span><span class=\"sa\">f</span><span class=\"sh\">\"</span><span class=\"si\">{</span><span class=\"n\">BUCKET</span><span class=\"si\">}</span><span class=\"s\">/raw/scrape/</span><span class=\"sh\">\"</span><span class=\"p\">)</span>\n<span class=\"n\">bronze</span> <span class=\"o\">=</span> <span class=\"n\">raw</span><span class=\"p\">.</span><span class=\"nf\">withColumn</span><span class=\"p\">(</span><span class=\"sh\">\"</span><span class=\"s\">ingested_at</span><span class=\"sh\">\"</span><span class=\"p\">,</span> <span class=\"n\">F</span><span class=\"p\">.</span><span class=\"nf\">current_timestamp</span><span class=\"p\">())</span> \\\n            <span class=\"p\">.</span><span class=\"nf\">withColumn</span><span class=\"p\">(</span><span class=\"sh\">\"</span><span class=\"s\">text_len</span><span class=\"sh\">\"</span><span class=\"p\">,</span> <span class=\"n\">F</span><span class=\"p\">.</span><span class=\"nf\">length</span><span class=\"p\">(</span><span class=\"sh\">\"</span><span class=\"s\">text</span><span class=\"sh\">\"</span><span class=\"p\">))</span>\n<span class=\"n\">bronze</span><span class=\"p\">.</span><span class=\"n\">write</span><span class=\"p\">.</span><span class=\"nf\">format</span><span class=\"p\">(</span><span class=\"sh\">\"</span><span class=\"s\">delta</span><span class=\"sh\">\"</span><span class=\"p\">).</span><span class=\"nf\">mode</span><span class=\"p\">(</span><span class=\"sh\">\"</span><span class=\"s\">overwrite</span><span class=\"sh\">\"</span><span class=\"p\">).</span><span class=\"nf\">save</span><span class=\"p\">(</span><span class=\"sa\">f</span><span class=\"sh\">\"</span><span class=\"si\">{</span><span class=\"n\">BUCKET</span><span class=\"si\">}</span><span class=\"s\">/bronze/sections</span><span class=\"sh\">\"</span><span class=\"p\">)</span>\n</code></pre></div></div>\n\n<table>\n  <thead>\n    <tr>\n      <th>layer</th>\n      <th>rows</th>\n      <th>what it owes you</th>\n    </tr>\n  </thead>\n  <tbody>\n    <tr>\n      <td>bronze</td>\n      <td>187</td>\n      <td>every raw record, typed and stamped, nothing dropped</td>\n    </tr>\n    <tr>\n      <td>silver</td>\n      <td>170</td>\n      <td>cleaned and deduplicated on <code class=\"language-plaintext highlighter-rouge\">(url, section)</code></td>\n    </tr>\n    <tr>\n      <td>gold</td>\n      <td>170</td>\n      <td>a deterministic <code class=\"language-plaintext highlighter-rouge\">chunk_id</code>, the text, and enough provenance to cite</td>\n    </tr>\n  </tbody>\n</table>\n\n<div class=\"callout callout--gotcha\">\n  <p><strong>Read the directory, not a glob.</strong> <code class=\"language-plaintext highlighter-rouge\">raw/scrape/*.jsonl</code> returns the right rows <em>and</em> throws <code class=\"language-plaintext highlighter-rouge\">FileNotFoundException: No such file or directory</code> into the log on the way past — S3A resolves the glob by listing, and the miss surfaces as an exception the job then recovers from. A stack trace in a successful run trains you to ignore stack traces.</p>\n</div>\n\n<h2 id=\"step-4--embeddings-and-why-the-id-is-a-hash\">Step 4 — Embeddings, and why the id is a hash</h2>\n\n<p><code class=\"language-plaintext highlighter-rouge\">chunk_id</code> is <code class=\"language-plaintext highlighter-rouge\">sha256(url :: section)</code> truncated to 16 characters. That makes it stable across runs, so re-running the pipeline <strong>updates</strong> a vector instead of inserting a second copy beside it.</p>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>read      170 chunks from delta in 0.21s\nembed     170 chunks in 2.62s (64.8/s, dim=384, model load 7.42s)\nupsert    170 vectors in 0.25s\n</code></pre></div></div>\n\n<p>Reading Delta here uses the Rust <code class=\"language-plaintext highlighter-rouge\">deltalake</code> reader, not PySpark. Embedding 170 rows does not need a cluster, and a 400 MB JVM image to read a small table is the kind of default that makes a stack feel heavier than its problem.</p>\n\n<h2 id=\"step-5--an-answer-that-can-be-checked-or-no-answer\">Step 5 — An answer that can be checked, or no answer</h2>\n\n<div data-file=\"rag/api.py\" class=\"language-python highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"n\">kept</span> <span class=\"o\">=</span> <span class=\"p\">[(</span><span class=\"n\">d</span><span class=\"p\">,</span> <span class=\"n\">m</span><span class=\"p\">,</span> <span class=\"n\">s</span><span class=\"p\">)</span> <span class=\"k\">for</span> <span class=\"n\">d</span><span class=\"p\">,</span> <span class=\"n\">m</span><span class=\"p\">,</span> <span class=\"n\">s</span> <span class=\"ow\">in</span> <span class=\"nf\">zip</span><span class=\"p\">(</span><span class=\"n\">docs</span><span class=\"p\">,</span> <span class=\"n\">metas</span><span class=\"p\">,</span> <span class=\"n\">dists</span><span class=\"p\">)</span> <span class=\"k\">if</span> <span class=\"n\">s</span> <span class=\"o\">&lt;=</span> <span class=\"n\">MAX_DISTANCE</span><span class=\"p\">]</span>\n<span class=\"k\">if</span> <span class=\"ow\">not</span> <span class=\"n\">kept</span><span class=\"p\">:</span>\n    <span class=\"k\">return</span> <span class=\"p\">{</span><span class=\"sh\">\"</span><span class=\"s\">answer</span><span class=\"sh\">\"</span><span class=\"p\">:</span> <span class=\"sh\">\"</span><span class=\"s\">I don</span><span class=\"sh\">'</span><span class=\"s\">t know.</span><span class=\"sh\">\"</span><span class=\"p\">,</span> <span class=\"sh\">\"</span><span class=\"s\">grounded</span><span class=\"sh\">\"</span><span class=\"p\">:</span> <span class=\"bp\">False</span><span class=\"p\">,</span>\n            <span class=\"sh\">\"</span><span class=\"s\">nearest_distance</span><span class=\"sh\">\"</span><span class=\"p\">:</span> <span class=\"nf\">round</span><span class=\"p\">(</span><span class=\"nf\">min</span><span class=\"p\">(</span><span class=\"n\">dists</span><span class=\"p\">),</span> <span class=\"mi\">4</span><span class=\"p\">),</span> <span class=\"sh\">\"</span><span class=\"s\">sources</span><span class=\"sh\">\"</span><span class=\"p\">:</span> <span class=\"p\">[]}</span>\n</code></pre></div></div>\n\n<p>Three questions against a corpus of Python standard-library documentation:</p>\n\n<table>\n  <thead>\n    <tr>\n      <th>question</th>\n      <th>grounded</th>\n      <th>evidence</th>\n    </tr>\n  </thead>\n  <tbody>\n    <tr>\n      <td>read and write JSON in Python</td>\n      <td>yes</td>\n      <td>cites the <code class=\"language-plaintext highlighter-rouge\">json</code> sections</td>\n    </tr>\n    <tr>\n      <td>difference between a list and a tuple</td>\n      <td>yes</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">Tuples</code> d=0.234, <code class=\"language-plaintext highlighter-rouge\">Lists</code> d=0.356</td>\n    </tr>\n    <tr>\n      <td><strong>capital city of Nepal</strong></td>\n      <td><strong>no</strong></td>\n      <td>nearest passage d=0.923, above the 0.75 threshold</td>\n    </tr>\n  </tbody>\n</table>\n\n<p>The third is the one worth building for. The response carried a <code class=\"language-plaintext highlighter-rouge\">retrieve</code> timing and <strong>no <code class=\"language-plaintext highlighter-rouge\">generate</code> timing at all</strong> — the model was never called. Nothing in the corpus was close enough, so there was nothing to be fluent about.</p>\n\n<p>That ordering matters. A retriever that hands four irrelevant passages to a model and asks it to be careful is asking the wrong component to hold the line. <strong>A refusal that costs no inference is the cheapest safety property in the system</strong>, and it is a threshold rather than a prompt.</p>\n\n<p class=\"verify\"><strong>Verify.</strong> An out-of-corpus question returns <code class=\"language-plaintext highlighter-rouge\">grounded: false</code>, a <code class=\"language-plaintext highlighter-rouge\">nearest_distance</code> above your threshold, and no generate timing.</p>\n\n<h2 id=\"step-6--the-bug-that-survived-being-fixed\">Step 6 — The bug that survived being fixed</h2>\n\n<p>This is the part I would keep if I could keep only one.</p>\n\n<p>Every section title came back with a stray <code class=\"language-plaintext highlighter-rouge\">Â</code> welded to the pilcrow — <code class=\"language-plaintext highlighter-rouge\">requests</code> had guessed the charset from headers that did not commit to one. The fix is one line: hand BeautifulSoup <code class=\"language-plaintext highlighter-rouge\">r.content</code> instead of <code class=\"language-plaintext highlighter-rouge\">r.text</code> and let it sniff the meta charset.</p>\n\n<p>That fixed the scrape. It fixed nothing downstream.</p>\n\n<p><code class=\"language-plaintext highlighter-rouge\">chunk_id</code> is a hash of <code class=\"language-plaintext highlighter-rouge\">(url, section)</code>. Changing the title changed the identity of every chunk — and every store in the chain is upsert-only:</p>\n\n<table>\n  <thead>\n    <tr>\n      <th>store</th>\n      <th>rows after the “fix”</th>\n      <th>why</th>\n    </tr>\n  </thead>\n  <tbody>\n    <tr>\n      <td>bronze — Delta, overwrite</td>\n      <td>187, correct</td>\n      <td>rewritten from raw every run</td>\n    </tr>\n    <tr>\n      <td>silver — Delta, MERGE</td>\n      <td><strong>323</strong></td>\n      <td>MERGE inserts and updates. It never deletes a row the source stopped producing.</td>\n    </tr>\n    <tr>\n      <td>gold — Delta, overwrite</td>\n      <td>323</td>\n      <td>derived from a silver that was already wrong</td>\n    </tr>\n    <tr>\n      <td>Chroma — upsert</td>\n      <td><strong>323</strong></td>\n      <td>same shape, same outcome</td>\n    </tr>\n  </tbody>\n</table>\n\n<p>Then the retriever answered a question with both copies:</p>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>src: 'Tuples'   dist 0.2338\nsrc: 'TuplesÂ'  dist 0.2350\n</code></pre></div></div>\n\n<p>Two near-identical passages competing for the same context window, from a bug that had already been fixed at the source. From the outside this reads as a model problem — the answer is repetitive, the citations are redundant. It is a pipeline problem, three layers upstream.</p>\n\n<p>The lesson is not “handle encodings”. It is that <strong>a natural key derived from scraped content is not stable</strong>, and an upsert-only layer has no way to forget. A merge that is meant to converge on its source needs a delete clause for rows the source no longer produces, or the layer needs a periodic full rebuild. I added the rebuild:</p>\n\n<div data-file=\"rag/embed.py\" class=\"language-python highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"k\">if</span> <span class=\"n\">os</span><span class=\"p\">.</span><span class=\"n\">environ</span><span class=\"p\">.</span><span class=\"nf\">get</span><span class=\"p\">(</span><span class=\"sh\">\"</span><span class=\"s\">REBUILD</span><span class=\"sh\">\"</span><span class=\"p\">,</span> <span class=\"sh\">\"\"</span><span class=\"p\">).</span><span class=\"nf\">lower</span><span class=\"p\">()</span> <span class=\"ow\">in</span> <span class=\"p\">(</span><span class=\"sh\">\"</span><span class=\"s\">1</span><span class=\"sh\">\"</span><span class=\"p\">,</span> <span class=\"sh\">\"</span><span class=\"s\">true</span><span class=\"sh\">\"</span><span class=\"p\">,</span> <span class=\"sh\">\"</span><span class=\"s\">yes</span><span class=\"sh\">\"</span><span class=\"p\">):</span>\n    <span class=\"n\">client</span><span class=\"p\">.</span><span class=\"nf\">delete_collection</span><span class=\"p\">(</span><span class=\"sh\">\"</span><span class=\"s\">sections</span><span class=\"sh\">\"</span><span class=\"p\">)</span>\n</code></pre></div></div>\n\n<h2 id=\"measured\">Measured</h2>\n\n<table>\n  <thead>\n    <tr>\n      <th>stage</th>\n      <th>figure</th>\n    </tr>\n  </thead>\n  <tbody>\n    <tr>\n      <td>scrape</td>\n      <td>187 sections, 10 pages, 883 KB</td>\n    </tr>\n    <tr>\n      <td>bronze → silver</td>\n      <td>187 → 170 (17 duplicates removed)</td>\n    </tr>\n    <tr>\n      <td>embed</td>\n      <td>170 chunks in 2.6 s — 64.8/s, 384 dimensions</td>\n    </tr>\n    <tr>\n      <td>upsert</td>\n      <td>0.16 s</td>\n    </tr>\n    <tr>\n      <td>retrieve</td>\n      <td>45–58 ms</td>\n    </tr>\n    <tr>\n      <td>generate</td>\n      <td>20–34 s, llama3.2:1b on CPU</td>\n    </tr>\n  </tbody>\n</table>\n\n<p>The generate figure is honest and unflattering: a 1B model on CPU. That is deliberate — the subject here is the pipeline around the model, and a larger model would make every run slower while changing none of the properties being tested.</p>\n\n<h2 id=\"failure-modes\">Failure modes</h2>\n\n<table>\n  <thead>\n    <tr>\n      <th>Symptom</th>\n      <th>Cause</th>\n      <th>Repair</th>\n    </tr>\n  </thead>\n  <tbody>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">bitnami/spark:3.5</code> not found</td>\n      <td>image moved to a paid catalog</td>\n      <td>use <code class=\"language-plaintext highlighter-rouge\">apache/spark</code></td>\n    </tr>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">/nonexistent/.ivy2</code> FileNotFoundException</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">spark</code> user has no home</td>\n      <td>set <code class=\"language-plaintext highlighter-rouge\">spark.jars.ivy</code></td>\n    </tr>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">NoSuchMethodError: ParserInterface.$init$</code></td>\n      <td>Delta/Spark minor mismatch</td>\n      <td>match Delta to Spark, not to latest</td>\n    </tr>\n    <tr>\n      <td>Containers stuck in <code class=\"language-plaintext highlighter-rouge\">Created</code>, no logs</td>\n      <td>volume mounted inside a <code class=\"language-plaintext highlighter-rouge\">:ro</code> mount</td>\n      <td>separate mount points</td>\n    </tr>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">FileNotFoundException</code> in a successful job</td>\n      <td>S3A glob listing</td>\n      <td>read the directory</td>\n    </tr>\n    <tr>\n      <td>Duplicate near-identical citations</td>\n      <td>upsert-only stores, changed key</td>\n      <td>full rebuild, or a delete clause</td>\n    </tr>\n  </tbody>\n</table>\n\n<h2 id=\"clean-up-and-operating-consequence\">Clean up and operating consequence</h2>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>docker compose <span class=\"nt\">--profile</span> tools down <span class=\"nt\">-v</span>\n</code></pre></div></div>\n\n<p>Two rules came out of this.</p>\n\n<p><strong>Pin everything, and know your exit from every vendor image.</strong> The stack did not break because of a bad upgrade. It broke because a company changed its distribution model and <code class=\"language-plaintext highlighter-rouge\">:latest</code> meant there was nothing to fall back to.</p>\n\n<p><strong>Ask which of your stores can forget.</strong> Every layer here could accept new data and none could drop stale data, which is fine until a key changes — and keys derived from source content change more often than anyone plans for. The question to ask of a pipeline is not “does a re-run add the right rows”, it is “does a re-run <em>remove</em> the wrong ones”. Bronze could. Nothing else could.</p>\n\n<p>Lineage is the obvious next piece: the original had Marquez wired in, and an OpenLineage graph would have shown the orphaned rows propagating across three stores in one picture rather than three separate counts.</p>\n",
      "summary": "Scrape to bronze, silver, gold on Spark 4 and Delta, embed into Chroma, and serve answers that cite their sources or decline to answer.",
      "date_published": "2026-08-16T08:00:00+05:45",
      "date_modified": "2026-08-16T00:00:00+05:45",
      "tags": ["rag","iceberg-lakehouse"],
      "authors": [
        {
          "name": "Basant Bhattarai",
          "url": "https://basantbhattarai.com.np"
        }
      ]
    },
    {
      "id": "https://basantbhattarai.com.np/writing/pgbackrest-patroni-cluster-backup-pitr/",
      "url": "https://basantbhattarai.com.np/writing/pgbackrest-patroni-cluster-backup-pitr/",
      "title": "pgBackRest against a Patroni cluster: archive, rebuild, and restore",
      "content_html": "<h2 id=\"the-thing-that-makes-this-different-from-single-node-backup\">The thing that makes this different from single-node backup</h2>\n\n<p>Backing up one PostgreSQL server is a solved problem with a well-worn runbook. Backing up a Patroni cluster is the same tool pointed at a target that moves: the primary is whichever node currently holds the leader key, and that can change between two backups without anyone touching a config file.</p>\n\n<p>So the repository is not configured against “the primary”. It is configured against <em>every member</em>, and pgBackRest works out which one is writable when it runs. That single decision is what makes the setup survive a failover, and it is visible in the very first command’s output.</p>\n\n<p>Everything here is Ansible. Part 1 of this series established the rule and it holds: Vagrant boots the box and wires the lab NIC; packages, configuration, services and certificates belong to Ansible, because a lab you fixed by hand is a lab you cannot rebuild.</p>\n\n<p>The cluster is the one from <a href=\"/writing/patroni-postgresql-18-rocky8-etcd-failover/\">part 2</a> — <code class=\"language-plaintext highlighter-rouge\">pgn1</code> and <code class=\"language-plaintext highlighter-rouge\">pgn2</code> on PostgreSQL 18.6 with etcd on <code class=\"language-plaintext highlighter-rouge\">etcd1</code>. The repository lives on <code class=\"language-plaintext highlighter-rouge\">etcd1</code> as well, which is a lab economy and not a recommendation: in production the DCS and the backup repository are separate failure domains, and the repo does not sit on a database host at all.</p>\n\n<h2 id=\"step-1--one-repo-every-member-declared\">Step 1 — One repo, every member declared</h2>\n\n<p>The Ansible play installs pgBackRest on all three nodes, then writes a repository config that lists both cluster members:</p>\n\n<div data-file=\"roles — repo host /etc/pgbackrest.conf\" class=\"language-jinja highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>[global]\nrepo1-path=\nrepo1-retention-full=2\nstart-fast=y\n\n[]\n\n</code></pre></div></div>\n\n<p>Transport is mutual TLS, the same as part 1, and it is the better answer for a cluster: no shell account on the peers, and authorisation is an explicit CN allow-list rather than “whatever the postgres unix user can reach over ssh”. Each side names the other:</p>\n\n<div data-file=\"repo host — the allow-list\" class=\"language-ini highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"py\">tls-server-address</span><span class=\"p\">=</span><span class=\"s\">*</span>\n<span class=\"py\">tls-server-port</span><span class=\"p\">=</span><span class=\"s\">8432</span>\n<span class=\"py\">tls-server-cert-file</span><span class=\"p\">=</span><span class=\"s\">/etc/pgbackrest/certs/etcd1.crt</span>\n<span class=\"py\">tls-server-key-file</span><span class=\"p\">=</span><span class=\"s\">/etc/pgbackrest/certs/etcd1.key</span>\n<span class=\"py\">tls-server-ca-file</span><span class=\"p\">=</span><span class=\"s\">/etc/pgbackrest/certs/ca.crt</span>\n<span class=\"py\">tls-server-auth</span><span class=\"p\">=</span><span class=\"s\">pgn1=pg18lab</span>\n<span class=\"py\">tls-server-auth</span><span class=\"p\">=</span><span class=\"s\">pgn2=pg18lab</span>\n</code></pre></div></div>\n\n<p>The database hosts get the mirror image, plus asynchronous archiving so <code class=\"language-plaintext highlighter-rouge\">archive_command</code> never becomes the cluster’s write ceiling. They run a TLS server too, because the repo reaches <em>back</em> to them when it takes a backup:</p>\n\n<div data-file=\"database hosts /etc/pgbackrest.conf\" class=\"language-ini highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nn\">[global]</span><span class=\"w\">\n</span><span class=\"py\">repo1-host</span><span class=\"p\">=</span><span class=\"s\">192.168.105.140</span>\n<span class=\"py\">repo1-host-type</span><span class=\"p\">=</span><span class=\"s\">tls</span>\n<span class=\"py\">repo1-host-port</span><span class=\"p\">=</span><span class=\"s\">8432</span>\n<span class=\"py\">repo1-host-ca-file</span><span class=\"p\">=</span><span class=\"s\">/etc/pgbackrest/certs/ca.crt</span>\n<span class=\"py\">repo1-host-cert-file</span><span class=\"p\">=</span><span class=\"s\">/etc/pgbackrest/certs/pgn1.crt</span>\n<span class=\"py\">repo1-host-key-file</span><span class=\"p\">=</span><span class=\"s\">/etc/pgbackrest/certs/pgn1.key</span>\n<span class=\"py\">archive-async</span><span class=\"p\">=</span><span class=\"s\">y</span>\n<span class=\"py\">spool-path</span><span class=\"p\">=</span><span class=\"s\">/var/spool/pgbackrest</span>\n<span class=\"w\">\n</span><span class=\"py\">tls-server-auth</span><span class=\"p\">=</span><span class=\"s\">etcd1=pg18lab</span>\n</code></pre></div></div>\n\n<p>A certificate signed by the CA but missing from <code class=\"language-plaintext highlighter-rouge\">tls-server-auth</code> authenticates fine and is then refused. That is the behaviour you want, and it is worth knowing before you meet the error.</p>\n\n<p>Name the stanza after the Patroni scope. pgBackRest thinks in stanzas and Patroni thinks in scopes; keeping them identical saves a future reader wondering whether they are the same thing.</p>\n\n<p><strong>Verify.</strong> The playbook is clean across all three hosts:</p>\n\n<div class=\"language-text verify highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>etcd1  : ok=9  changed=4  unreachable=0  failed=0\npgn1   : ok=7  changed=3  unreachable=0  failed=0\npgn2   : ok=7  changed=3  unreachable=0  failed=0\n</code></pre></div></div>\n\n<h2 id=\"step-1b--clocks-before-certificates\">Step 1b — Clocks, before certificates</h2>\n\n<p>This cost the most time of anything in the build, and the error points nowhere near the cause:</p>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>WARN: unable to check pg1: [ServiceError] TLS error [1:336151570] sslv3 alert bad certificate\nERROR: [027]: no database found\n</code></pre></div></div>\n\n<p>The certificates were fine. The CA fingerprint matched on every host. What did not match was the time:</p>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>control node (Mac):  2026-08-14 16:24:33 UTC\n  etcd1              2026-08-14 16:24:34 UTC\n  pgn1               2026-08-14 15:07:59 UTC\n  pgn2               2026-08-14 15:08:00 UTC\nCA notBefore:        Aug 14 16:16:14 2026 GMT\n</code></pre></div></div>\n\n<p>Both database nodes were <strong>76 minutes behind</strong>. The CA was issued on the Mac at 16:16, so from their point of view it was not yet valid, and <code class=\"language-plaintext highlighter-rouge\">openssl verify</code> says so plainly once you ask it directly:</p>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>error 9 at 1 depth lookup: certificate is not yet valid\n</code></pre></div></div>\n\n<p>TCG guests drift badly, and chronyd <em>slews</em> rather than steps, so it corrects a large offset far too slowly to help. Step the clock, and do it before anything issues a certificate:</p>\n\n<div data-file=\"playbooks/05_pgbackrest_tls.yml\" class=\"language-yaml highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"pi\">-</span> <span class=\"na\">name</span><span class=\"pi\">:</span> <span class=\"s\">Force an immediate step</span>\n  <span class=\"na\">ansible.builtin.command</span><span class=\"pi\">:</span> <span class=\"s\">chronyc -a makestep</span>\n\n<span class=\"pi\">-</span> <span class=\"na\">name</span><span class=\"pi\">:</span> <span class=\"s\">Fail if still skewed from the control node</span>\n  <span class=\"na\">ansible.builtin.assert</span><span class=\"pi\">:</span>\n    <span class=\"na\">that</span><span class=\"pi\">:</span> <span class=\"s2\">\"</span><span class=\"s\">(guest_epoch.stdout</span><span class=\"nv\"> </span><span class=\"s\">|</span><span class=\"nv\"> </span><span class=\"s\">int</span><span class=\"nv\"> </span><span class=\"s\">-</span><span class=\"nv\"> </span><span class=\"s\">control_epoch</span><span class=\"nv\"> </span><span class=\"s\">|</span><span class=\"nv\"> </span><span class=\"s\">int)</span><span class=\"nv\"> </span><span class=\"s\">|</span><span class=\"nv\"> </span><span class=\"s\">abs</span><span class=\"nv\"> </span><span class=\"s\">&lt;</span><span class=\"nv\"> </span><span class=\"s\">120\"</span>\n    <span class=\"na\">fail_msg</span><span class=\"pi\">:</span> <span class=\"s2\">\"</span><span class=\"nv\"> </span><span class=\"s\">is</span><span class=\"nv\"> </span><span class=\"s\">too</span><span class=\"nv\"> </span><span class=\"s\">far</span><span class=\"nv\"> </span><span class=\"s\">from</span><span class=\"nv\"> </span><span class=\"s\">the</span><span class=\"nv\"> </span><span class=\"s\">control</span><span class=\"nv\"> </span><span class=\"s\">node;</span><span class=\"nv\"> </span><span class=\"s\">certificates</span><span class=\"nv\"> </span><span class=\"s\">will</span><span class=\"nv\"> </span><span class=\"s\">not</span><span class=\"nv\"> </span><span class=\"s\">validate.\"</span>\n</code></pre></div></div>\n\n<p>The assertion matters more than the <code class=\"language-plaintext highlighter-rouge\">makestep</code>. A silent 76-minute skew turns into a TLS error an hour later; a failed assertion names the problem at the point it exists.</p>\n\n<p><strong>Verify.</strong> With clocks stepped, the handshake works and <code class=\"language-plaintext highlighter-rouge\">check</code> proves the whole path:</p>\n\n<div class=\"language-text verify highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>INFO: check repo1 (standby)\nINFO: switch wal not performed because this is a standby\nINFO: check repo1 configuration (primary)\nINFO: WAL segment 00000004000000000000000F successfully archived ... on repo1\nINFO: check command end: completed successfully (2560ms)\n</code></pre></div></div>\n\n<h2 id=\"step-2--two-settings-two-different-homes\">Step 2 — Two settings, two different homes</h2>\n\n<p>This is where most integrations quietly break, and the split is not arbitrary.</p>\n\n<p><code class=\"language-plaintext highlighter-rouge\">archive_mode</code> and <code class=\"language-plaintext highlighter-rouge\">archive_command</code> describe the whole cluster, so they belong in the <strong>DCS</strong>. Patroni regenerates <code class=\"language-plaintext highlighter-rouge\">postgresql.conf</code> from the DCS every time it starts a node — so an <code class=\"language-plaintext highlighter-rouge\">archive_command</code> typed straight into that file works perfectly until the first failover, then vanishes at exactly the moment you need it.</p>\n\n<div data-file=\"playbooks/04_patroni_archive.yml\" class=\"language-yaml highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"pi\">-</span> <span class=\"na\">name</span><span class=\"pi\">:</span> <span class=\"s\">Push archive settings into the DCS</span>\n  <span class=\"na\">ansible.builtin.shell</span><span class=\"pi\">:</span> <span class=\"pi\">|</span>\n    <span class=\"s\">cat &lt;&lt;'YML' | patronictl -c  edit-config --force --apply -</span>\n    <span class=\"s\">postgresql:</span>\n      <span class=\"s\">parameters:</span>\n        <span class=\"s\">archive_mode: \"on\"</span>\n        <span class=\"s\">archive_command: \"pgbackrest --stanza= archive-push %p\"</span>\n        <span class=\"s\">archive_timeout: 60</span>\n    <span class=\"s\">YML</span>\n  <span class=\"na\">become_user</span><span class=\"pi\">:</span> <span class=\"s\">postgres</span>\n  <span class=\"na\">run_once</span><span class=\"pi\">:</span> <span class=\"kc\">true</span>\n</code></pre></div></div>\n\n<p><code class=\"language-plaintext highlighter-rouge\">create_replica_methods</code> is the opposite. It names a <em>command on that host</em>, so it cannot live in shared cluster state and goes into the local <code class=\"language-plaintext highlighter-rouge\">patroni.yml</code>:</p>\n\n<div data-file=\"/etc/patroni/patroni.yml\" class=\"language-yaml highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"na\">create_replica_methods</span><span class=\"pi\">:</span>\n  <span class=\"pi\">-</span> <span class=\"s\">pgbackrest</span>\n  <span class=\"pi\">-</span> <span class=\"s\">basebackup</span>\n<span class=\"na\">pgbackrest</span><span class=\"pi\">:</span>\n  <span class=\"na\">command</span><span class=\"pi\">:</span> <span class=\"s2\">\"</span><span class=\"s\">/usr/bin/pgbackrest</span><span class=\"nv\"> </span><span class=\"s\">--stanza=pg18lab</span><span class=\"nv\"> </span><span class=\"s\">--delta</span><span class=\"nv\"> </span><span class=\"s\">restore\"</span>\n  <span class=\"na\">keep_data</span><span class=\"pi\">:</span> <span class=\"kc\">true</span>\n  <span class=\"na\">no_params</span><span class=\"pi\">:</span> <span class=\"kc\">true</span>\n</code></pre></div></div>\n\n<p>Order matters. With <code class=\"language-plaintext highlighter-rouge\">pgbackrest</code> first, a new replica clones from the repository; <code class=\"language-plaintext highlighter-rouge\">basebackup</code> stays as the fallback. Without it, every rebuild drags a full copy off the primary while that primary is serving traffic.</p>\n\n<h2 id=\"step-3--the-restart-nobody-mentions\">Step 3 — The restart nobody mentions</h2>\n\n<p>Apply the config and check whether PostgreSQL actually took it:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>psql <span class=\"nt\">-tAc</span> <span class=\"s2\">\"SELECT name||' = '||setting FROM pg_settings\n           WHERE name IN ('archive_mode','archive_command','archive_timeout');\"</span>\n</code></pre></div></div>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>archive_command = (disabled)\narchive_mode = off\narchive_timeout = 60\n</code></pre></div></div>\n\n<p>The DCS has the right values and the server does not. <code class=\"language-plaintext highlighter-rouge\">archive_timeout</code> is reloadable so it applied; <code class=\"language-plaintext highlighter-rouge\">archive_mode</code> is a postmaster-level parameter and needs a full restart. Patroni knows, and says so:</p>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>| Member | Role    | State     | TL | Pending restart | Pending restart reason |\n| pgn1   | Replica | streaming |  3 | *               | archive_mode: off-&gt;on  |\n| pgn2   | Leader  | running   |  3 | *               | archive_mode: off-&gt;on  |\n</code></pre></div></div>\n\n<p>A rolling restart through <code class=\"language-plaintext highlighter-rouge\">patronictl</code> handles the replica first and the leader last, so the write outage is one brief switchover rather than the whole cluster:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>patronictl <span class=\"nt\">-c</span> /etc/patroni/patroni.yml restart pg18lab <span class=\"nt\">--force</span>\n</code></pre></div></div>\n\n<p><strong>Verify.</strong> The flag clears and archiving is live:</p>\n\n<div class=\"language-text verify highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>archive_command = pgbackrest --stanza=pg18lab archive-push %p\narchive_mode = on\n</code></pre></div></div>\n\n<h2 id=\"step-4--stanza-check-and-the-first-backup\">Step 4 — Stanza, check, and the first backup</h2>\n\n<p>Run these from the repository host. <code class=\"language-plaintext highlighter-rouge\">stanza-create</code> initialises the repo layout; <code class=\"language-plaintext highlighter-rouge\">check</code> proves a WAL segment can actually make the trip.</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nb\">sudo</span> <span class=\"nt\">-u</span> postgres pgbackrest <span class=\"nt\">--stanza</span><span class=\"o\">=</span>pg18lab stanza-create\n<span class=\"nb\">sudo</span> <span class=\"nt\">-u</span> postgres pgbackrest <span class=\"nt\">--stanza</span><span class=\"o\">=</span>pg18lab check\n</code></pre></div></div>\n\n<p><strong>Verify.</strong> Read the <code class=\"language-plaintext highlighter-rouge\">check</code> output closely, because it contains the proof that the multi-host config works:</p>\n\n<div class=\"language-text verify highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>INFO: switch wal not performed because this is a standby\nINFO: check repo1 configuration (primary)\nINFO: WAL segment 000000030000000000000004 successfully archived to\n      '/var/lib/pgbackrest/archive/pg18lab/18-1/...' on repo1\nINFO: check command end: completed successfully (6795ms)\n</code></pre></div></div>\n\n<p>pgBackRest probed both declared hosts, found one in recovery, skipped it, and did the WAL switch on the primary. Nothing told it which node was the leader.</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nb\">sudo</span> <span class=\"nt\">-u</span> postgres pgbackrest <span class=\"nt\">--stanza</span><span class=\"o\">=</span>pg18lab <span class=\"nt\">--type</span><span class=\"o\">=</span>full backup\n</code></pre></div></div>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>INFO: backup start archive = 000000030000000000000006, lsn = 0/6000060\nINFO: new backup label = 20260814-141156F\nINFO: full backup size = 22.7MB, file total = 981\nINFO: backup command end: completed successfully (23910ms)\n</code></pre></div></div>\n\n<p>The repo stored 2.9 MB for a 22.7 MB database — compression is on by default and worth knowing before you size the volume.</p>\n\n<h2 id=\"step-5--rebuild-a-replica-from-the-repository\">Step 5 — Rebuild a replica from the repository</h2>\n\n<p>The first restore drill is the cheap one, and it is the one you will actually use: throw a replica away and let Patroni rebuild it.</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>patronictl <span class=\"nt\">-c</span> /etc/patroni/patroni.yml reinit pg18lab pgn1 <span class=\"nt\">--force</span>\n</code></pre></div></div>\n\n<p><strong>Verify.</strong> The Patroni journal on the rebuilt node names the method, which is the only way to know it did not silently fall back to <code class=\"language-plaintext highlighter-rouge\">pg_basebackup</code>:</p>\n\n<div class=\"language-text verify highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>INFO: restore command begin 2.59.0: --delta --repo1-host=192.168.105.140 ... --stanza=pg18lab\nINFO: restore command end: completed successfully (5020ms)\nINFO: replica has been created using pgbackrest\n</code></pre></div></div>\n\n<p>Five seconds, and the primary was never touched. On a database where a full copy would take hours across a busy link, that difference is the entire reason to wire the two tools together.</p>\n\n<h2 id=\"step-6--point-in-time-restore-of-a-dropped-table\">Step 6 — Point-in-time restore of a dropped table</h2>\n\n<p>Now the drill people actually rehearse for. Note a timestamp, then break something:</p>\n\n<div class=\"language-sql highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"k\">SELECT</span> <span class=\"n\">now</span><span class=\"p\">();</span>                     <span class=\"c1\">-- 2026-08-14 14:15:20.119249+00</span>\n<span class=\"k\">DROP</span> <span class=\"k\">TABLE</span> <span class=\"n\">failover_probe</span><span class=\"p\">;</span>        <span class=\"c1\">-- the accident</span>\n<span class=\"k\">SELECT</span> <span class=\"n\">pg_switch_wal</span><span class=\"p\">();</span>           <span class=\"c1\">-- make sure the damage is archived</span>\n</code></pre></div></div>\n\n<p>Patroni must be told to stand down first, or it will fight the restore by restarting PostgreSQL underneath you:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>patronictl <span class=\"nt\">-c</span> /etc/patroni/patroni.yml pause <span class=\"nt\">--wait</span>\n</code></pre></div></div>\n\n<p>Then stop PostgreSQL — and this is the step that catches people. Pausing Patroni and stopping the <em>service</em> does <strong>not</strong> stop the database. That is what pause means: Patroni stops managing, PostgreSQL keeps serving.</p>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>ERROR: [038]: unable to restore while PostgreSQL is running\n</code></pre></div></div>\n\n<p>Stop it properly on both nodes, then restore the leader to the moment before the drop:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nb\">sudo</span> <span class=\"nt\">-u</span> postgres /usr/pgsql-18/bin/pg_ctl <span class=\"nt\">-D</span> /var/lib/pgsql/18/data <span class=\"nt\">-m</span> fast stop\n\n<span class=\"nb\">sudo</span> <span class=\"nt\">-u</span> postgres pgbackrest <span class=\"nt\">--stanza</span><span class=\"o\">=</span>pg18lab <span class=\"nt\">--delta</span> <span class=\"nt\">--type</span><span class=\"o\">=</span><span class=\"nb\">time</span> <span class=\"se\">\\</span>\n  <span class=\"nt\">--target</span><span class=\"o\">=</span><span class=\"s1\">'2026-08-14 14:15:20'</span> <span class=\"nt\">--target-action</span><span class=\"o\">=</span>promote restore\n</code></pre></div></div>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>INFO: repo1: restore backup set 20260814-141156F, recovery will start at 2026-08-14 14:11:56\nINFO: restore command end: completed successfully (4198ms)\n</code></pre></div></div>\n\n<p>Start the server and let it replay forward to the target:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nb\">sudo</span> <span class=\"nt\">-u</span> postgres /usr/pgsql-18/bin/pg_ctl <span class=\"nt\">-D</span> /var/lib/pgsql/18/data <span class=\"nt\">-w</span> <span class=\"nt\">-t</span> 300 start\n</code></pre></div></div>\n\n<p><strong>Verify.</strong> The table is back, on a new timeline, and out of recovery:</p>\n\n<div class=\"language-text verify highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>in recovery: f\ntimeline:    4\ntable back:  t\nrow count:   5\n\n id |              note\n----+---------------------------------\n  1 | written on pgn1 before kill\n 34 | written on pgn2 after promotion\n 35 | after etcd recovery\n 36 | before replica reinit\n 37 | the row we must not lose\n</code></pre></div></div>\n\n<h2 id=\"step-7--the-gotcha-after-a-pitr\">Step 7 — The gotcha after a PITR</h2>\n\n<p>Resume Patroni, start the other node, and rebuild it — and it hangs in <code class=\"language-plaintext highlighter-rouge\">starting</code> forever:</p>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>LOG:  fetching timeline history file for timeline 3 from primary server\nFATAL: could not receive timeline history file from the primary server:\n       ERROR: could not open file \"pg_wal/00000003.history\": No such file or directory\n</code></pre></div></div>\n\n<p>The replica restored from a backup whose base is on <strong>timeline 3</strong> and is asking the leader for that timeline’s history. After the point-in-time restore the leader is on timeline 4 and never had the file. <code class=\"language-plaintext highlighter-rouge\">archive-get</code> cannot supply it either, because archiving was switched on <em>after</em> the cluster had already reached timeline 3 — so that history file was never archived at all.</p>\n\n<p>The fix is not a flag. It is an ordering rule: <strong>after a point-in-time restore, take a fresh full backup before rebuilding any replica.</strong> The new backup’s base is on the current timeline, so nothing has to reach backwards.</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nb\">sudo</span> <span class=\"nt\">-u</span> postgres pgbackrest <span class=\"nt\">--stanza</span><span class=\"o\">=</span>pg18lab <span class=\"nt\">--type</span><span class=\"o\">=</span>full backup\npatronictl <span class=\"nt\">-c</span> /etc/patroni/patroni.yml reinit pg18lab pgn1 <span class=\"nt\">--force</span>\n</code></pre></div></div>\n\n<p><strong>Verify.</strong> Both members on timeline 4, streaming, and both holding the recovered rows:</p>\n\n<div class=\"language-text verify highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>| Member | Host            | Role    | State     | TL | Replay LSN | Lag |\n| pgn1   | 192.168.105.141 | Replica | streaming |  4 | 0/E000060  |   0 |\n| pgn2   | 192.168.105.142 | Leader  | running   |  4 |            |     |\n\n  .141: 5 rows\n  .142: 5 rows\n</code></pre></div></div>\n\n<h2 id=\"failure-modes-worth-knowing\">Failure modes worth knowing</h2>\n\n<table>\n  <thead>\n    <tr>\n      <th>Symptom</th>\n      <th>Cause</th>\n      <th>Repair</th>\n    </tr>\n  </thead>\n  <tbody>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">archive_command</code> disabled after edit-config</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">archive_mode</code> is postmaster-level</td>\n      <td>Rolling <code class=\"language-plaintext highlighter-rouge\">patronictl restart</code>; watch the Pending restart column</td>\n    </tr>\n    <tr>\n      <td>archive settings vanish after a failover</td>\n      <td>Written into <code class=\"language-plaintext highlighter-rouge\">postgresql.conf</code> by hand</td>\n      <td>Put them in the DCS with <code class=\"language-plaintext highlighter-rouge\">edit-config</code></td>\n    </tr>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">unable to restore while PostgreSQL is running</code></td>\n      <td>pause and stop-service do not stop the database</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">pg_ctl -m fast stop</code> on every node first</td>\n    </tr>\n    <tr>\n      <td>Replica stuck <code class=\"language-plaintext highlighter-rouge\">starting</code>, wants <code class=\"language-plaintext highlighter-rouge\">0000000N.history</code></td>\n      <td>Backup base predates the current timeline</td>\n      <td>Fresh full backup after the PITR, then reinit</td>\n    </tr>\n    <tr>\n      <td>Replica rebuild is slow and loads the primary</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">create_replica_methods</code> missing or wrong order</td>\n      <td>List <code class=\"language-plaintext highlighter-rouge\">pgbackrest</code> before <code class=\"language-plaintext highlighter-rouge\">basebackup</code> in the local yml</td>\n    </tr>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">check</code> reports the wrong host as primary</td>\n      <td>Only one <code class=\"language-plaintext highlighter-rouge\">pgN-host</code> declared</td>\n      <td>Declare every member; the primary moves</td>\n    </tr>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">sslv3 alert bad certificate</code>, certs look correct</td>\n      <td>Guest clock behind the CA’s notBefore</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">chronyc -a makestep</code>, then assert the offset</td>\n    </tr>\n    <tr>\n      <td>Peer authenticates then is refused</td>\n      <td>CN missing from <code class=\"language-plaintext highlighter-rouge\">tls-server-auth</code></td>\n      <td>Add <code class=\"language-plaintext highlighter-rouge\">tls-server-auth=&lt;CN&gt;=&lt;stanza&gt;</code> on the receiving side</td>\n    </tr>\n  </tbody>\n</table>\n\n<h2 id=\"what-this-buys-you\">What this buys you</h2>\n\n<p>The cluster now survives three separate things, and they are worth naming separately because they fail separately. A node dying is handled by Patroni, in about twenty seconds, from part 2. A replica needing to come back is handled by pgBackRest in five seconds without touching the primary. And an operator error — the dropped table — is handled by a point-in-time restore that recovered every row.</p>\n\n<p>Only the first of those is automatic. The other two are drills, and a drill you have not run is a plan, not a capability. The useful outcome of this post is not the config; it is that the restore was performed once, on purpose, while nothing was actually on fire.</p>\n",
      "summary": "Wire pgBackRest into a running Patroni cluster over mutual TLS with Ansible, then prove it: full backup, replica rebuilt from the repo, and a point-in-time restore.",
      "date_published": "2026-08-14T16:30:00+05:45",
      "date_modified": "2026-08-14T00:00:00+05:45",
      "tags": ["postgres","distributed-databases"],
      "authors": [
        {
          "name": "Basant Bhattarai",
          "url": "https://basantbhattarai.com.np"
        }
      ]
    },
    {
      "id": "https://basantbhattarai.com.np/writing/patroni-postgresql-18-rocky8-etcd-failover/",
      "url": "https://basantbhattarai.com.np/writing/patroni-postgresql-18-rocky8-etcd-failover/",
      "title": "Patroni HA on PostgreSQL 18 with etcd, proven by killing the primary",
      "content_html": "<h2 id=\"what-this-proves-and-what-it-does-not\">What this proves, and what it does not</h2>\n\n<p>Most Patroni walkthroughs stop at <code class=\"language-plaintext highlighter-rouge\">patronictl list</code> showing a Leader and a Replica. That is the setup, not the result. The question worth answering is what happens when the primary dies badly — not a graceful <code class=\"language-plaintext highlighter-rouge\">systemctl stop</code>, but a <code class=\"language-plaintext highlighter-rouge\">SIGKILL</code> that leaves the cluster to work it out — and then whether the dead node can come back without a rebuild.</p>\n\n<p>This post builds that on three amd64 guests and measures it. The numbers below came off the run, not off a docs page: promotion observed in a 10–20 second window — <code class=\"language-plaintext highlighter-rouge\">patronictl</code> was polled every 10 s, so that interval is the instrument’s resolution and not the precision of the result — the timeline advancing 1 → 2, and the old primary rejoining as a streaming replica 15 seconds after it was restarted, via <code class=\"language-plaintext highlighter-rouge\">pg_rewind</code> from the last common checkpoint.</p>\n\n<p>It is also honest about the shape of the lab. etcd runs on one node, which is a single point of failure — so rather than wave that away, the last section stops etcd and measures what a DCS outage actually costs. The answer is worth knowing before you design one: the healthy leader demotes itself and the cluster goes read-only.</p>\n\n<p>The guests are amd64 under QEMU TCG on an Apple Silicon Mac, on the shared <code class=\"language-plaintext highlighter-rouge\">192.168.105.0/24</code> fabric from the <a href=\"/writing/amd64-vagrant-labs-apple-silicon-socket-vmnet/\">networking tutorial</a>. Slow on purpose: a lab that lies about the instruction set makes every later packaging and timing story suspect.</p>\n\n<h2 id=\"step-1--why-rocky-8-and-not-genericrhel8\">Step 1 — Why Rocky 8 and not generic/rhel8</h2>\n\n<p>I started this on <code class=\"language-plaintext highlighter-rouge\">generic/rhel8</code> and it does not work, for a reason worth stating plainly because it is invisible until the last command fails.</p>\n\n<p>An unregistered RHEL 8 box has no BaseOS and no AppStream. Only EPEL and PGDG are enabled:</p>\n\n<div data-file=\"on generic/rhel8\" class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nb\">sudo </span>subscription-manager status\n<span class=\"nb\">sudo </span>dnf <span class=\"nt\">-q</span> repolist\n</code></pre></div></div>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>Overall Status: Unknown\nepel pgdg-common pgdg14 pgdg15 pgdg16 pgdg17 pgdg18\n</code></pre></div></div>\n\n<p>PGDG packages Patroni 4.1.5 as <code class=\"language-plaintext highlighter-rouge\">patroni-etcd</code>, and that package needs a Python 3.12 interpreter:</p>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>nothing provides python(abi) = 3.12 needed by python3.12-etcd-0.4.5-49PGDG.rhel8.noarch\n</code></pre></div></div>\n\n<p><code class=\"language-plaintext highlighter-rouge\">python3.12</code> lives in AppStream. No subscription, no AppStream, no Patroni. Registering a RHEL box fixes it and is the right answer in an enterprise where entitlements exist. For a lab anyone should be able to reproduce, a RHEL-compatible rebuild is better: Rocky Linux 8 is binary-compatible, needs no account, and its <code class=\"language-plaintext highlighter-rouge\">$releasever</code> of <code class=\"language-plaintext highlighter-rouge\">8</code> serves current 8.10 content.</p>\n\n<p><strong>Verify.</strong> On a Rocky 8 guest the interpreter is there:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nb\">sudo </span>dnf <span class=\"nt\">-q</span> list available python3.12\n</code></pre></div></div>\n\n<div class=\"language-text verify highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>python3.12.x86_64      3.12.13-3.el8_10      appstream\n</code></pre></div></div>\n\n<p>Every command from here works unchanged on a <em>registered</em> RHEL 8. It is only the unregistered box that cannot get there.</p>\n\n<h2 id=\"step-2--three-guests-on-the-lab-fabric\">Step 2 — Three guests on the lab fabric</h2>\n\n<p>The Vagrantfile boots three nodes and does nothing else. Packages, configuration and services come later; Vagrant’s job is a booted box with a routable lab NIC.</p>\n\n<div data-file=\"Vagrantfile\" class=\"language-ruby highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"no\">LAB_PREFIX</span> <span class=\"o\">=</span> <span class=\"s2\">\"192.168.105\"</span>\n\n<span class=\"no\">NODES</span> <span class=\"o\">=</span> <span class=\"p\">[</span>\n  <span class=\"p\">{</span> <span class=\"ss\">name: </span><span class=\"s2\">\"etcd1\"</span><span class=\"p\">,</span> <span class=\"ss\">ip: </span><span class=\"s2\">\"</span><span class=\"si\">#{</span><span class=\"no\">LAB_PREFIX</span><span class=\"si\">}</span><span class=\"s2\">.140\"</span><span class=\"p\">,</span> <span class=\"ss\">ssh_port: </span><span class=\"mi\">22140</span><span class=\"p\">,</span> <span class=\"ss\">memory: </span><span class=\"s2\">\"1024\"</span><span class=\"p\">,</span> <span class=\"ss\">smp: </span><span class=\"s2\">\"1\"</span><span class=\"p\">,</span> <span class=\"ss\">mac: </span><span class=\"s2\">\"52:54:00:12:01:40\"</span> <span class=\"p\">},</span>\n  <span class=\"p\">{</span> <span class=\"ss\">name: </span><span class=\"s2\">\"pgn1\"</span><span class=\"p\">,</span>  <span class=\"ss\">ip: </span><span class=\"s2\">\"</span><span class=\"si\">#{</span><span class=\"no\">LAB_PREFIX</span><span class=\"si\">}</span><span class=\"s2\">.141\"</span><span class=\"p\">,</span> <span class=\"ss\">ssh_port: </span><span class=\"mi\">22141</span><span class=\"p\">,</span> <span class=\"ss\">memory: </span><span class=\"s2\">\"1536\"</span><span class=\"p\">,</span> <span class=\"ss\">smp: </span><span class=\"s2\">\"2\"</span><span class=\"p\">,</span> <span class=\"ss\">mac: </span><span class=\"s2\">\"52:54:00:12:01:41\"</span> <span class=\"p\">},</span>\n  <span class=\"p\">{</span> <span class=\"ss\">name: </span><span class=\"s2\">\"pgn2\"</span><span class=\"p\">,</span>  <span class=\"ss\">ip: </span><span class=\"s2\">\"</span><span class=\"si\">#{</span><span class=\"no\">LAB_PREFIX</span><span class=\"si\">}</span><span class=\"s2\">.142\"</span><span class=\"p\">,</span> <span class=\"ss\">ssh_port: </span><span class=\"mi\">22142</span><span class=\"p\">,</span> <span class=\"ss\">memory: </span><span class=\"s2\">\"1536\"</span><span class=\"p\">,</span> <span class=\"ss\">smp: </span><span class=\"s2\">\"2\"</span><span class=\"p\">,</span> <span class=\"ss\">mac: </span><span class=\"s2\">\"52:54:00:12:01:42\"</span> <span class=\"p\">},</span>\n<span class=\"p\">].</span><span class=\"nf\">freeze</span>\n\n<span class=\"no\">Vagrant</span><span class=\"p\">.</span><span class=\"nf\">configure</span><span class=\"p\">(</span><span class=\"s2\">\"2\"</span><span class=\"p\">)</span> <span class=\"k\">do</span> <span class=\"o\">|</span><span class=\"n\">config</span><span class=\"o\">|</span>\n  <span class=\"n\">config</span><span class=\"p\">.</span><span class=\"nf\">vm</span><span class=\"p\">.</span><span class=\"nf\">box</span> <span class=\"o\">=</span> <span class=\"s2\">\"generic/rocky8\"</span>\n  <span class=\"n\">config</span><span class=\"p\">.</span><span class=\"nf\">vm</span><span class=\"p\">.</span><span class=\"nf\">synced_folder</span> <span class=\"s2\">\".\"</span><span class=\"p\">,</span> <span class=\"s2\">\"/vagrant\"</span><span class=\"p\">,</span> <span class=\"ss\">disabled: </span><span class=\"kp\">true</span>\n  <span class=\"n\">config</span><span class=\"p\">.</span><span class=\"nf\">vm</span><span class=\"p\">.</span><span class=\"nf\">box_check_update</span> <span class=\"o\">=</span> <span class=\"kp\">false</span>\n  <span class=\"c1\"># TCG boots a RHEL-family guest slowly. The 300s default expires mid-cloud-init</span>\n  <span class=\"c1\"># and Vagrant reports a timeout for a VM that is still coming up.</span>\n  <span class=\"n\">config</span><span class=\"p\">.</span><span class=\"nf\">vm</span><span class=\"p\">.</span><span class=\"nf\">boot_timeout</span> <span class=\"o\">=</span> <span class=\"mi\">900</span>\n  <span class=\"c1\"># ... provider block: q35, cpu max, the socket_vmnet wrapper, and</span>\n  <span class=\"c1\">#     -netdev socket,id=lab0,fd=3 as in the networking post</span>\n<span class=\"k\">end</span>\n</code></pre></div></div>\n\n<p>Two settings are load-bearing and both were learned the hard way. <code class=\"language-plaintext highlighter-rouge\">boot_timeout = 900</code> because TCG is slow enough that the default makes Vagrant declare failure on a healthy boot. And <code class=\"language-plaintext highlighter-rouge\">no_daemonize = true</code> in the provider, because forking drops the inherited lab file descriptor and the lab NIC transmits into nothing.</p>\n\n<p><strong>Verify.</strong> Every node answers on the fabric, and every node can reach every other:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"k\">for </span>n <span class=\"k\">in </span>etcd1 pgn1 pgn2<span class=\"p\">;</span> <span class=\"k\">do\n  </span>vagrant ssh <span class=\"nv\">$n</span> <span class=\"nt\">-c</span> <span class=\"s2\">\"hostname -s; ip -4 -o addr show eth1 | awk '{print </span><span class=\"se\">\\$</span><span class=\"s2\">4}'\"</span>\n<span class=\"k\">done</span>\n</code></pre></div></div>\n\n<div class=\"language-text verify highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>etcd1  192.168.105.140/24  mesh: .140 ok .141 ok .142 ok\npgn1   192.168.105.141/24  mesh: .140 ok .141 ok .142 ok\npgn2   192.168.105.142/24  mesh: .140 ok .141 ok .142 ok\n</code></pre></div></div>\n\n<p>Guest ICMP to the vmnet gateway at <code class=\"language-plaintext highlighter-rouge\">.1</code> fails on macOS even when everything else is healthy. Host-to-guest and guest-to-guest are the pass criteria; do not chase the gateway ping.</p>\n\n<h2 id=\"step-3--etcd-and-the-gpg-error-that-is-not-a-gpg-error\">Step 3 — etcd, and the GPG error that is not a GPG error</h2>\n\n<p>etcd left the RHEL AppStream after RHEL 7. PGDG ships it in a repository that is present but disabled by default, and enabling it produces a message that sends people straight to a tarball:</p>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>Error: Failed to download metadata for repo 'pgdg-rhel8-extras':\nrepomd.xml GPG signature verification error: Bad GPG signature\n</code></pre></div></div>\n\n<p>The signature is fine. Verifying it by hand against the key the repo already shipped proves it:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>gpg <span class=\"nt\">--verify</span> repomd.xml.asc repomd.xml\n</code></pre></div></div>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>gpg: Signature made Thu 13 Aug 2026 07:34:54 AM UTC\ngpg:                using RSA key D4BF08AE67A0B4C7A1DBCCD240BCA2B408B40D20\ngpg: Good signature from \"PostgreSQL RPM Repository &lt;pgsql-pkg-yum@lists.postgresql.org&gt;\"\n</code></pre></div></div>\n\n<p>What actually happens is that dnf wants to <em>import</em> that key, asks <code class=\"language-plaintext highlighter-rouge\">Is this ok [y/N]:</code>, gets no answer in a non-interactive shell, and reports the refusal as a bad signature. The fix is <code class=\"language-plaintext highlighter-rouge\">-y</code>:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nb\">sudo </span>dnf <span class=\"nt\">-y</span> <span class=\"nt\">--enablerepo</span><span class=\"o\">=</span>pgdg-rhel8-extras <span class=\"nb\">install </span>etcd\n</code></pre></div></div>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>Installed:\n  etcd-3.7.0-1PGDG.rhel8.10.x86_64\n</code></pre></div></div>\n\n<p>Point it at the lab address, not localhost, or Patroni on the other two nodes cannot reach it:</p>\n\n<div data-file=\"/etc/etcd/etcd.conf.yaml\" class=\"language-yaml highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"na\">name</span><span class=\"pi\">:</span> <span class=\"s\">etcd1</span>\n<span class=\"na\">data-dir</span><span class=\"pi\">:</span> <span class=\"s\">/var/lib/etcd</span>\n<span class=\"na\">listen-client-urls</span><span class=\"pi\">:</span> <span class=\"s\">http://192.168.105.140:2379,http://127.0.0.1:2379</span>\n<span class=\"na\">advertise-client-urls</span><span class=\"pi\">:</span> <span class=\"s\">http://192.168.105.140:2379</span>\n<span class=\"na\">listen-peer-urls</span><span class=\"pi\">:</span> <span class=\"s\">http://192.168.105.140:2380</span>\n<span class=\"na\">initial-advertise-peer-urls</span><span class=\"pi\">:</span> <span class=\"s\">http://192.168.105.140:2380</span>\n<span class=\"na\">initial-cluster</span><span class=\"pi\">:</span> <span class=\"s\">etcd1=http://192.168.105.140:2380</span>\n<span class=\"na\">initial-cluster-state</span><span class=\"pi\">:</span> <span class=\"s\">new</span>\n<span class=\"na\">initial-cluster-token</span><span class=\"pi\">:</span> <span class=\"s\">pg18-patroni-lab</span>\n</code></pre></div></div>\n\n<p>The PGDG unit reads a different path, so override it rather than editing the shipped file:</p>\n\n<div data-file=\"/etc/systemd/system/etcd.service.d/override.conf\" class=\"language-ini highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nn\">[Service]</span><span class=\"w\">\n</span><span class=\"py\">ExecStart</span><span class=\"p\">=</span>\n<span class=\"py\">ExecStart</span><span class=\"p\">=</span><span class=\"s\">/usr/bin/etcd --config-file=/etc/etcd/etcd.conf.yaml</span>\n</code></pre></div></div>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nb\">sudo </span>systemctl daemon-reload\n<span class=\"nb\">sudo </span>systemctl <span class=\"nb\">enable</span> <span class=\"nt\">--now</span> etcd\n<span class=\"nb\">sudo </span>firewall-cmd <span class=\"nt\">--permanent</span> <span class=\"nt\">--add-port</span><span class=\"o\">=</span>2379/tcp <span class=\"nt\">--add-port</span><span class=\"o\">=</span>2380/tcp <span class=\"o\">&amp;&amp;</span> <span class=\"nb\">sudo </span>firewall-cmd <span class=\"nt\">--reload</span>\n</code></pre></div></div>\n\n<p><strong>Verify.</strong> The endpoint is healthy from the fabric address, not just from localhost:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>etcdctl <span class=\"nt\">--endpoints</span><span class=\"o\">=</span>http://192.168.105.140:2379 endpoint health\n</code></pre></div></div>\n\n<div class=\"language-text verify highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>http://192.168.105.140:2379 is healthy: successfully committed proposal: took = 472.211084ms\n</code></pre></div></div>\n\n<p>One deprecation worth noting: <code class=\"language-plaintext highlighter-rouge\">ETCDCTL_API=3</code> is now an unrecognised environment variable and etcd 3.7 warns about it. Every older Patroni guide still sets it. Drop it.</p>\n\n<h2 id=\"step-4--postgresql-18-and-patroni-from-packages\">Step 4 — PostgreSQL 18 and Patroni from packages</h2>\n\n<p>On both database nodes, install from PGDG and let it resolve its own Python:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nb\">sudo </span>dnf <span class=\"nt\">-y</span> <span class=\"nb\">install </span>https://download.postgresql.org/pub/repos/yum/reporpms/EL-8-x86_64/pgdg-redhat-repo-latest.noarch.rpm\n<span class=\"nb\">sudo </span>dnf <span class=\"nt\">-qy</span> module disable postgresql\n<span class=\"nb\">sudo </span>dnf <span class=\"nt\">-y</span> <span class=\"nb\">install </span>postgresql18-server postgresql18-contrib patroni-etcd\n</code></pre></div></div>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>postgres (PostgreSQL) 18.6\npatroni 4.1.5\nPython 3.12.13\n</code></pre></div></div>\n\n<p>That last line is the point of doing it this way. RHEL 8 and Rocky 8 ship Python 3.6 as <code class=\"language-plaintext highlighter-rouge\">python3</code>, which current Patroni does not support. <code class=\"language-plaintext highlighter-rouge\">pip install patroni</code> fails, and the usual workaround is a hand-built interpreter nobody maintains. The PGDG package pulls <code class=\"language-plaintext highlighter-rouge\">python3.12-etcd</code>, <code class=\"language-plaintext highlighter-rouge\">python3.12-dns</code> and the rest of a parallel 3.12 stack, and the system Python is never involved.</p>\n\n<p>Do not run <code class=\"language-plaintext highlighter-rouge\">initdb</code>. Patroni owns the data directory and will bootstrap it.</p>\n\n<div data-file=\"/etc/patroni/patroni.yml (pgn1; pgn2 differs only in name and IP)\" class=\"language-yaml highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"na\">scope</span><span class=\"pi\">:</span> <span class=\"s\">pg18lab</span>\n<span class=\"na\">name</span><span class=\"pi\">:</span> <span class=\"s\">pgn1</span>\n\n<span class=\"na\">restapi</span><span class=\"pi\">:</span>\n  <span class=\"na\">listen</span><span class=\"pi\">:</span> <span class=\"s\">192.168.105.141:8008</span>\n  <span class=\"na\">connect_address</span><span class=\"pi\">:</span> <span class=\"s\">192.168.105.141:8008</span>\n\n<span class=\"na\">etcd3</span><span class=\"pi\">:</span>\n  <span class=\"na\">hosts</span><span class=\"pi\">:</span> <span class=\"s\">192.168.105.140:2379</span>\n\n<span class=\"na\">bootstrap</span><span class=\"pi\">:</span>\n  <span class=\"na\">dcs</span><span class=\"pi\">:</span>\n    <span class=\"na\">ttl</span><span class=\"pi\">:</span> <span class=\"m\">30</span>\n    <span class=\"na\">loop_wait</span><span class=\"pi\">:</span> <span class=\"m\">10</span>\n    <span class=\"na\">retry_timeout</span><span class=\"pi\">:</span> <span class=\"m\">10</span>\n    <span class=\"na\">postgresql</span><span class=\"pi\">:</span>\n      <span class=\"na\">use_pg_rewind</span><span class=\"pi\">:</span> <span class=\"kc\">true</span>\n      <span class=\"na\">parameters</span><span class=\"pi\">:</span>\n        <span class=\"na\">wal_level</span><span class=\"pi\">:</span> <span class=\"s\">replica</span>\n        <span class=\"na\">max_wal_senders</span><span class=\"pi\">:</span> <span class=\"m\">10</span>\n        <span class=\"na\">max_replication_slots</span><span class=\"pi\">:</span> <span class=\"m\">10</span>\n  <span class=\"na\">pg_hba</span><span class=\"pi\">:</span>\n    <span class=\"pi\">-</span> <span class=\"s\">host replication replicator 192.168.105.0/24 scram-sha-256</span>\n    <span class=\"pi\">-</span> <span class=\"s\">host all all 192.168.105.0/24 scram-sha-256</span>\n    <span class=\"pi\">-</span> <span class=\"s\">local all all peer</span>\n\n<span class=\"na\">postgresql</span><span class=\"pi\">:</span>\n  <span class=\"na\">listen</span><span class=\"pi\">:</span> <span class=\"s\">192.168.105.141:5432</span>\n  <span class=\"na\">connect_address</span><span class=\"pi\">:</span> <span class=\"s\">192.168.105.141:5432</span>\n  <span class=\"na\">data_dir</span><span class=\"pi\">:</span> <span class=\"s\">/var/lib/pgsql/18/data</span>\n  <span class=\"na\">bin_dir</span><span class=\"pi\">:</span> <span class=\"s\">/usr/pgsql-18/bin</span>\n  <span class=\"na\">authentication</span><span class=\"pi\">:</span>\n    <span class=\"na\">replication</span><span class=\"pi\">:</span> <span class=\"pi\">{</span> <span class=\"nv\">username</span><span class=\"pi\">:</span> <span class=\"nv\">replicator</span><span class=\"pi\">,</span> <span class=\"nv\">password</span><span class=\"pi\">:</span> <span class=\"nv\">replpass</span> <span class=\"pi\">}</span>\n    <span class=\"na\">superuser</span><span class=\"pi\">:</span>   <span class=\"pi\">{</span> <span class=\"nv\">username</span><span class=\"pi\">:</span> <span class=\"nv\">postgres</span><span class=\"pi\">,</span>   <span class=\"nv\">password</span><span class=\"pi\">:</span> <span class=\"nv\">pgpass</span> <span class=\"pi\">}</span>\n</code></pre></div></div>\n\n<p>Use <code class=\"language-plaintext highlighter-rouge\">etcd3</code>, not <code class=\"language-plaintext highlighter-rouge\">etcd</code>. The older key is the v2 API, which modern etcd no longer serves. And <code class=\"language-plaintext highlighter-rouge\">use_pg_rewind: true</code> is what makes Step 6 work at all.</p>\n\n<p>Start <code class=\"language-plaintext highlighter-rouge\">pgn1</code> first and let it win the bootstrap, then start <code class=\"language-plaintext highlighter-rouge\">pgn2</code>:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nb\">sudo </span>systemctl <span class=\"nb\">enable</span> <span class=\"nt\">--now</span> patroni\n<span class=\"nb\">sudo</span> <span class=\"nt\">-u</span> postgres patronictl <span class=\"nt\">-c</span> /etc/patroni/patroni.yml list\n</code></pre></div></div>\n\n<p><strong>Verify.</strong> Two members, one leader, replica streaming with no lag:</p>\n\n<div class=\"language-text verify highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>+ Cluster: pg18lab (7673867291462481394) --------+----+-------------+-----+------------+-----+\n| Member |       Host      |   Role  |   State   | TL | Receive LSN | Lag | Replay LSN | Lag |\n+--------+-----------------+---------+-----------+----+-------------+-----+------------+-----+\n| pgn1   | 192.168.105.141 | Leader  | running   |  1 |             |     |            |     |\n| pgn2   | 192.168.105.142 | Replica | streaming |  1 |   0/302C4F8 |   0 |  0/302C4F8 |   0 |\n+--------+-----------------+---------+-----------+----+-------------+-----+------------+-----+\n</code></pre></div></div>\n\n<h2 id=\"step-5--kill-the-leader-and-measure\">Step 5 — Kill the leader and measure</h2>\n\n<p>Write something first, so survival is a fact rather than an assumption:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nv\">PGPASSWORD</span><span class=\"o\">=</span>pgpass psql <span class=\"nt\">-h</span> 192.168.105.141 <span class=\"nt\">-U</span> postgres <span class=\"se\">\\</span>\n  <span class=\"nt\">-c</span> <span class=\"s2\">\"CREATE TABLE failover_probe(id serial primary key, note text, at timestamptz default now());\"</span> <span class=\"se\">\\</span>\n  <span class=\"nt\">-c</span> <span class=\"s2\">\"INSERT INTO failover_probe(note) VALUES ('written on pgn1 before kill');\"</span>\n</code></pre></div></div>\n\n<p>Confirm it reached the replica, which should also report that it is in recovery:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nv\">PGPASSWORD</span><span class=\"o\">=</span>pgpass psql <span class=\"nt\">-h</span> 192.168.105.142 <span class=\"nt\">-U</span> postgres <span class=\"se\">\\</span>\n  <span class=\"nt\">-tAc</span> <span class=\"s2\">\"SELECT count(*), pg_is_in_recovery() FROM failover_probe;\"</span>\n</code></pre></div></div>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>1|t\n</code></pre></div></div>\n\n<p>Now kill it properly. Not <code class=\"language-plaintext highlighter-rouge\">systemctl stop</code> — that is a handover, and a handover is the easy case:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nb\">sudo </span>systemctl <span class=\"nb\">kill</span> <span class=\"nt\">-s</span> SIGKILL patroni\n<span class=\"nb\">sudo </span>pkill <span class=\"nt\">-9</span> <span class=\"nt\">-f</span> <span class=\"s1\">'postgres -D'</span>\n</code></pre></div></div>\n\n<p>Poll the surviving node while it happens:</p>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>+10s  pgn2 role=Replica\n+20s  pgn2 role=Leader\n</code></pre></div></div>\n\n<p><strong>Verify.</strong> The survivor is a writable leader on a new timeline, and the pre-kill row is still there:</p>\n\n<div class=\"language-text verify highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>in recovery:    f\nrows survived:  1\nINSERT 0 1\nrows now:       2\ntimeline:       2\n</code></pre></div></div>\n\n<p>Twenty seconds is not a tuning result, it is arithmetic: <code class=\"language-plaintext highlighter-rouge\">ttl: 30</code> with <code class=\"language-plaintext highlighter-rouge\">loop_wait: 10</code> means the leader key expires somewhere inside a 30-second window and the next loop notices. Shortening the TTL shortens the outage and raises the chance a slow node gets demoted for a hiccup. That trade is the whole design conversation, and it belongs in your environment, not in a lab default.</p>\n\n<h2 id=\"step-6--the-half-everyone-skips-rejoining-the-old-primary\">Step 6 — The half everyone skips: rejoining the old primary</h2>\n\n<p>A promoted replica is only half a failover. The old primary now holds writes that never reached the new leader — it diverged at the moment it died — so it cannot simply start following. Plenty of runbooks resolve this by wiping the data directory and re-cloning, which on a real database means hours and a lot of network.</p>\n\n<p><code class=\"language-plaintext highlighter-rouge\">use_pg_rewind: true</code> makes Patroni do the cheap thing instead. Start it and watch:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nb\">sudo </span>systemctl start patroni\n<span class=\"nb\">sudo </span>journalctl <span class=\"nt\">-u</span> patroni <span class=\"nt\">-f</span>\n</code></pre></div></div>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>pg_rewind: rewinding from last common checkpoint at 0/20000B8 on timeline 1\npg_rewind: Done!\nINFO: no action. I am (pgn1), a secondary, and following a leader (pgn2)\n</code></pre></div></div>\n\n<p><strong>Verify.</strong> Fifteen seconds after the restart, the old primary is a streaming replica on the new timeline with zero lag:</p>\n\n<div class=\"language-text verify highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>| Member |       Host      |   Role  |   State   | TL | Receive LSN | Lag | Replay LSN | Lag |\n| pgn1   | 192.168.105.141 | Replica | streaming |  2 |   0/306F940 |   0 |  0/306F940 |   0 |\n| pgn2   | 192.168.105.142 | Leader  | running   |  2 |             |     |            |     |\n</code></pre></div></div>\n\n<p><code class=\"language-plaintext highlighter-rouge\">pg_rewind</code> needs either <code class=\"language-plaintext highlighter-rouge\">wal_log_hints = on</code> or data checksums to work; the bootstrap above enables checksums via <code class=\"language-plaintext highlighter-rouge\">initdb</code>, which is why this succeeded without extra configuration. Skip that and the rejoin silently falls back to a full re-clone, which looks identical in <code class=\"language-plaintext highlighter-rouge\">patronictl list</code> and takes dramatically longer on a database with real data in it.</p>\n\n<h2 id=\"doesnt-patroni-need-three-nodes\">“Doesn’t Patroni need three nodes?”</h2>\n\n<p>Almost, but the number belongs to a different component than most people attach it to.</p>\n\n<p>Patroni does not require three <strong>Postgres</strong> nodes. Two — a primary and a standby — is a normal production topology and the one built above. The three-node minimum is a property of the <strong>DCS</strong>: etcd, Consul or ZooKeeper need an odd number of members to hold a quorum, and three is the smallest count that tolerates losing one. The two get conflated because the usual deployment co-locates etcd on the same three machines that run Postgres, so “a three-node Patroni cluster” ends up describing the DCS.</p>\n\n<p>That distinction is not academic, because a single-member DCS fails in a way that surprises people. It does not merely stop protecting you. Stop the one etcd node here and wait:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nb\">sudo </span>systemctl stop etcd     <span class=\"c\"># on etcd1</span>\n</code></pre></div></div>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>psql <span class=\"nt\">-h</span> 192.168.105.142 <span class=\"nt\">-U</span> postgres <span class=\"nt\">-tAc</span> <span class=\"s2\">\"SELECT pg_is_in_recovery();\"</span>\npsql <span class=\"nt\">-h</span> 192.168.105.142 <span class=\"nt\">-U</span> postgres <span class=\"nt\">-c</span> <span class=\"s2\">\"INSERT INTO failover_probe(note) VALUES ('after etcd loss');\"</span>\n</code></pre></div></div>\n\n<p><strong>Verify.</strong> Within about 45 seconds the healthy leader has demoted itself and the cluster is read-only:</p>\n\n<div class=\"language-text verify highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>t\nERROR:  cannot execute INSERT in a read-only transaction\n</code></pre></div></div>\n\n<p>Nothing is wrong with Postgres. Patroni cannot renew the leader key, so it cannot prove it is still the leader, so it refuses to accept writes — which is the correct choice, because the alternative is two nodes both believing they are primary. A DCS outage converts into a <strong>full write outage</strong>, not a degraded one.</p>\n\n<p>Restarting etcd restores service on its own:</p>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>| pgn1 | 192.168.105.141 | Replica | streaming |  3 |\n| pgn2 | 192.168.105.142 | Leader  | running   |  3 |\nINSERT 0 1\n</code></pre></div></div>\n\n<p>Note the timeline: <strong>3</strong>, not 2. The demote-and-repromote cycle is a new timeline, exactly like a failover, which is worth knowing before you go looking for the failover that “must have happened” in your logs.</p>\n\n<p>So: two Postgres nodes is a legitimate cluster. One etcd node is not a legitimate DCS. Put etcd on three hosts before this goes anywhere near production, and treat that as the availability floor for the whole system.</p>\n\n<h2 id=\"failure-modes-worth-knowing\">Failure modes worth knowing</h2>\n\n<table>\n  <thead>\n    <tr>\n      <th>Symptom</th>\n      <th>Cause</th>\n      <th>Repair</th>\n    </tr>\n  </thead>\n  <tbody>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">nothing provides python(abi) = 3.12</code></td>\n      <td>Unregistered RHEL 8: no AppStream</td>\n      <td>Register the box, or use Rocky/Alma 8</td>\n    </tr>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">Bad GPG signature</code> on pgdg-rhel8-extras</td>\n      <td>dnf refusing a non-interactive key import</td>\n      <td>Add <code class=\"language-plaintext highlighter-rouge\">-y</code>, or <code class=\"language-plaintext highlighter-rouge\">rpm --import</code> the key first</td>\n    </tr>\n    <tr>\n      <td>Patroni starts, never joins</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">etcd:</code> instead of <code class=\"language-plaintext highlighter-rouge\">etcd3:</code> in the config</td>\n      <td>Use <code class=\"language-plaintext highlighter-rouge\">etcd3</code>; the v2 API is gone</td>\n    </tr>\n    <tr>\n      <td>Replica stuck in <code class=\"language-plaintext highlighter-rouge\">creating replica</code></td>\n      <td>etcd unreachable from that node</td>\n      <td>Open 2379/tcp; check <code class=\"language-plaintext highlighter-rouge\">advertise-client-urls</code> is the lab IP</td>\n    </tr>\n    <tr>\n      <td>Vagrant reports boot timeout on a healthy VM</td>\n      <td>TCG slower than the 300s default</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">config.vm.boot_timeout = 900</code></td>\n    </tr>\n    <tr>\n      <td>Old primary re-clones instead of rewinding</td>\n      <td>No checksums and no <code class=\"language-plaintext highlighter-rouge\">wal_log_hints</code></td>\n      <td><code class=\"language-plaintext highlighter-rouge\">initdb</code> with <code class=\"language-plaintext highlighter-rouge\">data-checksums</code>, or set the hint parameter</td>\n    </tr>\n    <tr>\n      <td>Healthy leader goes read-only, no failover in logs</td>\n      <td>DCS unreachable: Patroni cannot renew the leader key</td>\n      <td>Restore etcd; run three DCS members so one loss is survivable</td>\n    </tr>\n  </tbody>\n</table>\n\n<h2 id=\"clean-up-and-what-this-changes\">Clean up, and what this changes</h2>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nb\">cd</span> ~/labs/rocky8-patroni <span class=\"o\">&amp;&amp;</span> vagrant destroy <span class=\"nt\">-f</span>\n</code></pre></div></div>\n\n<p>The operating consequence is the part to carry forward. This cluster survives a dead primary in about twenty seconds without anyone being paged, and it repairs the dead node automatically instead of demanding a rebuild — but only because two settings were right before the incident: <code class=\"language-plaintext highlighter-rouge\">use_pg_rewind</code> and data checksums. Neither can be added usefully while you are recovering.</p>\n\n<p>The single etcd node is the honest limit of this lab. It proves the Postgres half. Whether the <em>cluster</em> stays available when the DCS itself goes down is a separate test, and it deserves a separate rig rather than an assumption.</p>\n",
      "summary": "Build a two-node Patroni cluster on Rocky 8 under QEMU, then SIGKILL the leader and measure the promotion, the timeline bump, and the pg_rewind rejoin.",
      "date_published": "2026-08-14T09:00:00+05:45",
      "date_modified": "2026-08-14T00:00:00+05:45",
      "tags": ["postgres","distributed-databases"],
      "authors": [
        {
          "name": "Basant Bhattarai",
          "url": "https://basantbhattarai.com.np"
        }
      ]
    },
    {
      "id": "https://basantbhattarai.com.np/writing/pgbackrest-tls-ansible-rhel8/",
      "url": "https://basantbhattarai.com.np/writing/pgbackrest-tls-ansible-rhel8/",
      "title": "Dedicated pgBackRest TLS on RHEL 8 with Ansible—before Postgres",
      "content_html": "<h2 id=\"what-failed\">What failed</h2>\n\n<p>The usual pgBackRest lab starts with Postgres already running and SSH already trusted. That hides the failure you actually get in production: the backup host is up, port 8432 is open, and the first <code class=\"language-plaintext highlighter-rouge\">backup</code> still dies on certificate CN, SAN, or a CLI option that does not mean what the docs from two versions ago said.</p>\n\n<p>I wanted the opposite order. A <strong>dedicated</strong> <code class=\"language-plaintext highlighter-rouge\">backup1</code> is the TLS repo host. A tiny <code class=\"language-plaintext highlighter-rouge\">pg1</code> is only a TLS client. Ansible owns packages, <code class=\"language-plaintext highlighter-rouge\">pgbackrest.conf</code>, firewalld, systemd, the private CA, and cert distribution. Vagrant boots the box and the lab NIC. Postgres is the next post in this series, not this one.</p>\n\n<p>The measured package on this rig was <strong>pgBackRest 2.59.0</strong> from PGDG on <code class=\"language-plaintext highlighter-rouge\">generic/rhel8</code>. Your <code class=\"language-plaintext highlighter-rouge\">dnf</code> may land a newer patch; record what you installed. This sits on the <a href=\"/writing/amd64-vagrant-labs-apple-silicon-socket-vmnet/\">Apple Silicon amd64 networking tutorial</a>. If host and mesh ping are not green, stop. A dead fabric looks like a pgBackRest bug and is not one.</p>\n\n<p>There is no public repository to clone. The load-bearing files are reproduced below.</p>\n\n<h2 id=\"topology\">Topology</h2>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>Mac (Ansible control, Python 3.12 + ansible-core 2.15.13)\n  │  SSH to lab IPs, not SLIRP hostfwd\n  │\n  ├─ backup1   192.168.105.130   generic/rhel8   1G / 1 CPU\n  │              pgbackrest server  :8432\n  │              repo1-path         /var/lib/pgbackrest\n  │              certs              /etc/pgbackrest/certs/backup1.{crt,key}\n  │\n  └─ pg1       192.168.105.131   generic/rhel8   1G / 1 CPU\n                 client cert        /etc/pgbackrest/certs/pg1.{crt,key}\n                 proof              server-ping + openssl s_client mTLS\n</code></pre></div></div>\n\n<p>Two NICs per guest, same as the fabric post: SLIRP for <code class=\"language-plaintext highlighter-rouge\">vagrant ssh</code>, socket_vmnet for the addresses Ansible and pgBackRest will actually use. Private keys live on the control node under <code class=\"language-plaintext highlighter-rouge\">ansible/artifacts/certs/</code> and are gitignored. Guests get copies owned by the <code class=\"language-plaintext highlighter-rouge\">pgbackrest</code> user, mode <code class=\"language-plaintext highlighter-rouge\">0600</code> on keys.</p>\n\n<h2 id=\"step-1--boot-two-small-rhel-8-guests\">Step 1 — Boot two small RHEL 8 guests</h2>\n\n<div data-file=\"Vagrantfile (node table)\" class=\"language-ruby highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"no\">NODES</span> <span class=\"o\">=</span> <span class=\"p\">[</span>\n  <span class=\"p\">{</span> <span class=\"ss\">name: </span><span class=\"s2\">\"backup1\"</span><span class=\"p\">,</span> <span class=\"ss\">box: </span><span class=\"s2\">\"generic/rhel8\"</span><span class=\"p\">,</span> <span class=\"ss\">ip: </span><span class=\"s2\">\"192.168.105.130\"</span><span class=\"p\">,</span>\n    <span class=\"ss\">ssh_port: </span><span class=\"mi\">22130</span><span class=\"p\">,</span> <span class=\"ss\">memory: </span><span class=\"s2\">\"1024\"</span><span class=\"p\">,</span> <span class=\"ss\">smp: </span><span class=\"s2\">\"1\"</span><span class=\"p\">,</span> <span class=\"ss\">mac: </span><span class=\"s2\">\"52:54:00:12:01:30\"</span> <span class=\"p\">},</span>\n  <span class=\"p\">{</span> <span class=\"ss\">name: </span><span class=\"s2\">\"pg1\"</span><span class=\"p\">,</span>     <span class=\"ss\">box: </span><span class=\"s2\">\"generic/rhel8\"</span><span class=\"p\">,</span> <span class=\"ss\">ip: </span><span class=\"s2\">\"192.168.105.131\"</span><span class=\"p\">,</span>\n    <span class=\"ss\">ssh_port: </span><span class=\"mi\">22131</span><span class=\"p\">,</span> <span class=\"ss\">memory: </span><span class=\"s2\">\"1024\"</span><span class=\"p\">,</span> <span class=\"ss\">smp: </span><span class=\"s2\">\"1\"</span><span class=\"p\">,</span> <span class=\"ss\">mac: </span><span class=\"s2\">\"52:54:00:12:01:31\"</span> <span class=\"p\">},</span>\n<span class=\"p\">]</span>\n</code></pre></div></div>\n\n<p>Each provider block is <code class=\"language-plaintext highlighter-rouge\">arch = \"x86_64\"</code>, <code class=\"language-plaintext highlighter-rouge\">no_daemonize = true</code>, and a second NIC on <code class=\"language-plaintext highlighter-rouge\">fd=3</code> through the socket_vmnet wrapper. The only shell provisioner is lab NIC + <code class=\"language-plaintext highlighter-rouge\">/etc/hosts</code>. No packages, no pgBackRest.</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>./bin/vagrant-up backup1 pg1\n</code></pre></div></div>\n\n<p>First TCG boot is slow. When provision finishes, eth1 holds the lab IP.</p>\n\n<p class=\"verify\"><strong>Verify.</strong></p>\n\n<div class=\"language-console highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"gp\">$</span><span class=\"w\"> </span>ping <span class=\"nt\">-c</span> 2 192.168.105.130\n<span class=\"go\">2 packets transmitted, 2 packets received, 0.0% packet loss\n\n</span><span class=\"gp\">$</span><span class=\"w\"> </span>ping <span class=\"nt\">-c</span> 2 192.168.105.131\n<span class=\"go\">2 packets transmitted, 2 packets received, 0.0% packet loss\n</span></code></pre></div></div>\n\n<h2 id=\"step-2--use-an-ansible-that-still-speaks-rhel-8\">Step 2 — Use an Ansible that still speaks RHEL 8</h2>\n\n<p>Homebrew Ansible (core 2.21 on Python 3.14) fails against RHEL 8’s platform Python 3.6 with module deserialization errors (<code class=\"language-plaintext highlighter-rouge\">future feature annotations is not defined</code>). Python 3.14 also breaks Ansible <code class=\"language-plaintext highlighter-rouge\">when:</code> parsing (<code class=\"language-plaintext highlighter-rouge\">ast.Str</code>). The control node for this lab is a <strong>Python 3.12</strong> venv pinned to <strong>ansible-core 2.15.13</strong>.</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>python3.12 <span class=\"nt\">-m</span> venv .venv\n.venv/bin/pip <span class=\"nb\">install</span> <span class=\"s1\">'ansible-core==2.15.13'</span>\n</code></pre></div></div>\n\n<p>Inventory talks to <strong>lab IPs</strong>. <code class=\"language-plaintext highlighter-rouge\">vagrant ssh</code> over SLIRP is fine for a rescue shell; backups must use the fabric.</p>\n\n<div data-file=\"ansible/inventory/lab.yml\" class=\"language-yaml highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"na\">all</span><span class=\"pi\">:</span>\n  <span class=\"na\">hosts</span><span class=\"pi\">:</span>\n    <span class=\"na\">localhost</span><span class=\"pi\">:</span>\n      <span class=\"na\">ansible_connection</span><span class=\"pi\">:</span> <span class=\"s\">local</span>\n      <span class=\"na\">ansible_python_interpreter</span><span class=\"pi\">:</span> <span class=\"s2\">\"</span><span class=\"s\">\"</span>\n  <span class=\"na\">children</span><span class=\"pi\">:</span>\n    <span class=\"na\">pgbackrest_servers</span><span class=\"pi\">:</span>\n      <span class=\"na\">hosts</span><span class=\"pi\">:</span>\n        <span class=\"na\">backup1</span><span class=\"pi\">:</span>\n          <span class=\"na\">ansible_host</span><span class=\"pi\">:</span> <span class=\"s\">192.168.105.130</span>\n          <span class=\"na\">ansible_user</span><span class=\"pi\">:</span> <span class=\"s\">vagrant</span>\n          <span class=\"na\">ansible_ssh_private_key_file</span><span class=\"pi\">:</span> <span class=\"s\">.vagrant/machines/backup1/qemu/private_key</span>\n          <span class=\"na\">lab_ip</span><span class=\"pi\">:</span> <span class=\"s\">192.168.105.130</span>\n    <span class=\"na\">pgbackrest_clients</span><span class=\"pi\">:</span>\n      <span class=\"na\">hosts</span><span class=\"pi\">:</span>\n        <span class=\"na\">pg1</span><span class=\"pi\">:</span>\n          <span class=\"na\">ansible_host</span><span class=\"pi\">:</span> <span class=\"s\">192.168.105.131</span>\n          <span class=\"na\">ansible_user</span><span class=\"pi\">:</span> <span class=\"s\">vagrant</span>\n          <span class=\"na\">ansible_ssh_private_key_file</span><span class=\"pi\">:</span> <span class=\"s\">.vagrant/machines/pg1/qemu/private_key</span>\n          <span class=\"na\">lab_ip</span><span class=\"pi\">:</span> <span class=\"s\">192.168.105.131</span>\n</code></pre></div></div>\n\n<p><code class=\"language-plaintext highlighter-rouge\">lab_ip</code> is not decorative. The cert play uses it as a SAN. If the certificate only names <code class=\"language-plaintext highlighter-rouge\">backup1</code> and Ansible later connects by IP, OpenSSL will tell you the truth.</p>\n\n<p class=\"verify\"><strong>Verify.</strong></p>\n\n<div class=\"language-console highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"gp\">$</span><span class=\"w\"> </span>.venv/bin/ansible <span class=\"nt\">-i</span> ansible/inventory/lab.yml backup1,pg1 <span class=\"nt\">-m</span> ping\n<span class=\"gp\">backup1 | SUCCESS =&gt;</span><span class=\"w\"> </span><span class=\"o\">{</span> <span class=\"s2\">\"ping\"</span>: <span class=\"s2\">\"pong\"</span> <span class=\"o\">}</span>\n<span class=\"gp\">pg1     | SUCCESS =&gt;</span><span class=\"w\"> </span><span class=\"o\">{</span> <span class=\"s2\">\"ping\"</span>: <span class=\"s2\">\"pong\"</span> <span class=\"o\">}</span>\n</code></pre></div></div>\n\n<h2 id=\"step-3--install-from-pgdg-issue-a-ca-start-the-tls-server\">Step 3 — Install from PGDG, issue a CA, start the TLS server</h2>\n\n<p>The playbook is four jobs in order, and the order is load-bearing:</p>\n\n<ol>\n  <li>Install <code class=\"language-plaintext highlighter-rouge\">pgbackrest</code> and create the system user/dirs on every TLS node.</li>\n  <li>Generate CA + per-host certs on <strong>localhost</strong> (no sudo).</li>\n  <li>Copy <code class=\"language-plaintext highlighter-rouge\">ca.crt</code>, <code class=\"language-plaintext highlighter-rouge\">&lt;host&gt;.crt</code>, <code class=\"language-plaintext highlighter-rouge\">&lt;host&gt;.key</code> to <code class=\"language-plaintext highlighter-rouge\">/etc/pgbackrest/certs/</code>.</li>\n  <li>Template conf, open firewalld <code class=\"language-plaintext highlighter-rouge\">8432/tcp</code> on the repo host, start <code class=\"language-plaintext highlighter-rouge\">pgbackrest-server</code>, then prove from <code class=\"language-plaintext highlighter-rouge\">pg1</code>.</li>\n</ol>\n\n<p>Install the PGDG EL8 repo RPM, then <code class=\"language-plaintext highlighter-rouge\">dnf install</code> the newest <code class=\"language-plaintext highlighter-rouge\">pgbackrest</code>. On this run that resolved to <strong>2.59.0</strong>. Disable the AppStream <code class=\"language-plaintext highlighter-rouge\">postgresql</code> module if it exists; on this box the module was already absent, so that task is allowed to fail.</p>\n\n<p>Certificates are created on the Mac so the CA private key never lives on a guest except as a local artifact you can delete:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>openssl genrsa <span class=\"nt\">-out</span> ca.key 4096\nopenssl req <span class=\"nt\">-new</span> <span class=\"nt\">-x509</span> <span class=\"nt\">-days</span> 3650 <span class=\"nt\">-key</span> ca.key <span class=\"nt\">-out</span> ca.crt <span class=\"se\">\\</span>\n  <span class=\"nt\">-subj</span> <span class=\"s2\">\"/O=Lab/CN=lab-pgbackrest-ca\"</span>\n\n<span class=\"c\"># per host: CN = hostname, SAN = DNS:hostname,IP:lab_ip</span>\nopenssl genrsa <span class=\"nt\">-out</span> backup1.key 2048\nopenssl req <span class=\"nt\">-new</span> <span class=\"nt\">-key</span> backup1.key <span class=\"nt\">-out</span> backup1.csr <span class=\"nt\">-subj</span> <span class=\"s2\">\"/O=Lab/CN=backup1\"</span>\n<span class=\"c\"># extfile: serverAuth,clientAuth + subjectAltName=DNS:backup1,IP:192.168.105.130</span>\nopenssl x509 <span class=\"nt\">-req</span> <span class=\"nt\">-in</span> backup1.csr <span class=\"nt\">-CA</span> ca.crt <span class=\"nt\">-CAkey</span> ca.key <span class=\"nt\">-CAcreateserial</span> <span class=\"se\">\\</span>\n  <span class=\"nt\">-out</span> backup1.crt <span class=\"nt\">-days</span> 3650 <span class=\"nt\">-extfile</span> backup1.ext\n<span class=\"nb\">chmod </span>600 ca.key backup1.key\n</code></pre></div></div>\n\n<p>Both <code class=\"language-plaintext highlighter-rouge\">serverAuth</code> and <code class=\"language-plaintext highlighter-rouge\">clientAuth</code> are required. pgBackRest TLS is mutual: the repo host presents a server cert, the client presents a client cert, and <code class=\"language-plaintext highlighter-rouge\">tls-server-auth</code> matches the client <strong>CN</strong> to a stanza list. A SAN-only cert with a wrong CN will handshake in <code class=\"language-plaintext highlighter-rouge\">openssl s_client</code> and still fail pgBackRest auth.</p>\n\n<p>Repo host config:</p>\n\n<div data-file=\"/etc/pgbackrest/pgbackrest.conf (backup1)\" class=\"language-ini highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nn\">[global]</span><span class=\"w\">\n</span><span class=\"py\">log-level-console</span><span class=\"p\">=</span><span class=\"s\">info</span>\n<span class=\"py\">log-level-file</span><span class=\"p\">=</span><span class=\"s\">detail</span>\n<span class=\"py\">log-path</span><span class=\"p\">=</span><span class=\"s\">/var/log/pgbackrest</span>\n<span class=\"py\">repo1-path</span><span class=\"p\">=</span><span class=\"s\">/var/lib/pgbackrest</span>\n<span class=\"py\">repo1-retention-full</span><span class=\"p\">=</span><span class=\"s\">2</span>\n<span class=\"py\">repo1-bundle</span><span class=\"p\">=</span><span class=\"s\">y</span>\n<span class=\"py\">repo1-block</span><span class=\"p\">=</span><span class=\"s\">y</span>\n<span class=\"py\">tls-server-address</span><span class=\"p\">=</span><span class=\"s\">*</span>\n<span class=\"py\">tls-server-port</span><span class=\"p\">=</span><span class=\"s\">8432</span>\n<span class=\"py\">tls-server-cert-file</span><span class=\"p\">=</span><span class=\"s\">/etc/pgbackrest/certs/backup1.crt</span>\n<span class=\"py\">tls-server-key-file</span><span class=\"p\">=</span><span class=\"s\">/etc/pgbackrest/certs/backup1.key</span>\n<span class=\"py\">tls-server-ca-file</span><span class=\"p\">=</span><span class=\"s\">/etc/pgbackrest/certs/ca.crt</span>\n<span class=\"py\">tls-server-auth</span><span class=\"p\">=</span><span class=\"s\">pg1=*</span>\n<span class=\"py\">tls-server-auth</span><span class=\"p\">=</span><span class=\"s\">pg2=*</span>\n<span class=\"w\">\n</span><span class=\"nn\">[demo]</span><span class=\"w\">\n</span><span class=\"c\"># pg1-path filled when Postgres is installed\n</span></code></pre></div></div>\n\n<p>Four lines are load-bearing.</p>\n\n<p><strong><code class=\"language-plaintext highlighter-rouge\">tls-server-address=*</code>.</strong> Listen on every interface, including the lab NIC. <code class=\"language-plaintext highlighter-rouge\">localhost</code> would make <code class=\"language-plaintext highlighter-rouge\">server-ping</code> from <code class=\"language-plaintext highlighter-rouge\">pg1</code> hang.</p>\n\n<p><strong><code class=\"language-plaintext highlighter-rouge\">tls-server-auth=pg1=*</code>.</strong> Authorize the client certificate whose CN is <code class=\"language-plaintext highlighter-rouge\">pg1</code> for every stanza. Until Postgres exists the stanza is a placeholder; the auth line is still required or the later backup is refused as an unauthorized client.</p>\n\n<p><strong><code class=\"language-plaintext highlighter-rouge\">repo1-bundle</code> / <code class=\"language-plaintext highlighter-rouge\">repo1-block</code>.</strong> Current PGDG defaults for a new repo. Harmless on an empty repo; leave them so the next post does not change file format mid-series.</p>\n\n<p><strong>Keys <code class=\"language-plaintext highlighter-rouge\">0600</code>, owned by <code class=\"language-plaintext highlighter-rouge\">pgbackrest</code>.</strong> The systemd unit does not run as root. A root-owned key makes <code class=\"language-plaintext highlighter-rouge\">pgbackrest server</code> fail after a restart that looks unrelated.</p>\n\n<p>Client config (no local repo path — the repo is on <code class=\"language-plaintext highlighter-rouge\">backup1</code>):</p>\n\n<div data-file=\"/etc/pgbackrest/pgbackrest.conf (pg1)\" class=\"language-ini highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nn\">[global]</span><span class=\"w\">\n</span><span class=\"py\">log-level-console</span><span class=\"p\">=</span><span class=\"s\">info</span>\n<span class=\"py\">log-path</span><span class=\"p\">=</span><span class=\"s\">/var/log/pgbackrest</span>\n<span class=\"py\">repo1-host</span><span class=\"p\">=</span><span class=\"s\">backup1</span>\n<span class=\"py\">repo1-host-type</span><span class=\"p\">=</span><span class=\"s\">tls</span>\n<span class=\"py\">repo1-host-user</span><span class=\"p\">=</span><span class=\"s\">pgbackrest</span>\n<span class=\"py\">repo1-host-port</span><span class=\"p\">=</span><span class=\"s\">8432</span>\n<span class=\"py\">repo1-host-cert-file</span><span class=\"p\">=</span><span class=\"s\">/etc/pgbackrest/certs/pg1.crt</span>\n<span class=\"py\">repo1-host-key-file</span><span class=\"p\">=</span><span class=\"s\">/etc/pgbackrest/certs/pg1.key</span>\n<span class=\"py\">repo1-host-ca-file</span><span class=\"p\">=</span><span class=\"s\">/etc/pgbackrest/certs/ca.crt</span>\n<span class=\"py\">tls-server-address</span><span class=\"p\">=</span><span class=\"s\">*</span>\n<span class=\"py\">tls-server-port</span><span class=\"p\">=</span><span class=\"s\">8432</span>\n<span class=\"py\">tls-server-cert-file</span><span class=\"p\">=</span><span class=\"s\">/etc/pgbackrest/certs/pg1.crt</span>\n<span class=\"py\">tls-server-key-file</span><span class=\"p\">=</span><span class=\"s\">/etc/pgbackrest/certs/pg1.key</span>\n<span class=\"py\">tls-server-ca-file</span><span class=\"p\">=</span><span class=\"s\">/etc/pgbackrest/certs/ca.crt</span>\n<span class=\"py\">tls-server-auth</span><span class=\"p\">=</span><span class=\"s\">backup1=*</span>\n</code></pre></div></div>\n\n<p>The client also runs a TLS server so the repo host can call back once Postgres exists. Mutual TLS is the model, not “open 8432 and hope.”</p>\n\n<div data-file=\"/etc/systemd/system/pgbackrest-server.service\" class=\"language-ini highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nn\">[Unit]</span><span class=\"w\">\n</span><span class=\"py\">Description</span><span class=\"p\">=</span><span class=\"s\">pgBackRest TLS server</span>\n<span class=\"py\">After</span><span class=\"p\">=</span><span class=\"s\">network-online.target</span>\n<span class=\"py\">Wants</span><span class=\"p\">=</span><span class=\"s\">network-online.target</span>\n<span class=\"w\">\n</span><span class=\"nn\">[Service]</span><span class=\"w\">\n</span><span class=\"py\">Type</span><span class=\"p\">=</span><span class=\"s\">simple</span>\n<span class=\"py\">User</span><span class=\"p\">=</span><span class=\"s\">pgbackrest</span>\n<span class=\"py\">Group</span><span class=\"p\">=</span><span class=\"s\">pgbackrest</span>\n<span class=\"py\">ExecStart</span><span class=\"p\">=</span><span class=\"s\">/usr/bin/pgbackrest server</span>\n<span class=\"py\">Restart</span><span class=\"p\">=</span><span class=\"s\">on-failure</span>\n<span class=\"py\">RestartSec</span><span class=\"p\">=</span><span class=\"s\">3</span>\n<span class=\"w\">\n</span><span class=\"nn\">[Install]</span><span class=\"w\">\n</span><span class=\"py\">WantedBy</span><span class=\"p\">=</span><span class=\"s\">multi-user.target</span>\n</code></pre></div></div>\n\n<p>firewalld on <code class=\"language-plaintext highlighter-rouge\">backup1</code> only: <code class=\"language-plaintext highlighter-rouge\">firewall-cmd --permanent --add-port=8432/tcp</code> then <code class=\"language-plaintext highlighter-rouge\">--reload</code>. If the unit is inactive, <code class=\"language-plaintext highlighter-rouge\">server-ping</code> fails for a mundane reason: nothing is listening.</p>\n\n<p class=\"verify\"><strong>Verify.</strong></p>\n\n<div class=\"language-console highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"gp\">$</span><span class=\"w\"> </span>systemctl is-active pgbackrest-server\n<span class=\"go\">active\n\n</span><span class=\"gp\">$</span><span class=\"w\"> </span>ss <span class=\"nt\">-lntp</span> | <span class=\"nb\">grep </span>8432\n<span class=\"go\">LISTEN 0 100 0.0.0.0:8432 0.0.0.0:* users:((\"pgbackrest\",pid=…,fd=4))\n\n</span><span class=\"gp\">$</span><span class=\"w\"> </span>pgbackrest version\n<span class=\"go\">pgBackRest 2.59.0\n</span></code></pre></div></div>\n\n<h2 id=\"step-4--prove-aliveness-and-mutual-tls-from-the-client\">Step 4 — Prove aliveness and mutual TLS from the client</h2>\n\n<p><code class=\"language-plaintext highlighter-rouge\">server-ping</code> in 2.59 is an aliveness check. It does <strong>not</strong> take <code class=\"language-plaintext highlighter-rouge\">repo1-host-*</code>. Ping address and port:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nb\">sudo</span> <span class=\"nt\">-u</span> pgbackrest pgbackrest server-ping <span class=\"se\">\\</span>\n  <span class=\"nt\">--tls-server-address</span><span class=\"o\">=</span>backup1 <span class=\"se\">\\</span>\n  <span class=\"nt\">--tls-server-port</span><span class=\"o\">=</span>8432\n</code></pre></div></div>\n\n<p>Then prove the client certificate is accepted. <code class=\"language-plaintext highlighter-rouge\">server-ping</code> does not authenticate; a green ping with a broken client cert is a false pass.</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nb\">echo</span> | openssl s_client <span class=\"nt\">-connect</span> 192.168.105.130:8432 <span class=\"se\">\\</span>\n  <span class=\"nt\">-cert</span> /etc/pgbackrest/certs/pg1.crt <span class=\"se\">\\</span>\n  <span class=\"nt\">-key</span> /etc/pgbackrest/certs/pg1.key <span class=\"se\">\\</span>\n  <span class=\"nt\">-CAfile</span> /etc/pgbackrest/certs/ca.crt <span class=\"se\">\\</span>\n  <span class=\"nt\">-servername</span> backup1 2&gt;/dev/null <span class=\"se\">\\</span>\n  | <span class=\"nb\">grep</span> <span class=\"nt\">-E</span> <span class=\"s1\">'Verify return code:|subject='</span>\n</code></pre></div></div>\n\n<p class=\"verify\"><strong>Verify.</strong></p>\n\n<div class=\"language-console highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"gp\">#</span><span class=\"w\"> </span>from pg1 — measured\n<span class=\"go\">server-ping command begin 2.59.0: --tls-server-address=backup1 --tls-server-port=8432\nserver-ping command end: completed successfully (84ms)\n\nsubject=O = Lab, CN = backup1\nissuer=O = Lab, CN = lab-pgbackrest-ca\nVerify return code: 0 (ok)\n</span></code></pre></div></div>\n\n<p>That pair is the gate. A listening socket without mTLS success is not enough. Ansible runs both checks at the end of the playbook so a green recap means the handshake, not merely that packages installed.</p>\n\n<div class=\"callout callout--gotcha\">\n  <p><strong>Failure boundary.</strong> Passing <code class=\"language-plaintext highlighter-rouge\">repo1-host=…</code> on <code class=\"language-plaintext highlighter-rouge\">server-ping</code> fails with <code class=\"language-plaintext highlighter-rouge\">ERROR: [031]: option 'repo-host' not valid for command 'server-ping'</code>. That is a CLI mismatch, not a dead server. Use <code class=\"language-plaintext highlighter-rouge\">--tls-server-address</code> and <code class=\"language-plaintext highlighter-rouge\">--tls-server-port</code>, then prove auth with <code class=\"language-plaintext highlighter-rouge\">openssl s_client</code>. A later <code class=\"language-plaintext highlighter-rouge\">pgbackrest info</code> / <code class=\"language-plaintext highlighter-rouge\">backup</code> is the first command that actually exercises <code class=\"language-plaintext highlighter-rouge\">tls-server-auth</code>.</p>\n</div>\n\n<h2 id=\"failure-modes\">Failure modes</h2>\n\n<table>\n  <thead>\n    <tr>\n      <th>Symptom</th>\n      <th>Cause</th>\n      <th>Repair</th>\n    </tr>\n  </thead>\n  <tbody>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">future feature annotations is not defined</code></td>\n      <td>Control Ansible too new for guest Python 3.6</td>\n      <td>Python 3.12 venv + ansible-core 2.15.x</td>\n    </tr>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">Invalid conditional … ast.Str</code></td>\n      <td>Ansible running on Python 3.14</td>\n      <td>Same venv; do not use Homebrew ansible</td>\n    </tr>\n    <tr>\n      <td>Lab IPs unreachable</td>\n      <td>Fabric / subnet collision</td>\n      <td>Fix socket_vmnet first</td>\n    </tr>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">option 'repo-host' not valid for command 'server-ping'</code></td>\n      <td>Wrong options for 2.59</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">--tls-server-address</code> + port</td>\n    </tr>\n    <tr>\n      <td>Port open, <code class=\"language-plaintext highlighter-rouge\">Verify return code</code> ≠ 0</td>\n      <td>Wrong CA, missing SAN, or CN ≠ hostname</td>\n      <td>Re-issue certs; SAN must include DNS and lab IP</td>\n    </tr>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">server-ping</code> hangs</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">tls-server-address=localhost</code> or firewalld closed</td>\n      <td>Listen on <code class=\"language-plaintext highlighter-rouge\">*</code>; open <code class=\"language-plaintext highlighter-rouge\">8432/tcp</code> on backup1</td>\n    </tr>\n    <tr>\n      <td>Unit active, process cannot read key</td>\n      <td>Key not <code class=\"language-plaintext highlighter-rouge\">0600</code> / not owned by <code class=\"language-plaintext highlighter-rouge\">pgbackrest</code></td>\n      <td>Fix ownership before restarting</td>\n    </tr>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">dnf</code> cannot see <code class=\"language-plaintext highlighter-rouge\">pgbackrest</code></td>\n      <td>PGDG repo missing</td>\n      <td>Re-install <code class=\"language-plaintext highlighter-rouge\">pgdg-redhat-repo-latest</code></td>\n    </tr>\n  </tbody>\n</table>\n\n<h2 id=\"clean-up-and-operating-consequence\">Clean up and operating consequence</h2>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>vagrant halt backup1 pg1\n<span class=\"c\"># or: vagrant destroy -f backup1 pg1</span>\n</code></pre></div></div>\n\n<p>Leave socket_vmnet running if you will rebuild. Rotate certs by deleting <code class=\"language-plaintext highlighter-rouge\">ansible/artifacts/certs/</code> and re-running the generate play. Do not commit <code class=\"language-plaintext highlighter-rouge\">*.key</code>.</p>\n\n<p>Next in this series: install Postgres on the clients, fill <code class=\"language-plaintext highlighter-rouge\">[demo]</code> with <code class=\"language-plaintext highlighter-rouge\">pg1-path</code>, and take the first backup over this TLS repo. The operating rule is <strong>dedicated repo host, Ansible-owned TLS, measured handshake before any <code class=\"language-plaintext highlighter-rouge\">pg_basebackup</code> story.</strong> When that post lands, this one should still be a clean prerequisite, not a fossil inside a combined mega-role.</p>\n",
      "summary": "Stand up a dedicated pgBackRest TLS repo host on generic/rhel8 with Ansible-issued certs, then prove server-ping and mTLS from a client VM.",
      "date_published": "2026-08-13T12:30:00+05:45",
      "date_modified": "2026-08-13T14:10:00+05:45",
      "tags": ["postgres","self-hosted"],
      "authors": [
        {
          "name": "Basant Bhattarai",
          "url": "https://basantbhattarai.com.np"
        }
      ]
    },
    {
      "id": "https://basantbhattarai.com.np/writing/amd64-vagrant-labs-apple-silicon-socket-vmnet/",
      "url": "https://basantbhattarai.com.np/writing/amd64-vagrant-labs-apple-silicon-socket-vmnet/",
      "title": "amd64 Vagrant labs on Apple Silicon without broken host networking",
      "content_html": "<h2 id=\"what-actually-broke\">What actually broke</h2>\n\n<p>I wanted a small cluster on this Mac that behaved like the amd64 VMs I run everywhere else: Ubuntu next to a RHEL-family node, scriptable SSH, and a private network where the host can ping every guest and the guests can ping each other. That is the minimum before Patroni, Postgres 18, or any HA story is worth installing.</p>\n\n<p>What I kept getting instead was the worst kind of almost-working lab. <code class=\"language-plaintext highlighter-rouge\">vagrant up</code> finished. <code class=\"language-plaintext highlighter-rouge\">vagrant ssh</code> worked. The guest claimed an address like <code class=\"language-plaintext highlighter-rouge\">192.168.1.111</code>. And then the host could not reach that address, peers could not reach each other, and half an afternoon disappeared into “networking is fine, something else must be wrong.”</p>\n\n<p>Two mistakes caused most of it.</p>\n\n<p>The first is architecture. Apple Silicon makes <strong>aarch64</strong> boxes feel natural. Production VMs are almost always <strong>amd64</strong>. The moment the lab lies about the instruction set, every later package, extension, and timing story is suspect. So the guests here are deliberate: <code class=\"language-plaintext highlighter-rouge\">qemu-system-x86_64</code> under TCG, slow on purpose, honest about the CPU.</p>\n\n<p>The second is the subnet. Many older Vagrantfiles hard-code <strong><code class=\"language-plaintext highlighter-rouge\">192.168.1.x</code></strong>. On this Mac, Wi‑Fi (<code class=\"language-plaintext highlighter-rouge\">en0</code>) was already <code class=\"language-plaintext highlighter-rouge\">192.168.1.68/24</code>. The lab and the LAN were the same /24. Port-forwarded SSH still worked through SLIRP. Everything that depended on a real L2 fabric—host ping, inter-node ping, later VIP tricks—did not. The repair is boring and non-negotiable: give the lab its <strong>own</strong> unused /24. Mine is <code class=\"language-plaintext highlighter-rouge\">192.168.105.0/24</code>.</p>\n\n<p>This post is the path that survived measurement on one machine: two amd64 guests, Ubuntu plus CentOS 8 as the RHEL stand-in, socket_vmnet for the shared fabric, and proof that host and mesh pings actually return.</p>\n\n<h2 id=\"how-the-two-nics-share-the-work\">How the two NICs share the work</h2>\n\n<p>Each guest gets two interfaces on purpose.</p>\n\n<ul>\n  <li><strong>net0 (QEMU user / SLIRP)</strong> — Vagrant SSH lands on <code class=\"language-plaintext highlighter-rouge\">127.0.0.1:&lt;ssh_port&gt;</code>. This is how you get a shell when the lab NIC is still dark.</li>\n  <li><strong>lab0 (socket_vmnet)</strong> — shared L2 with the host bridge at <code class=\"language-plaintext highlighter-rouge\">192.168.105.1</code> and every peer. Static addresses sit above the DHCP end (<code class=\"language-plaintext highlighter-rouge\">.100</code>): Ubuntu <code class=\"language-plaintext highlighter-rouge\">.111</code> / <code class=\"language-plaintext highlighter-rouge\">.112</code>, Rocky-or-CentOS <code class=\"language-plaintext highlighter-rouge\">.121</code> / <code class=\"language-plaintext highlighter-rouge\">.122</code>.</li>\n</ul>\n\n<p>The second NIC is not “another private_network line and hope.” It is wired as:</p>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>-device virtio-net-pci,netdev=lab0,mac=…\n-netdev socket,id=lab0,fd=3\n</code></pre></div></div>\n\n<p>That <code class=\"language-plaintext highlighter-rouge\">fd=3</code> has to be the unix pipe opened by <code class=\"language-plaintext highlighter-rouge\">socket_vmnet_client</code>. When QEMU opened the unix socket itself with a native <code class=\"language-plaintext highlighter-rouge\">stream</code> connect, the guest’s eth1 transmitted forever and received nothing. <code class=\"language-plaintext highlighter-rouge\">lsof</code> on fd 3 is the cheap tell: you want <code class=\"language-plaintext highlighter-rouge\">TYPE=unix</code> with a live peer, not a pipe left over from an IDE shell.</p>\n\n<h2 id=\"step-1--install-qemu-socket_vmnet-and-a-keepalive-daemon\">Step 1 — Install QEMU, socket_vmnet, and a KeepAlive daemon</h2>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>brew <span class=\"nb\">install </span>qemu socket_vmnet\nvagrant plugin <span class=\"nb\">install </span>vagrant-qemu\nvagrant plugin list | <span class=\"nb\">grep </span>qemu\n</code></pre></div></div>\n\n<p>You want <strong>vagrant-qemu ≥ 0.6.3</strong>. The 0.3.x line does not give you a reliable path for this pattern.</p>\n\n<p>socket_vmnet has to stay up for the whole boot. A one-shot process that dies while TCG is still grinding through cloud-init will leave you debugging a dead bridge. Install it as a LaunchDaemon:</p>\n\n<div data-file=\"/Library/LaunchDaemons/lab.socket_vmnet.plist\" class=\"language-xml highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"cp\">&lt;?xml version=\"1.0\" encoding=\"UTF-8\"?&gt;</span>\n<span class=\"cp\">&lt;!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\"&gt;</span>\n<span class=\"nt\">&lt;plist</span> <span class=\"na\">version=</span><span class=\"s\">\"1.0\"</span><span class=\"nt\">&gt;</span>\n<span class=\"nt\">&lt;dict&gt;</span>\n  <span class=\"nt\">&lt;key&gt;</span>Label<span class=\"nt\">&lt;/key&gt;&lt;string&gt;</span>lab.socket_vmnet<span class=\"nt\">&lt;/string&gt;</span>\n  <span class=\"nt\">&lt;key&gt;</span>ProgramArguments<span class=\"nt\">&lt;/key&gt;</span>\n  <span class=\"nt\">&lt;array&gt;</span>\n    <span class=\"nt\">&lt;string&gt;</span>/opt/homebrew/opt/socket_vmnet/bin/socket_vmnet<span class=\"nt\">&lt;/string&gt;</span>\n    <span class=\"nt\">&lt;string&gt;</span>--vmnet-gateway=192.168.105.1<span class=\"nt\">&lt;/string&gt;</span>\n    <span class=\"nt\">&lt;string&gt;</span>--vmnet-dhcp-end=192.168.105.100<span class=\"nt\">&lt;/string&gt;</span>\n    <span class=\"nt\">&lt;string&gt;</span>/opt/homebrew/var/run/socket_vmnet<span class=\"nt\">&lt;/string&gt;</span>\n  <span class=\"nt\">&lt;/array&gt;</span>\n  <span class=\"nt\">&lt;key&gt;</span>RunAtLoad<span class=\"nt\">&lt;/key&gt;&lt;true/&gt;</span>\n  <span class=\"nt\">&lt;key&gt;</span>KeepAlive<span class=\"nt\">&lt;/key&gt;&lt;true/&gt;</span>\n  <span class=\"nt\">&lt;key&gt;</span>StandardOutPath<span class=\"nt\">&lt;/key&gt;&lt;string&gt;</span>/opt/homebrew/var/log/socket_vmnet/stdout<span class=\"nt\">&lt;/string&gt;</span>\n  <span class=\"nt\">&lt;key&gt;</span>StandardErrorPath<span class=\"nt\">&lt;/key&gt;&lt;string&gt;</span>/opt/homebrew/var/log/socket_vmnet/stderr<span class=\"nt\">&lt;/string&gt;</span>\n<span class=\"nt\">&lt;/dict&gt;</span>\n<span class=\"nt\">&lt;/plist&gt;</span>\n</code></pre></div></div>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nb\">sudo mkdir</span> <span class=\"nt\">-p</span> /opt/homebrew/var/log/socket_vmnet\n<span class=\"nb\">sudo cp </span>lab.socket_vmnet.plist /Library/LaunchDaemons/\n<span class=\"nb\">sudo </span>launchctl bootstrap system /Library/LaunchDaemons/lab.socket_vmnet.plist\n<span class=\"c\"># after edits: sudo launchctl kickstart -kp system/lab.socket_vmnet</span>\n</code></pre></div></div>\n\n<p class=\"verify\"><strong>Verify.</strong></p>\n\n<div class=\"language-console highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"gp\">$</span><span class=\"w\"> </span>ifconfig bridge100 | <span class=\"nb\">grep</span> <span class=\"s1\">'inet '</span>\n<span class=\"go\">\tinet 192.168.105.1 netmask 0xffffff00 broadcast 192.168.105.255\n</span><span class=\"gp\">$</span><span class=\"w\"> </span><span class=\"nb\">ls</span> <span class=\"nt\">-l</span> /opt/homebrew/var/run/socket_vmnet\n<span class=\"go\">srwxr-xr-x  ... /opt/homebrew/var/run/socket_vmnet\n</span></code></pre></div></div>\n\n<div class=\"callout callout--gotcha\">\n  <p><strong>Failure boundary.</strong> If your home LAN already owns <code class=\"language-plaintext highlighter-rouge\">192.168.105.0/24</code>, pick another free /24 and change <strong>both</strong> the daemon and every lab IP. Do not “just use 192.168.1.0/24” because a Patroni gist from 2019 did. That is how this whole mess started.</p>\n</div>\n\n<h2 id=\"step-2--cache-amd64-boxes-and-wrap-qemu-so-fd-3-stays-clean\">Step 2 — Cache amd64 boxes and wrap QEMU so fd 3 stays clean</h2>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>vagrant box add generic/ubuntu2204 <span class=\"nt\">--provider</span> libvirt\nvagrant box add generic/centos8 <span class=\"nt\">--provider</span> libvirt\n</code></pre></div></div>\n\n<p><code class=\"language-plaintext highlighter-rouge\">generic/centos8</code> is the RHEL-family stand-in that was measured here. Swap in Rocky when you have a libvirt box cached; the networking story does not change.</p>\n\n<p>Vagrant’s ChildProcess likes to leave pipes on descriptors ≥ 3. <code class=\"language-plaintext highlighter-rouge\">socket_vmnet_client</code> needs a clean fd 3 for the lab NIC. The host wrapper closes everything above stderr, then execs the client onto the real QEMU binary:</p>\n\n<div data-file=\"bin/qemu-system-x86_64\" class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"c\">#!/bin/bash</span>\n<span class=\"nb\">set</span> <span class=\"nt\">-euo</span> pipefail\n<span class=\"nv\">REAL_QEMU</span><span class=\"o\">=</span><span class=\"s2\">\"</span><span class=\"k\">${</span><span class=\"nv\">REAL_QEMU</span><span class=\"k\">:-</span><span class=\"p\">/opt/homebrew/opt/qemu/bin/qemu-system-x86_64</span><span class=\"k\">}</span><span class=\"s2\">\"</span>\n<span class=\"nv\">CLIENT</span><span class=\"o\">=</span><span class=\"s2\">\"</span><span class=\"k\">${</span><span class=\"nv\">SOCKET_VMNET_CLIENT</span><span class=\"k\">:-</span><span class=\"p\">/opt/homebrew/opt/socket_vmnet/bin/socket_vmnet_client</span><span class=\"k\">}</span><span class=\"s2\">\"</span>\n<span class=\"nv\">SOCK</span><span class=\"o\">=</span><span class=\"s2\">\"</span><span class=\"k\">${</span><span class=\"nv\">SOCKET_VMNET_SOCK</span><span class=\"k\">:-</span><span class=\"p\">/opt/homebrew/var/run/socket_vmnet</span><span class=\"k\">}</span><span class=\"s2\">\"</span>\n\n<span class=\"o\">[[</span> <span class=\"nt\">-S</span> <span class=\"s2\">\"</span><span class=\"nv\">$SOCK</span><span class=\"s2\">\"</span> <span class=\"o\">]]</span> <span class=\"o\">||</span> <span class=\"o\">{</span> <span class=\"nb\">echo</span> <span class=\"s2\">\"socket_vmnet not running at </span><span class=\"nv\">$SOCK</span><span class=\"s2\">\"</span> <span class=\"o\">&gt;</span>&amp;2<span class=\"p\">;</span> <span class=\"nb\">exit </span>1<span class=\"p\">;</span> <span class=\"o\">}</span>\n\n<span class=\"k\">for </span>fd <span class=\"k\">in</span> <span class=\"si\">$(</span><span class=\"nb\">ls</span> /dev/fd 2&gt;/dev/null | <span class=\"nb\">grep</span> <span class=\"nt\">-E</span> <span class=\"s1\">'^[0-9]+$'</span> | <span class=\"nb\">sort</span> <span class=\"nt\">-n</span><span class=\"si\">)</span><span class=\"p\">;</span> <span class=\"k\">do</span>\n  <span class=\"o\">[[</span> <span class=\"s2\">\"</span><span class=\"nv\">$fd</span><span class=\"s2\">\"</span> <span class=\"nt\">-gt</span> 2 <span class=\"o\">]]</span> <span class=\"o\">&amp;&amp;</span> <span class=\"nb\">eval</span> <span class=\"s2\">\"exec </span><span class=\"k\">${</span><span class=\"nv\">fd</span><span class=\"k\">}</span><span class=\"s2\">&lt;&amp;-\"</span> 2&gt;/dev/null <span class=\"o\">||</span> <span class=\"nb\">true\n</span><span class=\"k\">done\n\n</span><span class=\"nb\">exec</span> <span class=\"s2\">\"</span><span class=\"nv\">$CLIENT</span><span class=\"s2\">\"</span> <span class=\"s2\">\"</span><span class=\"nv\">$SOCK</span><span class=\"s2\">\"</span> <span class=\"s2\">\"</span><span class=\"nv\">$REAL_QEMU</span><span class=\"s2\">\"</span> <span class=\"s2\">\"</span><span class=\"nv\">$@</span><span class=\"s2\">\"</span>\n</code></pre></div></div>\n\n<p>Point the provider at that wrapper. Do <strong>not</strong> daemonize QEMU here—forking drops the inherited lab fd. The load-bearing provider lines look like this:</p>\n\n<div data-file=\"Vagrantfile (provider excerpt)\" class=\"language-ruby highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"n\">config</span><span class=\"p\">.</span><span class=\"nf\">vm</span><span class=\"p\">.</span><span class=\"nf\">provider</span> <span class=\"s2\">\"qemu\"</span> <span class=\"k\">do</span> <span class=\"o\">|</span><span class=\"n\">qe</span><span class=\"o\">|</span>\n  <span class=\"n\">qe</span><span class=\"p\">.</span><span class=\"nf\">arch</span> <span class=\"o\">=</span> <span class=\"s2\">\"x86_64\"</span>\n  <span class=\"n\">qe</span><span class=\"p\">.</span><span class=\"nf\">machine</span> <span class=\"o\">=</span> <span class=\"s2\">\"q35\"</span>\n  <span class=\"n\">qe</span><span class=\"p\">.</span><span class=\"nf\">cpu</span> <span class=\"o\">=</span> <span class=\"s2\">\"max\"</span>\n  <span class=\"n\">qe</span><span class=\"p\">.</span><span class=\"nf\">smp</span> <span class=\"o\">=</span> <span class=\"s2\">\"2\"</span>\n  <span class=\"n\">qe</span><span class=\"p\">.</span><span class=\"nf\">memory</span> <span class=\"o\">=</span> <span class=\"s2\">\"1536\"</span>\n  <span class=\"n\">qe</span><span class=\"p\">.</span><span class=\"nf\">ssh_port</span> <span class=\"o\">=</span> <span class=\"mi\">22111</span>\n  <span class=\"n\">qe</span><span class=\"p\">.</span><span class=\"nf\">net_device</span> <span class=\"o\">=</span> <span class=\"s2\">\"virtio-net-pci\"</span>\n  <span class=\"n\">qe</span><span class=\"p\">.</span><span class=\"nf\">no_daemonize</span> <span class=\"o\">=</span> <span class=\"kp\">true</span>\n  <span class=\"n\">qe</span><span class=\"p\">.</span><span class=\"nf\">qemu_bin</span> <span class=\"o\">=</span> <span class=\"no\">File</span><span class=\"p\">.</span><span class=\"nf\">join</span><span class=\"p\">(</span><span class=\"no\">File</span><span class=\"p\">.</span><span class=\"nf\">dirname</span><span class=\"p\">(</span><span class=\"kp\">__FILE__</span><span class=\"p\">),</span> <span class=\"s2\">\"bin\"</span><span class=\"p\">,</span> <span class=\"s2\">\"qemu-system-x86_64\"</span><span class=\"p\">)</span>\n  <span class=\"n\">qe</span><span class=\"p\">.</span><span class=\"nf\">extra_qemu_args</span> <span class=\"o\">=</span> <span class=\"p\">[</span>\n    <span class=\"s2\">\"-device\"</span><span class=\"p\">,</span> <span class=\"s2\">\"virtio-net-pci,netdev=lab0,mac=52:54:00:12:00:11\"</span><span class=\"p\">,</span>\n    <span class=\"s2\">\"-netdev\"</span><span class=\"p\">,</span> <span class=\"s2\">\"socket,id=lab0,fd=3\"</span><span class=\"p\">,</span>\n  <span class=\"p\">]</span>\n<span class=\"k\">end</span>\n</code></pre></div></div>\n\n<p>A short provisioner then finds the non-SLIRP NIC (prefer the known MAC), writes a static address with netplan or NetworkManager, and records <code class=\"language-plaintext highlighter-rouge\">/etc/lab/ip</code>. That is ordinary guest plumbing; the unusual part already happened on the host.</p>\n\n<p class=\"verify\"><strong>Verify.</strong></p>\n\n<p>After <code class=\"language-plaintext highlighter-rouge\">vagrant up</code> has started QEMU:</p>\n\n<div class=\"language-console highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"gp\">$</span><span class=\"w\"> </span>lsof <span class=\"nt\">-p</span> <span class=\"s2\">\"</span><span class=\"si\">$(</span>pgrep <span class=\"nt\">-n</span> qemu-system-x86_64<span class=\"si\">)</span><span class=\"s2\">\"</span> <span class=\"nt\">-d</span> 3\n<span class=\"go\">COMMAND   PID  USER FD TYPE DEVICE ... NAME\n</span><span class=\"gp\">qemu-syst ...         3u unix ...      -&gt;</span>0x…\n</code></pre></div></div>\n\n<p>If fd 3 is a pipe or missing a peer, stop and fix the wrapper before you chase guest routes.</p>\n\n<h2 id=\"step-3--bring-the-pair-up-and-prove-the-mesh\">Step 3 — Bring the pair up and prove the mesh</h2>\n\n<p>TCG first boot is slow. Several minutes of SSH retries is normal, not a hung VM. Raise <code class=\"language-plaintext highlighter-rouge\">boot_timeout</code> (I use 900). If your shell—Cursor, VS Code, anything that injects pipes—still pollutes descriptors, close them before <code class=\"language-plaintext highlighter-rouge\">vagrant up</code>:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"o\">(</span> <span class=\"nb\">exec </span>3&lt;&amp;- 4&lt;&amp;- 5&lt;&amp;- 6&lt;&amp;- 7&lt;&amp;- 8&lt;&amp;- 9&lt;&amp;-\n  vagrant up ubuntu1 rocky1 <span class=\"nt\">--provider</span><span class=\"o\">=</span>qemu\n<span class=\"o\">)</span>\n</code></pre></div></div>\n\n<p>Bring up one Ubuntu node and one RHEL-family node first. Four nodes fit the same Vagrantfile later; two are enough to prove L2.</p>\n\n<p class=\"verify\"><strong>Verify.</strong></p>\n\n<p>From the Mac host, after provision finishes:</p>\n\n<div class=\"language-console highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"gp\">$</span><span class=\"w\"> </span>ping <span class=\"nt\">-c</span> 3 192.168.105.111\n<span class=\"go\">3 packets transmitted, 3 packets received, 0.0% packet loss\n</span><span class=\"gp\">#</span><span class=\"w\"> </span>~1–2 ms on this rig\n<span class=\"go\">\n</span><span class=\"gp\">$</span><span class=\"w\"> </span>ping <span class=\"nt\">-c</span> 3 192.168.105.121\n<span class=\"go\">3 packets transmitted, 3 packets received, 0.0% packet loss\n</span><span class=\"gp\">#</span><span class=\"w\"> </span>~2–3 ms\n</code></pre></div></div>\n\n<p class=\"verify\"><strong>Verify.</strong></p>\n\n<p>Then from inside each guest:</p>\n\n<div class=\"language-console highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"gp\">$</span><span class=\"w\"> </span>vagrant ssh ubuntu1 <span class=\"nt\">-c</span> <span class=\"s1\">'ping -c 3 192.168.105.121'</span>\n<span class=\"go\">3 packets transmitted, 3 received, 0% packet loss\n</span><span class=\"gp\">#</span><span class=\"w\"> </span>~2–4 ms\n<span class=\"go\">\n</span><span class=\"gp\">$</span><span class=\"w\"> </span>vagrant ssh rocky1 <span class=\"nt\">-c</span> <span class=\"s1\">'ping -c 3 192.168.105.111'</span>\n<span class=\"go\">3 packets transmitted, 3 received, 0% packet loss\n</span><span class=\"gp\">#</span><span class=\"w\"> </span>~1–2 ms\n</code></pre></div></div>\n\n<p>Those four checks are the gate. Guest ping to <code class=\"language-plaintext highlighter-rouge\">192.168.105.1</code> may still show <strong>100% loss</strong> while <code class=\"language-plaintext highlighter-rouge\">ip neigh</code> shows the gateway <strong>REACHABLE</strong>. On this macOS vmnet setup the gateway answers ARP and carries L2, but often ignores ICMP. Do not fail the lab on gateway ping. Fail it when peers or the host cannot reach the lab IPs.</p>\n\n<p>One more SSH gotcha under TCG: if your agent offers a long list of keys, you can burn through <code class=\"language-plaintext highlighter-rouge\">MaxAuthTries</code> before the Vagrant key is tried. Prefer <code class=\"language-plaintext highlighter-rouge\">IdentitiesOnly=yes</code> with the key Vagrant manages, or just use <code class=\"language-plaintext highlighter-rouge\">vagrant ssh</code>.</p>\n\n<h2 id=\"failure-modes\">Failure modes</h2>\n\n<table>\n  <thead>\n    <tr>\n      <th>Symptom</th>\n      <th>Cause</th>\n      <th>Repair</th>\n    </tr>\n  </thead>\n  <tbody>\n    <tr>\n      <td>Lab IPs look plausible but host/peers cannot reach them</td>\n      <td>Lab subnet equals Wi‑Fi (<code class=\"language-plaintext highlighter-rouge\">192.168.1.0/24</code> here)</td>\n      <td>Move lab to an unused /24; change daemon + Vagrant IPs together</td>\n    </tr>\n    <tr>\n      <td>eth1 TX rises, RX stays 0</td>\n      <td>fd 3 is not the vmnet client pipe, or QEMU used <code class=\"language-plaintext highlighter-rouge\">stream</code></td>\n      <td>Wrapper + <code class=\"language-plaintext highlighter-rouge\">socket_vmnet_client</code>; confirm <code class=\"language-plaintext highlighter-rouge\">lsof … -d 3</code> is unix</td>\n    </tr>\n    <tr>\n      <td>socket_vmnet vanishes mid-boot</td>\n      <td>One-shot daemon</td>\n      <td>LaunchDaemon with <code class=\"language-plaintext highlighter-rouge\">KeepAlive</code></td>\n    </tr>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">Too many authentication failures</code></td>\n      <td>Agent offers many keys while TCG is slow</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">IdentitiesOnly=yes</code> / <code class=\"language-plaintext highlighter-rouge\">vagrant ssh</code></td>\n    </tr>\n    <tr>\n      <td>First boot “hangs” for ages</td>\n      <td>x86_64 under TCG</td>\n      <td>Raise <code class=\"language-plaintext highlighter-rouge\">boot_timeout</code>; wait; watch SSH retries, not wall-clock panic</td>\n    </tr>\n  </tbody>\n</table>\n\n<h2 id=\"clean-up-and-the-rule-i-will-keep-using\">Clean up and the rule I will keep using</h2>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>vagrant halt ubuntu1 rocky1\n<span class=\"c\"># or: vagrant destroy -f ubuntu1 rocky1</span>\n</code></pre></div></div>\n\n<p>Leave <code class=\"language-plaintext highlighter-rouge\">lab.socket_vmnet</code> installed while you are iterating. Stop it only when you want <code class=\"language-plaintext highlighter-rouge\">bridge100</code> gone.</p>\n\n<p>The operating rule for the Postgres 18+ / Patroni write-ups that sit on top of this Mac: <strong>amd64 guests, a dedicated lab subnet, socket_vmnet_client on fd 3, and a green host-plus-mesh check before a single package install.</strong> Architecture fidelity without L2 fidelity is just a slow container with extra ceremony. Get the fabric honest first; then the database work can mean what it says.</p>\n",
      "summary": "Apple Silicon labs that boot still fail host and mesh ping when you use aarch64 boxes or park the VMs on your Wi‑Fi subnet.",
      "date_published": "2026-08-13T10:00:00+05:45",
      "date_modified": "2026-08-13T00:00:00+05:45",
      "tags": ["self-hosted","postgres"],
      "authors": [
        {
          "name": "Basant Bhattarai",
          "url": "https://basantbhattarai.com.np"
        }
      ]
    },
    {
      "id": "https://basantbhattarai.com.np/writing/building-data-platforms-and-ai-products/",
      "url": "https://basantbhattarai.com.np/writing/building-data-platforms-and-ai-products/",
      "title": "The data platform is part of the product",
      "content_html": "<p>I used to draw data platforms underneath the product: neat boxes for ingestion, storage, transformation, and serving, with the “real” application sitting safely above them. The diagram was tidy and the boundary was wrong.</p>\n\n<p>A user does not experience those boxes. They experience one answer. If a source arrives late, a transformation accepts an impossible value, a cache serves an old projection, and an AI workflow confidently acts on it, every individual service can be green while the product is wrong.</p>\n\n<p>That changed the question I ask in design reviews. I no longer begin with, “What should process this data?” I begin with, “What promise is the product making, and how will we repair that promise when one layer lies?”</p>\n\n<p>This is the operating model I now use. It is intentionally independent of any employer architecture or vendor stack.</p>\n\n<h2 id=\"start-with-the-decision-window-not-the-job\">Start with the decision window, not the job</h2>\n\n<p>“The pipeline runs every hour” is a schedule, not a reliability promise. The consumer cares whether the expected fact is queryable before a decision becomes stale.</p>\n\n<p>For each product-facing dataset or endpoint, I write down five things:</p>\n\n<ol>\n  <li><strong>The fact:</strong> the smallest record the consumer actually needs.</li>\n  <li><strong>The clock:</strong> event time, processing time, or the time a decision must be made.</li>\n  <li><strong>The tolerance:</strong> how late, incomplete, or approximate the fact may be.</li>\n  <li><strong>The failure state:</strong> what the product shows when the promise cannot be met.</li>\n  <li><strong>The repair owner:</strong> who can replay, correct, or suppress the result safely.</li>\n</ol>\n\n<p>That short contract changes the implementation. Freshness becomes a property of a published fact rather than a scheduler. Quarantine becomes a visible state instead of a forgotten dead-letter queue. A backfill becomes part of the interface, because a correction that never reaches the product is not a correction.</p>\n\n<p>I also separate <em>availability</em> from <em>fitness</em>. A service can answer every request and still serve a stale or semantically invalid record. For data products, I want at least these signals beside ordinary uptime:</p>\n\n<table>\n  <thead>\n    <tr>\n      <th>Signal</th>\n      <th>Question it answers</th>\n    </tr>\n  </thead>\n  <tbody>\n    <tr>\n      <td>Freshness</td>\n      <td>Is the newest expected fact available inside its decision window?</td>\n    </tr>\n    <tr>\n      <td>Completeness</td>\n      <td>Did the expected population arrive, not merely some records?</td>\n    </tr>\n    <tr>\n      <td>Validity</td>\n      <td>Do values satisfy the contract at the publication boundary?</td>\n    </tr>\n    <tr>\n      <td>Decision latency</td>\n      <td>How long from source event to a product action?</td>\n    </tr>\n    <tr>\n      <td>Repair time</td>\n      <td>How long from detection to a corrected product state?</td>\n    </tr>\n  </tbody>\n</table>\n\n<h2 id=\"make-the-boundary-a-record-not-a-feeling\">Make the boundary a record, not a feeling</h2>\n\n<p>Whenever automated output can change another workflow, I persist a decision record before I persist the effect. The record needs enough information to explain what happened without reconstructing it from logs scattered across services.</p>\n\n<p>This small standard-library example is deliberately runnable. It validates confidence, requires source references, fixes the set of allowed outcomes, and emits a JSON-ready record. The type hints help the editor; the runtime checks protect the boundary.</p>\n\n<div data-file=\"contracts/decision_record.py\" class=\"language-python highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"kn\">from</span> <span class=\"n\">__future__</span> <span class=\"kn\">import</span> <span class=\"n\">annotations</span>\n\n<span class=\"kn\">from</span> <span class=\"n\">dataclasses</span> <span class=\"kn\">import</span> <span class=\"n\">asdict</span><span class=\"p\">,</span> <span class=\"n\">dataclass</span>\n<span class=\"kn\">from</span> <span class=\"n\">datetime</span> <span class=\"kn\">import</span> <span class=\"n\">UTC</span><span class=\"p\">,</span> <span class=\"n\">datetime</span>\n<span class=\"kn\">from</span> <span class=\"n\">typing</span> <span class=\"kn\">import</span> <span class=\"n\">Literal</span>\n\n<span class=\"n\">Outcome</span> <span class=\"o\">=</span> <span class=\"n\">Literal</span><span class=\"p\">[</span><span class=\"sh\">\"</span><span class=\"s\">approve</span><span class=\"sh\">\"</span><span class=\"p\">,</span> <span class=\"sh\">\"</span><span class=\"s\">reject</span><span class=\"sh\">\"</span><span class=\"p\">,</span> <span class=\"sh\">\"</span><span class=\"s\">needs_review</span><span class=\"sh\">\"</span><span class=\"p\">]</span>\n<span class=\"n\">ALLOWED_OUTCOMES</span> <span class=\"o\">=</span> <span class=\"p\">{</span><span class=\"sh\">\"</span><span class=\"s\">approve</span><span class=\"sh\">\"</span><span class=\"p\">,</span> <span class=\"sh\">\"</span><span class=\"s\">reject</span><span class=\"sh\">\"</span><span class=\"p\">,</span> <span class=\"sh\">\"</span><span class=\"s\">needs_review</span><span class=\"sh\">\"</span><span class=\"p\">}</span>\n\n\n<span class=\"nd\">@dataclass</span><span class=\"p\">(</span><span class=\"n\">frozen</span><span class=\"o\">=</span><span class=\"bp\">True</span><span class=\"p\">,</span> <span class=\"n\">slots</span><span class=\"o\">=</span><span class=\"bp\">True</span><span class=\"p\">)</span>\n<span class=\"k\">class</span> <span class=\"nc\">DecisionRecord</span><span class=\"p\">:</span>\n    <span class=\"n\">subject_id</span><span class=\"p\">:</span> <span class=\"nb\">str</span>\n    <span class=\"n\">outcome</span><span class=\"p\">:</span> <span class=\"n\">Outcome</span>\n    <span class=\"n\">confidence</span><span class=\"p\">:</span> <span class=\"nb\">float</span>\n    <span class=\"n\">policy_version</span><span class=\"p\">:</span> <span class=\"nb\">str</span>\n    <span class=\"n\">source_refs</span><span class=\"p\">:</span> <span class=\"nb\">tuple</span><span class=\"p\">[</span><span class=\"nb\">str</span><span class=\"p\">,</span> <span class=\"p\">...]</span>\n    <span class=\"n\">created_at</span><span class=\"p\">:</span> <span class=\"n\">datetime</span>\n\n    <span class=\"k\">def</span> <span class=\"nf\">__post_init__</span><span class=\"p\">(</span><span class=\"n\">self</span><span class=\"p\">)</span> <span class=\"o\">-&gt;</span> <span class=\"bp\">None</span><span class=\"p\">:</span>\n        <span class=\"k\">if</span> <span class=\"ow\">not</span> <span class=\"n\">self</span><span class=\"p\">.</span><span class=\"n\">subject_id</span><span class=\"p\">.</span><span class=\"nf\">strip</span><span class=\"p\">():</span>\n            <span class=\"k\">raise</span> <span class=\"nc\">ValueError</span><span class=\"p\">(</span><span class=\"sh\">\"</span><span class=\"s\">subject_id must not be blank</span><span class=\"sh\">\"</span><span class=\"p\">)</span>\n        <span class=\"k\">if</span> <span class=\"n\">self</span><span class=\"p\">.</span><span class=\"n\">outcome</span> <span class=\"ow\">not</span> <span class=\"ow\">in</span> <span class=\"n\">ALLOWED_OUTCOMES</span><span class=\"p\">:</span>\n            <span class=\"k\">raise</span> <span class=\"nc\">ValueError</span><span class=\"p\">(</span><span class=\"sa\">f</span><span class=\"sh\">\"</span><span class=\"s\">unsupported outcome: </span><span class=\"si\">{</span><span class=\"n\">self</span><span class=\"p\">.</span><span class=\"n\">outcome</span><span class=\"si\">}</span><span class=\"sh\">\"</span><span class=\"p\">)</span>\n        <span class=\"k\">if</span> <span class=\"ow\">not</span> <span class=\"mf\">0.0</span> <span class=\"o\">&lt;=</span> <span class=\"n\">self</span><span class=\"p\">.</span><span class=\"n\">confidence</span> <span class=\"o\">&lt;=</span> <span class=\"mf\">1.0</span><span class=\"p\">:</span>\n            <span class=\"k\">raise</span> <span class=\"nc\">ValueError</span><span class=\"p\">(</span><span class=\"sh\">\"</span><span class=\"s\">confidence must be between 0 and 1</span><span class=\"sh\">\"</span><span class=\"p\">)</span>\n        <span class=\"k\">if</span> <span class=\"ow\">not</span> <span class=\"n\">self</span><span class=\"p\">.</span><span class=\"n\">policy_version</span><span class=\"p\">.</span><span class=\"nf\">strip</span><span class=\"p\">():</span>\n            <span class=\"k\">raise</span> <span class=\"nc\">ValueError</span><span class=\"p\">(</span><span class=\"sh\">\"</span><span class=\"s\">policy_version must not be blank</span><span class=\"sh\">\"</span><span class=\"p\">)</span>\n        <span class=\"k\">if</span> <span class=\"ow\">not</span> <span class=\"n\">self</span><span class=\"p\">.</span><span class=\"n\">source_refs</span> <span class=\"ow\">or</span> <span class=\"nf\">any</span><span class=\"p\">(</span><span class=\"ow\">not</span> <span class=\"n\">ref</span><span class=\"p\">.</span><span class=\"nf\">strip</span><span class=\"p\">()</span> <span class=\"k\">for</span> <span class=\"n\">ref</span> <span class=\"ow\">in</span> <span class=\"n\">self</span><span class=\"p\">.</span><span class=\"n\">source_refs</span><span class=\"p\">):</span>\n            <span class=\"k\">raise</span> <span class=\"nc\">ValueError</span><span class=\"p\">(</span><span class=\"sh\">\"</span><span class=\"s\">at least one non-blank source reference is required</span><span class=\"sh\">\"</span><span class=\"p\">)</span>\n        <span class=\"k\">if</span> <span class=\"n\">self</span><span class=\"p\">.</span><span class=\"n\">created_at</span><span class=\"p\">.</span><span class=\"n\">tzinfo</span> <span class=\"ow\">is</span> <span class=\"bp\">None</span><span class=\"p\">:</span>\n            <span class=\"k\">raise</span> <span class=\"nc\">ValueError</span><span class=\"p\">(</span><span class=\"sh\">\"</span><span class=\"s\">created_at must include a timezone</span><span class=\"sh\">\"</span><span class=\"p\">)</span>\n\n    <span class=\"k\">def</span> <span class=\"nf\">to_dict</span><span class=\"p\">(</span><span class=\"n\">self</span><span class=\"p\">)</span> <span class=\"o\">-&gt;</span> <span class=\"nb\">dict</span><span class=\"p\">[</span><span class=\"nb\">str</span><span class=\"p\">,</span> <span class=\"nb\">object</span><span class=\"p\">]:</span>\n        <span class=\"n\">payload</span> <span class=\"o\">=</span> <span class=\"nf\">asdict</span><span class=\"p\">(</span><span class=\"n\">self</span><span class=\"p\">)</span>\n        <span class=\"n\">payload</span><span class=\"p\">[</span><span class=\"sh\">\"</span><span class=\"s\">source_refs</span><span class=\"sh\">\"</span><span class=\"p\">]</span> <span class=\"o\">=</span> <span class=\"nf\">list</span><span class=\"p\">(</span><span class=\"n\">self</span><span class=\"p\">.</span><span class=\"n\">source_refs</span><span class=\"p\">)</span>\n        <span class=\"n\">payload</span><span class=\"p\">[</span><span class=\"sh\">\"</span><span class=\"s\">created_at</span><span class=\"sh\">\"</span><span class=\"p\">]</span> <span class=\"o\">=</span> <span class=\"n\">self</span><span class=\"p\">.</span><span class=\"n\">created_at</span><span class=\"p\">.</span><span class=\"nf\">isoformat</span><span class=\"p\">()</span>\n        <span class=\"k\">return</span> <span class=\"n\">payload</span>\n\n\n<span class=\"n\">record</span> <span class=\"o\">=</span> <span class=\"nc\">DecisionRecord</span><span class=\"p\">(</span>\n    <span class=\"n\">subject_id</span><span class=\"o\">=</span><span class=\"sh\">\"</span><span class=\"s\">case-1042</span><span class=\"sh\">\"</span><span class=\"p\">,</span>\n    <span class=\"n\">outcome</span><span class=\"o\">=</span><span class=\"sh\">\"</span><span class=\"s\">needs_review</span><span class=\"sh\">\"</span><span class=\"p\">,</span>\n    <span class=\"n\">confidence</span><span class=\"o\">=</span><span class=\"mf\">0.71</span><span class=\"p\">,</span>\n    <span class=\"n\">policy_version</span><span class=\"o\">=</span><span class=\"sh\">\"</span><span class=\"s\">risk-policy-2026-08</span><span class=\"sh\">\"</span><span class=\"p\">,</span>\n    <span class=\"n\">source_refs</span><span class=\"o\">=</span><span class=\"p\">(</span><span class=\"sh\">\"</span><span class=\"s\">snapshot:6b8f</span><span class=\"sh\">\"</span><span class=\"p\">,</span> <span class=\"sh\">\"</span><span class=\"s\">rule:missing-history</span><span class=\"sh\">\"</span><span class=\"p\">),</span>\n    <span class=\"n\">created_at</span><span class=\"o\">=</span><span class=\"n\">datetime</span><span class=\"p\">.</span><span class=\"nf\">now</span><span class=\"p\">(</span><span class=\"n\">UTC</span><span class=\"p\">),</span>\n<span class=\"p\">)</span>\n\n<span class=\"nf\">print</span><span class=\"p\">(</span><span class=\"n\">record</span><span class=\"p\">.</span><span class=\"n\">outcome</span><span class=\"p\">)</span>\n<span class=\"nf\">print</span><span class=\"p\">(</span><span class=\"n\">record</span><span class=\"p\">.</span><span class=\"nf\">to_dict</span><span class=\"p\">()[</span><span class=\"sh\">\"</span><span class=\"s\">policy_version</span><span class=\"sh\">\"</span><span class=\"p\">])</span>\n</code></pre></div></div>\n\n<p>Running the file should produce the outcome and policy version:</p>\n\n<div class=\"language-console highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"gp\">$</span><span class=\"w\"> </span>python3 contracts/decision_record.py\n<span class=\"go\">needs_review\nrisk-policy-2026-08\n</span></code></pre></div></div>\n\n<p>The database should defend the same boundary. Application validation gives a useful error close to the producer; database constraints protect every write path, including scripts and future services.</p>\n\n<div data-file=\"migrations/001_decision_records.sql\" class=\"language-sql highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"k\">CREATE</span> <span class=\"k\">TABLE</span> <span class=\"n\">decision_records</span> <span class=\"p\">(</span>\n    <span class=\"n\">decision_id</span>      <span class=\"nb\">bigint</span> <span class=\"k\">GENERATED</span> <span class=\"n\">ALWAYS</span> <span class=\"k\">AS</span> <span class=\"k\">IDENTITY</span> <span class=\"k\">PRIMARY</span> <span class=\"k\">KEY</span><span class=\"p\">,</span>\n    <span class=\"n\">idempotency_key</span>  <span class=\"nb\">text</span>        <span class=\"k\">NOT</span> <span class=\"k\">NULL</span> <span class=\"k\">UNIQUE</span><span class=\"p\">,</span>\n    <span class=\"n\">subject_id</span>       <span class=\"nb\">text</span>        <span class=\"k\">NOT</span> <span class=\"k\">NULL</span> <span class=\"k\">CHECK</span> <span class=\"p\">(</span><span class=\"n\">btrim</span><span class=\"p\">(</span><span class=\"n\">subject_id</span><span class=\"p\">)</span> <span class=\"o\">&lt;&gt;</span> <span class=\"s1\">''</span><span class=\"p\">),</span>\n    <span class=\"n\">outcome</span>          <span class=\"nb\">text</span>        <span class=\"k\">NOT</span> <span class=\"k\">NULL</span>\n                     <span class=\"k\">CHECK</span> <span class=\"p\">(</span><span class=\"n\">outcome</span> <span class=\"k\">IN</span> <span class=\"p\">(</span><span class=\"s1\">'approve'</span><span class=\"p\">,</span> <span class=\"s1\">'reject'</span><span class=\"p\">,</span> <span class=\"s1\">'needs_review'</span><span class=\"p\">)),</span>\n    <span class=\"n\">confidence</span>       <span class=\"nb\">numeric</span><span class=\"p\">(</span><span class=\"mi\">5</span><span class=\"p\">,</span><span class=\"mi\">4</span><span class=\"p\">)</span> <span class=\"k\">NOT</span> <span class=\"k\">NULL</span>\n                     <span class=\"k\">CHECK</span> <span class=\"p\">(</span><span class=\"n\">confidence</span> <span class=\"k\">BETWEEN</span> <span class=\"mi\">0</span> <span class=\"k\">AND</span> <span class=\"mi\">1</span><span class=\"p\">),</span>\n    <span class=\"n\">policy_version</span>   <span class=\"nb\">text</span>        <span class=\"k\">NOT</span> <span class=\"k\">NULL</span> <span class=\"k\">CHECK</span> <span class=\"p\">(</span><span class=\"n\">btrim</span><span class=\"p\">(</span><span class=\"n\">policy_version</span><span class=\"p\">)</span> <span class=\"o\">&lt;&gt;</span> <span class=\"s1\">''</span><span class=\"p\">),</span>\n    <span class=\"n\">source_refs</span>      <span class=\"n\">jsonb</span>       <span class=\"k\">NOT</span> <span class=\"k\">NULL</span>\n                     <span class=\"k\">CHECK</span> <span class=\"p\">(</span><span class=\"n\">jsonb_typeof</span><span class=\"p\">(</span><span class=\"n\">source_refs</span><span class=\"p\">)</span> <span class=\"o\">=</span> <span class=\"s1\">'array'</span>\n                            <span class=\"k\">AND</span> <span class=\"n\">jsonb_array_length</span><span class=\"p\">(</span><span class=\"n\">source_refs</span><span class=\"p\">)</span> <span class=\"o\">&gt;</span> <span class=\"mi\">0</span><span class=\"p\">),</span>\n    <span class=\"n\">created_at</span>       <span class=\"n\">timestamptz</span> <span class=\"k\">NOT</span> <span class=\"k\">NULL</span><span class=\"p\">,</span>\n    <span class=\"n\">recorded_at</span>      <span class=\"n\">timestamptz</span> <span class=\"k\">NOT</span> <span class=\"k\">NULL</span> <span class=\"k\">DEFAULT</span> <span class=\"n\">now</span><span class=\"p\">()</span>\n<span class=\"p\">);</span>\n\n<span class=\"k\">CREATE</span> <span class=\"k\">INDEX</span> <span class=\"n\">decision_records_subject_time_idx</span>\n    <span class=\"k\">ON</span> <span class=\"n\">decision_records</span> <span class=\"p\">(</span><span class=\"n\">subject_id</span><span class=\"p\">,</span> <span class=\"n\">created_at</span> <span class=\"k\">DESC</span><span class=\"p\">);</span>\n</code></pre></div></div>\n\n<p>Notice what is absent: a raw prompt as the only evidence, a free-form verdict, and an overwritten “current answer.” Those make a demo easy and a correction almost impossible.</p>\n\n<h2 id=\"keep-truth-separate-from-projections\">Keep truth separate from projections</h2>\n\n<p>Product reads often need a shape that the source of truth should not have. A low-latency endpoint may want one precomputed row per subject; an audit may need the full history; an analyst may need a columnar projection. Those are different read paths, not competing definitions of truth.</p>\n\n<p>I use three layers of responsibility:</p>\n\n<ul>\n  <li><strong>Source records</strong> preserve the facts received from producers, including event time and a stable source identifier.</li>\n  <li><strong>Decision records</strong> preserve what a policy or model concluded, the version that concluded it, and the evidence it used.</li>\n  <li><strong>Projections</strong> arrange those facts for a particular read path and may be rebuilt.</li>\n</ul>\n\n<p>The third layer is disposable by design. If a projection is corrupted or its model changes, I should be able to rebuild it from the first two layers without inventing history. That repair path is more valuable than choosing a fashionable serving engine.</p>\n\n<p>This distinction also makes corrections honest. A new policy should append a new decision; it should not mutate the old one until the audit trail agrees with the present. The product can point to the active decision while the history explains how it got there.</p>\n\n<h2 id=\"treat-ai-as-an-unreliable-producer\">Treat AI as an unreliable producer</h2>\n\n<p>AI does not need a separate philosophy of reliability. It needs the same producer boundary as any source we do not fully control, with stricter attention to provenance and evaluation.</p>\n\n<p>I model the workflow as explicit states:</p>\n\n<div data-file=\"docs/decision-state-machine.txt\" class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>proposed -&gt; validated -&gt; applied\n    |           |\n    |           +-&gt; needs_review\n    +--------------&gt; rejected\n</code></pre></div></div>\n\n<p>“Proposed” is important. Model output has not become a product fact merely because inference returned successfully. It still has to pass structural validation, policy checks, evidence requirements, and—where the consequence justifies it—human review.</p>\n\n<p>Confidence is not a universal truth meter either. A threshold only has meaning against an evaluated task, versioned data, and a defined cost for false approval versus false rejection. If those are missing, <code class=\"language-plaintext highlighter-rouge\">0.93</code> is decoration.</p>\n\n<p>For every automated decision, I want to answer:</p>\n\n<ul>\n  <li>Which model, policy, and tool versions ran?</li>\n  <li>Which source records were visible?</li>\n  <li>Which checks passed, failed, or were skipped?</li>\n  <li>Was a human review required, and who completed it?</li>\n  <li>Can the effect be reversed without deleting the original record?</li>\n</ul>\n\n<p>If the system cannot answer those questions, it is not ready to make a consequential change unattended.</p>\n\n<h2 id=\"design-replay-and-idempotency-before-launch\">Design replay and idempotency before launch</h2>\n\n<p>Retries happen everywhere: queues redeliver, clients time out after a successful write, operators replay a date range, and backfills overlap live traffic. “Exactly once” is often a property claimed by one component while the business effect crosses several.</p>\n\n<p>I prefer an idempotency key derived from the stable inputs to the decision, such as:</p>\n\n<div data-file=\"contracts/idempotency.py\" class=\"language-python highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"kn\">from</span> <span class=\"n\">hashlib</span> <span class=\"kn\">import</span> <span class=\"n\">sha256</span>\n\n\n<span class=\"k\">def</span> <span class=\"nf\">decision_key</span><span class=\"p\">(</span>\n    <span class=\"n\">subject_id</span><span class=\"p\">:</span> <span class=\"nb\">str</span><span class=\"p\">,</span>\n    <span class=\"n\">policy_version</span><span class=\"p\">:</span> <span class=\"nb\">str</span><span class=\"p\">,</span>\n    <span class=\"n\">source_snapshot</span><span class=\"p\">:</span> <span class=\"nb\">str</span><span class=\"p\">,</span>\n<span class=\"p\">)</span> <span class=\"o\">-&gt;</span> <span class=\"nb\">str</span><span class=\"p\">:</span>\n    <span class=\"n\">canonical</span> <span class=\"o\">=</span> <span class=\"sh\">\"</span><span class=\"se\">\\x1f</span><span class=\"sh\">\"</span><span class=\"p\">.</span><span class=\"nf\">join</span><span class=\"p\">((</span><span class=\"n\">subject_id</span><span class=\"p\">,</span> <span class=\"n\">policy_version</span><span class=\"p\">,</span> <span class=\"n\">source_snapshot</span><span class=\"p\">))</span>\n    <span class=\"k\">return</span> <span class=\"nf\">sha256</span><span class=\"p\">(</span><span class=\"n\">canonical</span><span class=\"p\">.</span><span class=\"nf\">encode</span><span class=\"p\">(</span><span class=\"sh\">\"</span><span class=\"s\">utf-8</span><span class=\"sh\">\"</span><span class=\"p\">)).</span><span class=\"nf\">hexdigest</span><span class=\"p\">()</span>\n</code></pre></div></div>\n\n<p>The write path then treats a duplicate as a known result, not a second decision:</p>\n\n<div data-file=\"queries/insert_decision.sql\" class=\"language-sql highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"k\">INSERT</span> <span class=\"k\">INTO</span> <span class=\"n\">decision_records</span> <span class=\"p\">(</span>\n    <span class=\"n\">idempotency_key</span><span class=\"p\">,</span>\n    <span class=\"n\">subject_id</span><span class=\"p\">,</span>\n    <span class=\"n\">outcome</span><span class=\"p\">,</span>\n    <span class=\"n\">confidence</span><span class=\"p\">,</span>\n    <span class=\"n\">policy_version</span><span class=\"p\">,</span>\n    <span class=\"n\">source_refs</span><span class=\"p\">,</span>\n    <span class=\"n\">created_at</span>\n<span class=\"p\">)</span>\n<span class=\"k\">VALUES</span> <span class=\"p\">(</span>\n    <span class=\"p\">:</span><span class=\"n\">idempotency_key</span><span class=\"p\">,</span>\n    <span class=\"p\">:</span><span class=\"n\">subject_id</span><span class=\"p\">,</span>\n    <span class=\"p\">:</span><span class=\"n\">outcome</span><span class=\"p\">,</span>\n    <span class=\"p\">:</span><span class=\"n\">confidence</span><span class=\"p\">,</span>\n    <span class=\"p\">:</span><span class=\"n\">policy_version</span><span class=\"p\">,</span>\n    <span class=\"k\">CAST</span><span class=\"p\">(:</span><span class=\"n\">source_refs</span> <span class=\"k\">AS</span> <span class=\"n\">jsonb</span><span class=\"p\">),</span>\n    <span class=\"p\">:</span><span class=\"n\">created_at</span>\n<span class=\"p\">)</span>\n<span class=\"k\">ON</span> <span class=\"n\">CONFLICT</span> <span class=\"p\">(</span><span class=\"n\">idempotency_key</span><span class=\"p\">)</span> <span class=\"k\">DO</span> <span class=\"k\">NOTHING</span>\n<span class=\"n\">RETURNING</span> <span class=\"n\">decision_id</span><span class=\"p\">;</span>\n</code></pre></div></div>\n\n<p>There is a trade-off: the key encodes what I consider the identity of a decision. Changing that definition is a contract change. I document it and version the policy rather than hiding it in a helper function.</p>\n\n<p>Replay needs a product rule too. When a corrected source record produces a different outcome, does the system apply it automatically, open a review, or leave the prior effect in place? That answer depends on consequence, not throughput. The platform can provide the mechanism; the product must own the policy.</p>\n\n<h2 id=\"review-the-seams-as-one-operating-model\">Review the seams as one operating model</h2>\n\n<p>End-to-end ownership does not mean one person builds every service. It means the seams share one definition of correctness and one repair story.</p>\n\n<p>This is the checklist I use before a data-backed or AI-assisted workflow ships:</p>\n\n<ul class=\"checklist\">\n  <li>The consumer-facing fact and decision window are written down.</li>\n  <li>Missing, late, duplicate, malformed, and contradictory inputs have named states.</li>\n  <li>Automated output crosses typed validation before it can create an effect.</li>\n  <li>The decision record includes policy version, evidence references, and time.</li>\n  <li>The write path is idempotent under retry and replay.</li>\n  <li>Projections can be rebuilt without rewriting source or decision history.</li>\n  <li>Alerts point to a safe action, not merely a dashboard.</li>\n  <li>A corrected decision can reach the product without erasing the old one.</li>\n  <li>A human can stop or review the workflow when the consequence demands it.</li>\n</ul>\n\n<p>The principle underneath all of this is modest: the platform is part of the product because it decides which facts the product may trust. Once that is explicit, architecture discussions get less theatrical. The useful questions become concrete: What is the promise? Where is it recorded? How does it fail? Who can repair it? And can they do so while the rest of the system keeps moving?</p>\n",
      "summary": "A practical operating model for reliable data and AI products: consumer-facing promises, typed decisions, idempotency, provenance, and repair.",
      "date_published": "2026-08-11T10:00:00+05:45",
      "date_modified": "2026-08-11T16:00:00+05:45",
      "tags": ["ai-agents","data-quality"],
      "authors": [
        {
          "name": "Basant Bhattarai",
          "url": "https://basantbhattarai.com.np"
        }
      ]
    },
    {
      "id": "https://basantbhattarai.com.np/writing/postgresql-18-docker-volume-path-change/",
      "url": "https://basantbhattarai.com.np/writing/postgresql-18-docker-volume-path-change/",
      "title": "PostgreSQL 18 won't take a volume at /var/lib/postgresql/data",
      "content_html": "<h2 id=\"the-line-that-breaks-every-copied-compose-file\">The line that breaks every copied compose file</h2>\n\n<p>There is one line that appears in every PostgreSQL docker-compose file written between 2017 and 2025:</p>\n\n<div class=\"language-yaml highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"na\">volumes</span><span class=\"pi\">:</span>\n  <span class=\"pi\">-</span> <span class=\"s\">pgdata:/var/lib/postgresql/data</span>\n</code></pre></div></div>\n\n<p>On <code class=\"language-plaintext highlighter-rouge\">postgres:18</code> it does not work. The container starts, complains about data in an unused mount or volume, and your data does not end up where you think it does.</p>\n\n<p>The image changed where it wants the volume. In 12 through 17 the data directory <em>was</em> the mount point. In 18 the mount point is the parent, <code class=\"language-plaintext highlighter-rouge\">/var/lib/postgresql</code>, and the cluster lives in an <code class=\"language-plaintext highlighter-rouge\">18/docker</code> subdirectory beneath it. The reason is <code class=\"language-plaintext highlighter-rouge\">pg_upgrade --link</code>: an in-place major upgrade needs the old and new data directories side by side on the same filesystem, and that is impossible when the data directory is itself the mount point. Moving the mount up one level makes room for <code class=\"language-plaintext highlighter-rouge\">18/docker</code> and <code class=\"language-plaintext highlighter-rouge\">19/docker</code> to coexist during an upgrade.</p>\n\n<div class=\"language-diff highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code> volumes:\n<span class=\"gd\">-  - pgdata:/var/lib/postgresql/data\n</span><span class=\"gi\">+  - pgdata:/var/lib/postgresql\n</span></code></pre></div></div>\n\n<p>That is the whole fix, and if that is all you came for you can stop here. The rest of this post builds a primary and a streaming hot standby on top of the corrected file, measures the replication lag, promotes the standby, and sets up WAL archiving for point-in-time recovery — which is where the second half of the change bites, because a replica cloned by <code class=\"language-plaintext highlighter-rouge\">pg_basebackup</code> needs <code class=\"language-plaintext highlighter-rouge\">PGDATA</code> set explicitly.</p>\n\n<p>The compose file and the init script are reproduced inline. There is no public repository to clone.</p>\n\n<div class=\"callout callout--gotcha\">\n  <p><strong>Gotcha.</strong> The message you get contains the phrase <code class=\"language-plaintext highlighter-rouge\">data in unused mount/volume</code>, which reads like a warning about a stray volume rather than what it is — the image telling you the data directory is not where the mount is. Searching for the phrase is how most people find this; the fix is not in the message.</p>\n</div>\n\n<h2 id=\"step-1--write-the-compose-file-pg-18-accepts\">Step 1 — Write the compose file PG 18 accepts</h2>\n\n<p>Two services: a primary with the replication settings turned on, and a replica that clones itself from the primary on first boot and then streams.</p>\n\n<div data-file=\"pg-replication/docker-compose.yml\" class=\"language-yaml highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"na\">name</span><span class=\"pi\">:</span> <span class=\"s\">pg-replication</span>\n\n<span class=\"na\">services</span><span class=\"pi\">:</span>\n  <span class=\"na\">pgprimary</span><span class=\"pi\">:</span>\n    <span class=\"na\">image</span><span class=\"pi\">:</span> <span class=\"s\">postgres:18</span>\n    <span class=\"na\">environment</span><span class=\"pi\">:</span>\n      <span class=\"na\">POSTGRES_USER</span><span class=\"pi\">:</span> <span class=\"s\">admin</span>\n      <span class=\"na\">POSTGRES_PASSWORD</span><span class=\"pi\">:</span> <span class=\"s\">admin123</span>\n      <span class=\"na\">POSTGRES_DB</span><span class=\"pi\">:</span> <span class=\"s\">poc</span>\n    <span class=\"na\">command</span><span class=\"pi\">:</span>\n      <span class=\"pi\">-</span> <span class=\"s\">postgres</span>\n      <span class=\"pi\">-</span> <span class=\"s\">-c</span>\n      <span class=\"pi\">-</span> <span class=\"s\">wal_level=replica</span>\n      <span class=\"pi\">-</span> <span class=\"s\">-c</span>\n      <span class=\"pi\">-</span> <span class=\"s\">max_wal_senders=10</span>\n      <span class=\"pi\">-</span> <span class=\"s\">-c</span>\n      <span class=\"pi\">-</span> <span class=\"s\">max_replication_slots=10</span>\n      <span class=\"pi\">-</span> <span class=\"s\">-c</span>\n      <span class=\"pi\">-</span> <span class=\"s\">hot_standby=on</span>\n    <span class=\"na\">ports</span><span class=\"pi\">:</span> <span class=\"pi\">[</span><span class=\"s2\">\"</span><span class=\"s\">5434:5432\"</span><span class=\"pi\">]</span>\n    <span class=\"c1\"># PG18 image wants the mount at /var/lib/postgresql (data lands in an 18/docker</span>\n    <span class=\"c1\"># subdir) — NOT /var/lib/postgresql/data, which the image now rejects.</span>\n    <span class=\"na\">volumes</span><span class=\"pi\">:</span>\n      <span class=\"pi\">-</span> <span class=\"s\">pgp:/var/lib/postgresql</span>\n      <span class=\"pi\">-</span> <span class=\"s\">./primary-init.sh:/docker-entrypoint-initdb.d/primary-init.sh:ro</span>\n\n  <span class=\"na\">pgreplica</span><span class=\"pi\">:</span>\n    <span class=\"na\">image</span><span class=\"pi\">:</span> <span class=\"s\">postgres:18</span>\n    <span class=\"na\">environment</span><span class=\"pi\">:</span>\n      <span class=\"na\">PGDATA</span><span class=\"pi\">:</span> <span class=\"s\">/var/lib/postgresql/18/docker</span>\n    <span class=\"na\">depends_on</span><span class=\"pi\">:</span> <span class=\"pi\">[</span><span class=\"nv\">pgprimary</span><span class=\"pi\">]</span>\n    <span class=\"na\">ports</span><span class=\"pi\">:</span> <span class=\"pi\">[</span><span class=\"s2\">\"</span><span class=\"s\">5435:5432\"</span><span class=\"pi\">]</span>\n    <span class=\"c1\"># If the data dir is empty, clone the primary with pg_basebackup, then start as</span>\n    <span class=\"c1\"># a hot standby. Runs the clone as the postgres user so file ownership is correct.</span>\n    <span class=\"na\">command</span><span class=\"pi\">:</span>\n      <span class=\"pi\">-</span> <span class=\"s\">bash</span>\n      <span class=\"pi\">-</span> <span class=\"s\">-c</span>\n      <span class=\"pi\">-</span> <span class=\"pi\">|</span>\n        <span class=\"s\">set -e</span>\n        <span class=\"s\">if [ ! -s \"$$PGDATA/PG_VERSION\" ]; then</span>\n          <span class=\"s\">until pg_isready -h pgprimary -p 5432 -U admin -d poc; do</span>\n            <span class=\"s\">echo \"waiting for primary...\"; sleep 2;</span>\n          <span class=\"s\">done</span>\n          <span class=\"s\">export PGPASSWORD=replpass</span>\n          <span class=\"s\">gosu postgres pg_basebackup -h pgprimary -p 5432 -U replicator \\</span>\n            <span class=\"s\">-D \"$$PGDATA\" -Fp -Xs -R -P -S replica1_slot</span>\n        <span class=\"s\">fi</span>\n        <span class=\"s\">exec docker-entrypoint.sh postgres</span>\n    <span class=\"na\">volumes</span><span class=\"pi\">:</span>\n      <span class=\"pi\">-</span> <span class=\"s\">pgr:/var/lib/postgresql</span>\n\n<span class=\"na\">volumes</span><span class=\"pi\">:</span>\n  <span class=\"na\">pgp</span><span class=\"pi\">:</span>\n  <span class=\"na\">pgr</span><span class=\"pi\">:</span>\n</code></pre></div></div>\n\n<p>Four details in there are load-bearing.</p>\n\n<p><strong><code class=\"language-plaintext highlighter-rouge\">PGDATA: /var/lib/postgresql/18/docker</code> on the replica.</strong> The primary gets this path from the entrypoint’s own default because it runs <code class=\"language-plaintext highlighter-rouge\">initdb</code>. The replica does not run <code class=\"language-plaintext highlighter-rouge\">initdb</code> — it runs <code class=\"language-plaintext highlighter-rouge\">pg_basebackup</code>, and <code class=\"language-plaintext highlighter-rouge\">pg_basebackup</code> writes wherever <code class=\"language-plaintext highlighter-rouge\">-D</code> points. Without <code class=\"language-plaintext highlighter-rouge\">PGDATA</code> set, the clone lands in the wrong place and the server then starts against an empty directory. This is the same mount-point change, showing up a second time in a shape that does not mention mounts at all.</p>\n\n<p><strong><code class=\"language-plaintext highlighter-rouge\">$$PGDATA</code>, with two dollar signs.</strong> Compose does variable interpolation on the file before Docker ever sees it. <code class=\"language-plaintext highlighter-rouge\">$$</code> escapes to a literal <code class=\"language-plaintext highlighter-rouge\">$</code>, so the shell inside the container gets <code class=\"language-plaintext highlighter-rouge\">$PGDATA</code>. A single <code class=\"language-plaintext highlighter-rouge\">$</code> here means Compose substitutes an empty string and the test becomes <code class=\"language-plaintext highlighter-rouge\">[ ! -s /PG_VERSION ]</code>.</p>\n\n<p><strong><code class=\"language-plaintext highlighter-rouge\">gosu postgres</code> around <code class=\"language-plaintext highlighter-rouge\">pg_basebackup</code>.</strong> The command runs as root. A data directory full of root-owned files makes the server refuse to start, with an ownership error that gives no hint about which command created them.</p>\n\n<p><strong><code class=\"language-plaintext highlighter-rouge\">-Xs -R -S replica1_slot</code> on <code class=\"language-plaintext highlighter-rouge\">pg_basebackup</code>.</strong> <code class=\"language-plaintext highlighter-rouge\">-Xs</code> streams WAL alongside the base backup, so the backup is consistent without needing archived WAL. <code class=\"language-plaintext highlighter-rouge\">-R</code> writes <code class=\"language-plaintext highlighter-rouge\">standby.signal</code> and a <code class=\"language-plaintext highlighter-rouge\">primary_conninfo</code> line so the clone comes up as a standby with no further editing. <code class=\"language-plaintext highlighter-rouge\">-S</code> binds it to a named replication slot.</p>\n\n<p class=\"verify\"><strong>Verify.</strong> Nothing to run yet — but read the volume lines once more. Both are <code class=\"language-plaintext highlighter-rouge\">:/var/lib/postgresql</code>, not <code class=\"language-plaintext highlighter-rouge\">:/var/lib/postgresql/data</code>.</p>\n\n<h2 id=\"step-2--create-the-replication-role-and-slot-on-the-primary\">Step 2 — Create the replication role and slot on the primary</h2>\n\n<p>The entrypoint runs anything in <code class=\"language-plaintext highlighter-rouge\">/docker-entrypoint-initdb.d/</code> once, on the first boot, after <code class=\"language-plaintext highlighter-rouge\">initdb</code> and before the server is opened to the network. That is the right place to create the replication role, the slot, and the <code class=\"language-plaintext highlighter-rouge\">pg_hba.conf</code> line that lets the replica in.</p>\n\n<div data-file=\"pg-replication/primary-init.sh\" class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"c\">#!/bin/bash</span>\n<span class=\"c\"># Runs once on the primary during initdb. Creates a replication role and a slot,</span>\n<span class=\"c\"># and allows replication connections in pg_hba.conf.</span>\n<span class=\"nb\">set</span> <span class=\"nt\">-e</span>\n\npsql <span class=\"nt\">-v</span> <span class=\"nv\">ON_ERROR_STOP</span><span class=\"o\">=</span>1 <span class=\"nt\">-U</span> <span class=\"s2\">\"</span><span class=\"nv\">$POSTGRES_USER</span><span class=\"s2\">\"</span> <span class=\"nt\">-d</span> <span class=\"s2\">\"</span><span class=\"nv\">$POSTGRES_DB</span><span class=\"s2\">\"</span> <span class=\"o\">&lt;&lt;-</span><span class=\"no\">EOSQL</span><span class=\"sh\">\n  CREATE ROLE replicator WITH REPLICATION LOGIN PASSWORD 'replpass';\n  SELECT pg_create_physical_replication_slot('replica1_slot');\n</span><span class=\"no\">EOSQL\n\n</span><span class=\"c\"># Allow the replica to connect for streaming replication.</span>\n<span class=\"nb\">echo</span> <span class=\"s2\">\"host replication replicator all scram-sha-256\"</span> <span class=\"o\">&gt;&gt;</span> <span class=\"s2\">\"</span><span class=\"nv\">$PGDATA</span><span class=\"s2\">/pg_hba.conf\"</span>\n</code></pre></div></div>\n\n<p>The <code class=\"language-plaintext highlighter-rouge\">pg_hba.conf</code> line is easy to forget because <code class=\"language-plaintext highlighter-rouge\">replication</code> is a pseudo-database — a normal <code class=\"language-plaintext highlighter-rouge\">host all all ...</code> rule does not cover replication connections, so the role can log in for queries and still be refused for streaming.</p>\n\n<p>A physical replication slot makes the primary keep WAL that the replica has not consumed yet. Without one, a replica that falls far enough behind gets its WAL recycled out from under it and has to be rebuilt. With one, you get the opposite failure mode: a replica that is down forever makes the primary’s WAL grow without bound. Pick your problem knowingly and monitor for it.</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nb\">chmod</span> +x pg-replication/primary-init.sh\n</code></pre></div></div>\n\n<p class=\"verify\"><strong>Verify.</strong></p>\n\n<div class=\"language-console highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"gp\">$</span><span class=\"w\"> </span><span class=\"nb\">ls</span> <span class=\"nt\">-l</span> pg-replication/primary-init.sh\n<span class=\"go\">-rwxr-xr-x  1 basant  staff  500 Aug 11 09:00 pg-replication/primary-init.sh\n</span></code></pre></div></div>\n\n<h2 id=\"step-3--bring-up-the-pair-and-watch-the-clone\">Step 3 — Bring up the pair and watch the clone</h2>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>docker compose <span class=\"nt\">-f</span> pg-replication/docker-compose.yml up <span class=\"nt\">-d</span>\n</code></pre></div></div>\n\n<p>The replica polls <code class=\"language-plaintext highlighter-rouge\">pg_isready</code> until the primary answers, then clones. On this rig that took a few seconds; <code class=\"language-plaintext highlighter-rouge\">-P</code> prints basebackup progress into the container log while it happens.</p>\n\n<p class=\"verify\"><strong>Verify.</strong> The replica’s log walks through four states, in this order. Timestamps and LSNs are elided here — yours will differ:</p>\n\n<div class=\"language-console highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"gp\">$</span><span class=\"w\"> </span>docker compose <span class=\"nt\">-f</span> pg-replication/docker-compose.yml logs pgreplica\n<span class=\"go\">...  entering standby mode\n...  consistent recovery state reached\n...  database system is ready to accept read-only connections\n...  started streaming WAL ... on timeline 1\n</span></code></pre></div></div>\n\n<p>All four have to appear. “Entering standby mode” alone means the clone worked but streaming has not started. “Ready to accept read-only connections” without “started streaming WAL” means the standby is replaying from disk but has not connected back to the primary — usually the <code class=\"language-plaintext highlighter-rouge\">pg_hba.conf</code> line or the slot.</p>\n\n<h2 id=\"step-4--prove-replication-and-that-the-standby-refuses-writes\">Step 4 — Prove replication, and that the standby refuses writes</h2>\n\n<p>Two separate promises, and they are worth checking separately: the standby has your data, and the standby will not accept a write that would diverge from the primary.</p>\n\n<p>On the primary:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>docker <span class=\"nb\">exec</span> <span class=\"nt\">-it</span> pg-replication-pgprimary-1 psql <span class=\"nt\">-U</span> admin <span class=\"nt\">-d</span> poc\n</code></pre></div></div>\n\n<div class=\"language-sql highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"k\">CREATE</span> <span class=\"k\">TABLE</span> <span class=\"n\">t</span> <span class=\"p\">(</span><span class=\"n\">id</span> <span class=\"nb\">int</span><span class=\"p\">);</span>\n<span class=\"k\">INSERT</span> <span class=\"k\">INTO</span> <span class=\"n\">t</span> <span class=\"p\">(</span><span class=\"n\">id</span><span class=\"p\">)</span> <span class=\"k\">VALUES</span> <span class=\"p\">(</span><span class=\"mi\">1</span><span class=\"p\">);</span>\n</code></pre></div></div>\n\n<p>On the replica:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>docker <span class=\"nb\">exec</span> <span class=\"nt\">-it</span> pg-replication-pgreplica-1 psql <span class=\"nt\">-U</span> admin <span class=\"nt\">-d</span> poc\n</code></pre></div></div>\n\n<p class=\"verify\"><strong>Verify.</strong> The row is there, and the write is refused:</p>\n\n<div class=\"language-console highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"gp\">poc=#</span><span class=\"w\"> </span>SELECT <span class=\"k\">*</span> FROM t<span class=\"p\">;</span>\n<span class=\"go\"> id\n----\n  1\n(1 row)\n\n</span><span class=\"gp\">poc=#</span><span class=\"w\"> </span>INSERT INTO t <span class=\"o\">(</span><span class=\"nb\">id</span><span class=\"o\">)</span> VALUES <span class=\"o\">(</span>2<span class=\"o\">)</span><span class=\"p\">;</span>\n<span class=\"go\">ERROR:  cannot execute INSERT in a read-only transaction\n</span></code></pre></div></div>\n\n<p>That error is the standby doing its job. <code class=\"language-plaintext highlighter-rouge\">hot_standby=on</code> buys you read traffic on the replica, not a second writable node — the moment two nodes accept conflicting writes on the same timeline you have a reconciliation problem that PostgreSQL will not solve for you.</p>\n\n<h2 id=\"step-5--read-the-lag-properly\">Step 5 — Read the lag properly</h2>\n\n<p>“Replication lag” is three different numbers and people quote whichever one is smallest. <code class=\"language-plaintext highlighter-rouge\">pg_stat_replication</code> on the primary gives all of them: <code class=\"language-plaintext highlighter-rouge\">write_lag</code> is time until the standby wrote the WAL, <code class=\"language-plaintext highlighter-rouge\">flush_lag</code> until it fsynced, <code class=\"language-plaintext highlighter-rouge\">replay_lag</code> until it applied and the data became visible to a reader. <code class=\"language-plaintext highlighter-rouge\">replay_lag</code> is the one that matters for read-after-write, and it is always the largest.</p>\n\n<div class=\"language-sql highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"k\">SELECT</span> <span class=\"n\">application_name</span><span class=\"p\">,</span> <span class=\"k\">state</span><span class=\"p\">,</span> <span class=\"n\">sync_state</span><span class=\"p\">,</span> <span class=\"n\">write_lag</span><span class=\"p\">,</span> <span class=\"n\">replay_lag</span><span class=\"p\">,</span>\n       <span class=\"n\">pg_wal_lsn_diff</span><span class=\"p\">(</span><span class=\"n\">sent_lsn</span><span class=\"p\">,</span> <span class=\"n\">replay_lsn</span><span class=\"p\">)</span> <span class=\"k\">AS</span> <span class=\"n\">lag_bytes</span>\n  <span class=\"k\">FROM</span> <span class=\"n\">pg_stat_replication</span><span class=\"p\">;</span>\n</code></pre></div></div>\n\n<p class=\"verify\"><strong>Verify.</strong></p>\n\n<div class=\"language-console highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"go\"> application_name |   state   | sync_state |    write_lag    |   replay_lag    | lag_bytes\n------------------+-----------+------------+-----------------+-----------------+-----------\n walreceiver      | streaming | async      | 00:00:00.000104 | 00:00:00.000383 |         0\n(1 row)\n</span></code></pre></div></div>\n\n<p><code class=\"language-plaintext highlighter-rouge\">write_lag ≈ 104 µs</code>, <code class=\"language-plaintext highlighter-rouge\">replay_lag ≈ 383 µs</code>, and zero bytes of outstanding WAL. Both containers are on the same host with no network in between, so treat these as the protocol’s floor rather than anything you will see across a WAN — but they do establish that when production lag is measured in seconds, the cost is network and disk, not PostgreSQL.</p>\n\n<p><code class=\"language-plaintext highlighter-rouge\">sync_state</code> reads <code class=\"language-plaintext highlighter-rouge\">async</code>, which is the default and the right default for most setups: the primary does not wait for the standby to acknowledge, so a stalled standby cannot stall your writes. It also means a hard primary failure can lose the last few transactions. If that is unacceptable, <code class=\"language-plaintext highlighter-rouge\">synchronous_standby_names</code> trades write latency for that guarantee.</p>\n\n<p>Check the slot too:</p>\n\n<div class=\"language-sql highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"k\">SELECT</span> <span class=\"n\">slot_name</span><span class=\"p\">,</span> <span class=\"n\">slot_type</span><span class=\"p\">,</span> <span class=\"n\">active</span><span class=\"p\">,</span> <span class=\"n\">wal_status</span> <span class=\"k\">FROM</span> <span class=\"n\">pg_replication_slots</span><span class=\"p\">;</span>\n</code></pre></div></div>\n\n<div class=\"language-console highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"go\">   slot_name   | slot_type | active | wal_status\n---------------+-----------+--------+------------\n replica1_slot | physical  | t      | reserved\n(1 row)\n</span></code></pre></div></div>\n\n<p><code class=\"language-plaintext highlighter-rouge\">active = t</code> means a replica is attached. <code class=\"language-plaintext highlighter-rouge\">wal_status = reserved</code> means the WAL the slot needs is still within <code class=\"language-plaintext highlighter-rouge\">max_slot_wal_keep_size</code>. When that column reads <code class=\"language-plaintext highlighter-rouge\">extended</code>, <code class=\"language-plaintext highlighter-rouge\">unreserved</code> or <code class=\"language-plaintext highlighter-rouge\">lost</code>, you are on the path to either a full disk or a replica that can never catch up. It is the single best column to alert on for this topology.</p>\n\n<h2 id=\"step-6--fail-over-with-pg_promote\">Step 6 — Fail over with pg_promote()</h2>\n\n<p>There is no automatic failover here, and that is not an oversight. PostgreSQL leaves the decision to something that can tell a dead primary apart from an unreachable one — Patroni with etcd and a watchdog, or repmgr. What those tools ultimately call is this:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>docker <span class=\"nb\">exec </span>pg-replication-pgreplica-1 psql <span class=\"nt\">-U</span> admin <span class=\"nt\">-d</span> postgres <span class=\"nt\">-c</span> <span class=\"s2\">\"SELECT pg_promote();\"</span>\n</code></pre></div></div>\n\n<p class=\"verify\"><strong>Verify.</strong> Promotion returns true, recovery ends, and writes are accepted:</p>\n\n<div class=\"language-console highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"gp\">postgres=#</span><span class=\"w\"> </span>SELECT pg_promote<span class=\"o\">()</span><span class=\"p\">;</span>\n<span class=\"go\"> pg_promote\n------------\n t\n(1 row)\n\n</span><span class=\"gp\">postgres=#</span><span class=\"w\"> </span>SELECT pg_is_in_recovery<span class=\"o\">()</span><span class=\"p\">;</span>\n<span class=\"go\"> pg_is_in_recovery\n-------------------\n f\n(1 row)\n</span></code></pre></div></div>\n\n<div class=\"language-console highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"gp\">poc=#</span><span class=\"w\"> </span>INSERT INTO t <span class=\"o\">(</span><span class=\"nb\">id</span><span class=\"o\">)</span> VALUES <span class=\"o\">(</span>2<span class=\"o\">)</span><span class=\"p\">;</span>\n<span class=\"go\">INSERT 0 1\n</span></code></pre></div></div>\n\n<p>And the timeline advances:</p>\n\n<div class=\"language-console highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"gp\">poc=#</span><span class=\"w\"> </span>SELECT timeline_id FROM pg_control_checkpoint<span class=\"o\">()</span><span class=\"p\">;</span>\n<span class=\"go\"> timeline_id\n-------------\n           2\n(1 row)\n</span></code></pre></div></div>\n\n<p>That bump from 1 to 2 is the most important thing on this page after the volume path. A timeline is PostgreSQL’s record of a divergence: the new primary is now writing history that the old primary knows nothing about.</p>\n\n<div class=\"callout callout--danger\">\n  <p><strong>Do not restart the old primary and point traffic at it.</strong> After a promotion, the old primary is on timeline 1 and the new one is on timeline 2. They are two different databases that agree about the past. Bringing the old one back as a writable node is how you get split brain. It has to be rewound onto the new timeline with <code class=\"language-plaintext highlighter-rouge\">pg_rewind</code>, or rebuilt from a fresh <code class=\"language-plaintext highlighter-rouge\">pg_basebackup</code>, before it can rejoin as a standby.</p>\n</div>\n\n<h2 id=\"step-7--base-backup-and-wal-archiving-for-pitr\">Step 7 — Base backup and WAL archiving for PITR</h2>\n\n<p>Replication protects you from a node dying. It does not protect you from <code class=\"language-plaintext highlighter-rouge\">DELETE</code> without a <code class=\"language-plaintext highlighter-rouge\">WHERE</code> clause, because the standby replays that faithfully in 383 microseconds. Point-in-time recovery is the other half, and it is two pieces: a base backup, plus every WAL segment written since.</p>\n\n<p>Add archiving to the primary’s command list and give it somewhere to write:</p>\n\n<div data-file=\"pg-replication/docker-compose.yml\" class=\"language-yaml highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>    <span class=\"na\">command</span><span class=\"pi\">:</span>\n      <span class=\"pi\">-</span> <span class=\"s\">postgres</span>\n      <span class=\"pi\">-</span> <span class=\"s\">-c</span>\n      <span class=\"pi\">-</span> <span class=\"s\">wal_level=replica</span>\n      <span class=\"pi\">-</span> <span class=\"s\">-c</span>\n      <span class=\"pi\">-</span> <span class=\"s\">max_wal_senders=10</span>\n      <span class=\"pi\">-</span> <span class=\"s\">-c</span>\n      <span class=\"pi\">-</span> <span class=\"s\">max_replication_slots=10</span>\n      <span class=\"pi\">-</span> <span class=\"s\">-c</span>\n      <span class=\"pi\">-</span> <span class=\"s\">hot_standby=on</span>\n      <span class=\"pi\">-</span> <span class=\"s\">-c</span>\n      <span class=\"pi\">-</span> <span class=\"s\">archive_mode=on</span>\n      <span class=\"pi\">-</span> <span class=\"s\">-c</span>\n      <span class=\"pi\">-</span> <span class=\"s\">archive_command=test ! -f /archive/%f &amp;&amp; cp %p /archive/%f</span>\n    <span class=\"na\">volumes</span><span class=\"pi\">:</span>\n      <span class=\"pi\">-</span> <span class=\"s\">pgp:/var/lib/postgresql</span>\n      <span class=\"pi\">-</span> <span class=\"s\">pgarchive:/archive</span>\n      <span class=\"pi\">-</span> <span class=\"s\">./primary-init.sh:/docker-entrypoint-initdb.d/primary-init.sh:ro</span>\n\n<span class=\"na\">volumes</span><span class=\"pi\">:</span>\n  <span class=\"na\">pgp</span><span class=\"pi\">:</span>\n  <span class=\"na\">pgr</span><span class=\"pi\">:</span>\n  <span class=\"na\">pgarchive</span><span class=\"pi\">:</span>\n</code></pre></div></div>\n\n<p><code class=\"language-plaintext highlighter-rouge\">archive_mode</code> needs a restart, not a reload. The <code class=\"language-plaintext highlighter-rouge\">test ! -f</code> guard makes the command refuse to overwrite an existing segment: <code class=\"language-plaintext highlighter-rouge\">archive_command</code> must fail rather than silently clobber, because a segment overwritten is a recovery that stops early.</p>\n\n<p>Take the base backup as the <code class=\"language-plaintext highlighter-rouge\">postgres</code> user, for the same ownership reason as the replica clone:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>docker <span class=\"nb\">exec </span>pg-replication-pgprimary-1 sh <span class=\"nt\">-c</span> <span class=\"s1\">'\n  PGPASSWORD=replpass gosu postgres pg_basebackup \\\n    -h localhost -p 5432 -U replicator -D /archive/base -Fp -Xs -P'</span>\n</code></pre></div></div>\n\n<p>Force a segment switch so there is something in the archive to look at:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>docker <span class=\"nb\">exec </span>pg-replication-pgprimary-1 <span class=\"se\">\\</span>\n  psql <span class=\"nt\">-U</span> admin <span class=\"nt\">-d</span> poc <span class=\"nt\">-c</span> <span class=\"s2\">\"SELECT pg_switch_wal();\"</span>\n</code></pre></div></div>\n\n<p class=\"verify\"><strong>Verify.</strong> A base backup directory and at least one archived segment:</p>\n\n<div class=\"language-console highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"gp\">$</span><span class=\"w\"> </span>docker <span class=\"nb\">exec </span>pg-replication-pgprimary-1 <span class=\"nb\">ls</span> /archive\n<span class=\"go\">base\n000000010000000000000003\n000000010000000000000003.00000028.backup\n</span></code></pre></div></div>\n\n<p>To restore to a point in time you start from a copy of <code class=\"language-plaintext highlighter-rouge\">/archive/base</code>, add a <code class=\"language-plaintext highlighter-rouge\">recovery.signal</code> file to its data directory, and set two parameters in <code class=\"language-plaintext highlighter-rouge\">postgresql.conf</code>:</p>\n\n<div class=\"language-ini highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"py\">restore_command</span><span class=\"w\"> </span><span class=\"p\">=</span><span class=\"w\"> </span><span class=\"s\">'cp /archive/%f \"%p\"'</span>\n<span class=\"py\">recovery_target_time</span><span class=\"w\"> </span><span class=\"p\">=</span><span class=\"w\"> </span><span class=\"s\">'2026-08-11 09:15:00+05:45'</span>\n</code></pre></div></div>\n\n<p>The server then replays archived WAL up to that timestamp and pauses. <code class=\"language-plaintext highlighter-rouge\">SELECT pg_wal_replay_resume();</code> completes the recovery and opens the cluster for writes on a new timeline.</p>\n\n<div class=\"callout callout--note\">\n  <p><strong>What I measured and what I did not.</strong> The replication, lag, promotion and timeline numbers above came off this rig. For PITR I verified the base backup and the archive — the <code class=\"language-plaintext highlighter-rouge\">restore_command</code> and <code class=\"language-plaintext highlighter-rouge\">recovery_target_time</code> recipe is the documented path, and I have not shown output for a full point-in-time rewind because I did not run one here. Test yours on real data before you rely on it; an untested restore is a hypothesis.</p>\n</div>\n\n<h2 id=\"step-8--clean-up\">Step 8 — Clean up</h2>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>docker compose <span class=\"nt\">-f</span> pg-replication/docker-compose.yml down <span class=\"nt\">-v</span>\n</code></pre></div></div>\n\n<p class=\"verify\"><strong>Verify.</strong></p>\n\n<div class=\"language-console highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"gp\">$</span><span class=\"w\"> </span>docker volume <span class=\"nb\">ls</span> <span class=\"nt\">--filter</span> <span class=\"nv\">name</span><span class=\"o\">=</span>pg-replication\n<span class=\"go\">DRIVER    VOLUME NAME\n</span></code></pre></div></div>\n\n<p>Note that a promoted replica plus a full base backup is three copies of the same cluster on disk. <code class=\"language-plaintext highlighter-rouge\">down</code> without <code class=\"language-plaintext highlighter-rouge\">-v</code> keeps all three.</p>\n\n<h2 id=\"troubleshooting\">Troubleshooting</h2>\n\n<table>\n  <thead>\n    <tr>\n      <th>Symptom</th>\n      <th>Cause</th>\n      <th>Fix</th>\n    </tr>\n  </thead>\n  <tbody>\n    <tr>\n      <td>Container complains about <code class=\"language-plaintext highlighter-rouge\">data in unused mount/volume</code> and the data is not persisted</td>\n      <td>The volume is mounted at <code class=\"language-plaintext highlighter-rouge\">/var/lib/postgresql/data</code>, which PG 18 no longer uses as the data directory</td>\n      <td>Mount at <code class=\"language-plaintext highlighter-rouge\">/var/lib/postgresql</code>; the cluster lands in <code class=\"language-plaintext highlighter-rouge\">18/docker</code> beneath it</td>\n    </tr>\n    <tr>\n      <td>Replica starts against an empty cluster after a clean clone</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">PGDATA</code> is unset on the replica, so <code class=\"language-plaintext highlighter-rouge\">pg_basebackup -D \"$PGDATA\"</code> wrote to the wrong path</td>\n      <td>Set <code class=\"language-plaintext highlighter-rouge\">PGDATA: /var/lib/postgresql/18/docker</code> on the replica service</td>\n    </tr>\n    <tr>\n      <td>The replica’s clone test always fires, even on a warm volume</td>\n      <td>Single <code class=\"language-plaintext highlighter-rouge\">$</code> in the compose command, so Compose interpolated <code class=\"language-plaintext highlighter-rouge\">$PGDATA</code> to an empty string</td>\n      <td>Write <code class=\"language-plaintext highlighter-rouge\">$$PGDATA</code> in the compose file</td>\n    </tr>\n    <tr>\n      <td>Server refuses to start: data directory has invalid ownership</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">pg_basebackup</code> ran as root</td>\n      <td>Run it through <code class=\"language-plaintext highlighter-rouge\">gosu postgres</code></td>\n    </tr>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">pg_basebackup</code> is rejected with a <code class=\"language-plaintext highlighter-rouge\">pg_hba.conf</code> entry error</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">replication</code> is a pseudo-database and is not covered by a <code class=\"language-plaintext highlighter-rouge\">host all all</code> rule</td>\n      <td>Append <code class=\"language-plaintext highlighter-rouge\">host replication replicator all scram-sha-256</code> in the init script</td>\n    </tr>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">pg_basebackup</code> fails saying the replication slot does not exist</td>\n      <td>The slot was never created, or the init script did not run because the volume already had data</td>\n      <td>Create it with <code class=\"language-plaintext highlighter-rouge\">SELECT pg_create_physical_replication_slot('replica1_slot');</code>, or <code class=\"language-plaintext highlighter-rouge\">down -v</code> and start clean</td>\n    </tr>\n    <tr>\n      <td>Log shows “ready to accept read-only connections” but never “started streaming WAL”</td>\n      <td>The standby is replaying from disk and has not connected to the primary</td>\n      <td>Check <code class=\"language-plaintext highlighter-rouge\">primary_conninfo</code> in the standby’s config, the <code class=\"language-plaintext highlighter-rouge\">pg_hba.conf</code> line, and the <code class=\"language-plaintext highlighter-rouge\">replicator</code> password</td>\n    </tr>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">pg_stat_replication</code> is empty on the primary</td>\n      <td>No standby is attached</td>\n      <td>Confirm the replica container is running and that the slot shows <code class=\"language-plaintext highlighter-rouge\">active = t</code></td>\n    </tr>\n    <tr>\n      <td>Primary’s disk fills with WAL</td>\n      <td>A replication slot is holding WAL for a replica that is gone</td>\n      <td>Watch <code class=\"language-plaintext highlighter-rouge\">wal_status</code> in <code class=\"language-plaintext highlighter-rouge\">pg_replication_slots</code>; set <code class=\"language-plaintext highlighter-rouge\">max_slot_wal_keep_size</code>, or drop the slot if the replica is not coming back</td>\n    </tr>\n    <tr>\n      <td>Writes on the standby fail with <code class=\"language-plaintext highlighter-rouge\">cannot execute INSERT in a read-only transaction</code></td>\n      <td>It is still a standby</td>\n      <td>This is correct. Promote it with <code class=\"language-plaintext highlighter-rouge\">SELECT pg_promote();</code> only if you actually mean to fail over</td>\n    </tr>\n    <tr>\n      <td>Both nodes accept writes after a failover</td>\n      <td>The old primary was restarted on timeline 1 while the new one is on timeline 2</td>\n      <td>Never do this. Rewind the old node with <code class=\"language-plaintext highlighter-rouge\">pg_rewind</code> or rebuild it from <code class=\"language-plaintext highlighter-rouge\">pg_basebackup</code> before it rejoins</td>\n    </tr>\n    <tr>\n      <td>Recovery stops earlier than the target time</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">archive_command</code> overwrote or skipped a segment</td>\n      <td>Keep the <code class=\"language-plaintext highlighter-rouge\">test ! -f</code> guard so archiving fails loudly instead of clobbering</td>\n    </tr>\n  </tbody>\n</table>\n\n<p>Earlier in this series: <a href=\"/writing/failover-lab-six-engines-eight-scenarios/\">A failover lab you can run on one laptop</a> for the rig and the full results matrix, and <a href=\"/writing/scylladb-nodetool-refresh-upload-truncate-marker/\">nodetool refresh only reads upload/</a> for the ScyllaDB restore that returns zero rows three different ways.</p>\n",
      "summary": "The mount point moved in the PG 18 image, so every compose file copied from a 12–17 guide breaks. The corrected file, then replication, promotion and PITR.",
      "date_published": "2026-08-11T09:00:00+05:45",
      "date_modified": "2026-08-11T10:20:00+05:45",
      "tags": ["postgres"],
      "authors": [
        {
          "name": "Basant Bhattarai",
          "url": "https://basantbhattarai.com.np"
        }
      ]
    },
    {
      "id": "https://basantbhattarai.com.np/writing/scylladb-nodetool-refresh-upload-truncate-marker/",
      "url": "https://basantbhattarai.com.np/writing/scylladb-nodetool-refresh-upload-truncate-marker/",
      "title": "nodetool refresh only reads upload/ — and TRUNCATE hides your SSTables",
      "content_html": "<h2 id=\"what-went-wrong\">What went wrong</h2>\n\n<p>I had a good snapshot. I had the SSTable files on disk. I ran <code class=\"language-plaintext highlighter-rouge\">nodetool refresh</code> on all three nodes, got no errors, and <code class=\"language-plaintext highlighter-rouge\">SELECT count(*)</code> returned zero.</p>\n\n<p>It took three separate discoveries to get the rows back, and none of them produced an error message at any point. That is what makes this expensive: every step in the obvious restore procedure succeeds, and the data still is not there.</p>\n\n<p>The three behaviors, stated up front so you can stop reading if one of them is your problem:</p>\n\n<ol>\n  <li><strong><code class=\"language-plaintext highlighter-rouge\">nodetool refresh</code> only loads SSTables from the table’s <code class=\"language-plaintext highlighter-rouge\">upload/</code> subdirectory.</strong> Files dropped into the table directory itself are ignored — silently, with a zero exit code.</li>\n  <li><strong><code class=\"language-plaintext highlighter-rouge\">TRUNCATE</code> writes a truncation marker</strong>, and that marker hides every SSTable written before it. You can put the old files back and they stay invisible. Recovering from a <code class=\"language-plaintext highlighter-rouge\">TRUNCATE</code> needs <code class=\"language-plaintext highlighter-rouge\">DROP TABLE</code> and a recreate, not another restore attempt.</li>\n  <li><strong>Recreating a table gives it a new UUID</strong>, and the on-disk directory is named after that UUID. The path you carefully copied files into a minute ago now belongs to a dead table.</li>\n</ol>\n\n<p>There is a fourth thing, which happens before any of this and is the reason the cluster would not start in the first place. It gets Step 1.</p>\n\n<p>The compose file is reproduced inline below. There is no public repository to clone.</p>\n\n<h2 id=\"step-1--raise-fsaio-max-nr-before-the-third-node\">Step 1 — Raise fs.aio-max-nr before the third node</h2>\n\n<p>The first two Scylla nodes came up fine. The third exited during startup with:</p>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>system does not satisfy minimum AIO requirements\n</code></pre></div></div>\n\n<p>ScyllaDB’s Seastar runtime reserves asynchronous I/O contexts per reactor when it starts. <code class=\"language-plaintext highlighter-rouge\">fs.aio-max-nr</code> is a kernel-wide ceiling on those contexts, and the Linux VM behind Docker Desktop ships with a low default. Two nodes fit under it. Three did not.</p>\n\n<p>The limit belongs to the VM, not to a container, so raise it from a privileged throwaway container. It applies to every container on that daemon, including ones you start later.</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>docker run <span class=\"nt\">--rm</span> <span class=\"nt\">--privileged</span> alpine sysctl <span class=\"nt\">-w</span> fs.aio-max-nr<span class=\"o\">=</span>1048576\n</code></pre></div></div>\n\n<p class=\"verify\"><strong>Verify.</strong></p>\n\n<div class=\"language-console highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"gp\">$</span><span class=\"w\"> </span>docker run <span class=\"nt\">--rm</span> <span class=\"nt\">--privileged</span> alpine sysctl <span class=\"nt\">-w</span> fs.aio-max-nr<span class=\"o\">=</span>1048576\n<span class=\"go\">fs.aio-max-nr = 1048576\n</span></code></pre></div></div>\n\n<div class=\"callout callout--gotcha\">\n  <p><strong>Gotcha.</strong> This sysctl lives inside the Docker VM, and the VM is rebuilt when Docker Desktop restarts. The fix does not persist. If a node that started yesterday refuses to start today with the same AIO message, re-run the privileged container before you look at anything else.</p>\n</div>\n\n<h2 id=\"step-2--bring-the-ring-up-one-node-at-a-time\">Step 2 — Bring the ring up one node at a time</h2>\n\n<p>Three nodes, each pinned to a single shard and 1200 MB, which is what makes three of them fit on one laptop. <code class=\"language-plaintext highlighter-rouge\">scylla1</code> is the seed; the other two join it.</p>\n\n<div data-file=\"scylla-ha/docker-compose.yml\" class=\"language-yaml highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"na\">name</span><span class=\"pi\">:</span> <span class=\"s\">scylla-ha</span>\n\n<span class=\"na\">services</span><span class=\"pi\">:</span>\n  <span class=\"na\">scylla1</span><span class=\"pi\">:</span>\n    <span class=\"na\">image</span><span class=\"pi\">:</span> <span class=\"s\">scylladb/scylla:2026.1</span>\n    <span class=\"na\">command</span><span class=\"pi\">:</span> <span class=\"pi\">[</span><span class=\"s2\">\"</span><span class=\"s\">--seeds=scylla1\"</span><span class=\"pi\">,</span> <span class=\"s2\">\"</span><span class=\"s\">--smp=1\"</span><span class=\"pi\">,</span> <span class=\"s2\">\"</span><span class=\"s\">--memory=1200M\"</span><span class=\"pi\">,</span> <span class=\"s2\">\"</span><span class=\"s\">--overprovisioned=1\"</span><span class=\"pi\">,</span> <span class=\"s2\">\"</span><span class=\"s\">--api-address=0.0.0.0\"</span><span class=\"pi\">]</span>\n    <span class=\"na\">ports</span><span class=\"pi\">:</span> <span class=\"pi\">[</span><span class=\"s2\">\"</span><span class=\"s\">9042:9042\"</span><span class=\"pi\">]</span>\n    <span class=\"na\">volumes</span><span class=\"pi\">:</span> <span class=\"pi\">[</span><span class=\"s2\">\"</span><span class=\"s\">s1:/var/lib/scylla\"</span><span class=\"pi\">]</span>\n\n  <span class=\"na\">scylla2</span><span class=\"pi\">:</span>\n    <span class=\"na\">image</span><span class=\"pi\">:</span> <span class=\"s\">scylladb/scylla:2026.1</span>\n    <span class=\"na\">command</span><span class=\"pi\">:</span> <span class=\"pi\">[</span><span class=\"s2\">\"</span><span class=\"s\">--seeds=scylla1\"</span><span class=\"pi\">,</span> <span class=\"s2\">\"</span><span class=\"s\">--smp=1\"</span><span class=\"pi\">,</span> <span class=\"s2\">\"</span><span class=\"s\">--memory=1200M\"</span><span class=\"pi\">,</span> <span class=\"s2\">\"</span><span class=\"s\">--overprovisioned=1\"</span><span class=\"pi\">,</span> <span class=\"s2\">\"</span><span class=\"s\">--api-address=0.0.0.0\"</span><span class=\"pi\">]</span>\n    <span class=\"na\">volumes</span><span class=\"pi\">:</span> <span class=\"pi\">[</span><span class=\"s2\">\"</span><span class=\"s\">s2:/var/lib/scylla\"</span><span class=\"pi\">]</span>\n    <span class=\"na\">depends_on</span><span class=\"pi\">:</span> <span class=\"pi\">[</span><span class=\"nv\">scylla1</span><span class=\"pi\">]</span>\n\n  <span class=\"na\">scylla3</span><span class=\"pi\">:</span>\n    <span class=\"na\">image</span><span class=\"pi\">:</span> <span class=\"s\">scylladb/scylla:2026.1</span>\n    <span class=\"na\">command</span><span class=\"pi\">:</span> <span class=\"pi\">[</span><span class=\"s2\">\"</span><span class=\"s\">--seeds=scylla1\"</span><span class=\"pi\">,</span> <span class=\"s2\">\"</span><span class=\"s\">--smp=1\"</span><span class=\"pi\">,</span> <span class=\"s2\">\"</span><span class=\"s\">--memory=1200M\"</span><span class=\"pi\">,</span> <span class=\"s2\">\"</span><span class=\"s\">--overprovisioned=1\"</span><span class=\"pi\">,</span> <span class=\"s2\">\"</span><span class=\"s\">--api-address=0.0.0.0\"</span><span class=\"pi\">]</span>\n    <span class=\"na\">volumes</span><span class=\"pi\">:</span> <span class=\"pi\">[</span><span class=\"s2\">\"</span><span class=\"s\">s3:/var/lib/scylla\"</span><span class=\"pi\">]</span>\n    <span class=\"na\">depends_on</span><span class=\"pi\">:</span> <span class=\"pi\">[</span><span class=\"nv\">scylla1</span><span class=\"pi\">]</span>\n\n<span class=\"na\">volumes</span><span class=\"pi\">:</span>\n  <span class=\"na\">s1</span><span class=\"pi\">:</span>\n  <span class=\"na\">s2</span><span class=\"pi\">:</span>\n  <span class=\"na\">s3</span><span class=\"pi\">:</span>\n</code></pre></div></div>\n\n<p><code class=\"language-plaintext highlighter-rouge\">depends_on</code> only controls start order, not readiness, so bring the nodes up by hand. Start the seed, wait for it to report Up/Normal, then add one joiner at a time.</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>docker compose <span class=\"nt\">-f</span> scylla-ha/docker-compose.yml up <span class=\"nt\">-d</span> scylla1\n</code></pre></div></div>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>docker <span class=\"nb\">exec </span>scylla-ha-scylla1-1 nodetool status\n</code></pre></div></div>\n\n<p>Once <code class=\"language-plaintext highlighter-rouge\">scylla1</code> shows <code class=\"language-plaintext highlighter-rouge\">UN</code>, add the second, wait again, then the third:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>docker compose <span class=\"nt\">-f</span> scylla-ha/docker-compose.yml up <span class=\"nt\">-d</span> scylla2\n</code></pre></div></div>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>docker compose <span class=\"nt\">-f</span> scylla-ha/docker-compose.yml up <span class=\"nt\">-d</span> scylla3\n</code></pre></div></div>\n\n<p class=\"verify\"><strong>Verify.</strong> <code class=\"language-plaintext highlighter-rouge\">nodetool status</code> lists three nodes at <code class=\"language-plaintext highlighter-rouge\">UN</code> — Up and Normal — each holding 256 tokens:</p>\n\n<div class=\"language-console highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"go\">UN 172.21.0.2 ... rack1\nUN 172.21.0.3 ... rack1\nUN 172.21.0.4 ... rack1\n</span></code></pre></div></div>\n\n<p>A node stuck at <code class=\"language-plaintext highlighter-rouge\">UJ</code> is still joining and streaming; give it time rather than restarting it. A node at <code class=\"language-plaintext highlighter-rouge\">DN</code> never made it — check that its AIO problem is fixed and that the seed was <code class=\"language-plaintext highlighter-rouge\">UN</code> before it started.</p>\n\n<h2 id=\"step-3--create-the-keyspace-and-a-query-driven-table\">Step 3 — Create the keyspace and a query-driven table</h2>\n\n<p>RF=3 on a three-node cluster means every node holds every row, which is what makes the QUORUM behavior in the next step easy to reason about.</p>\n\n<div class=\"language-sql highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"k\">CREATE</span> <span class=\"n\">KEYSPACE</span> <span class=\"n\">poc</span> <span class=\"k\">WITH</span> <span class=\"n\">replication</span> <span class=\"o\">=</span> <span class=\"p\">{</span>\n  <span class=\"s1\">'class'</span><span class=\"p\">:</span> <span class=\"s1\">'NetworkTopologyStrategy'</span><span class=\"p\">,</span>\n  <span class=\"s1\">'datacenter1'</span><span class=\"p\">:</span> <span class=\"mi\">3</span>\n<span class=\"p\">};</span>\n\n<span class=\"k\">CREATE</span> <span class=\"k\">TABLE</span> <span class=\"n\">poc</span><span class=\"p\">.</span><span class=\"n\">sensor_by_day</span> <span class=\"p\">(</span>\n  <span class=\"n\">sensor_id</span> <span class=\"nb\">text</span><span class=\"p\">,</span>\n  <span class=\"k\">day</span>       <span class=\"nb\">date</span><span class=\"p\">,</span>\n  <span class=\"n\">ts</span>        <span class=\"nb\">timestamp</span><span class=\"p\">,</span>\n  <span class=\"n\">value</span>     <span class=\"nb\">double</span><span class=\"p\">,</span>\n  <span class=\"k\">PRIMARY</span> <span class=\"k\">KEY</span> <span class=\"p\">((</span><span class=\"n\">sensor_id</span><span class=\"p\">,</span> <span class=\"k\">day</span><span class=\"p\">),</span> <span class=\"n\">ts</span><span class=\"p\">)</span>\n<span class=\"p\">)</span> <span class=\"k\">WITH</span> <span class=\"n\">CLUSTERING</span> <span class=\"k\">ORDER</span> <span class=\"k\">BY</span> <span class=\"p\">(</span><span class=\"n\">ts</span> <span class=\"k\">DESC</span><span class=\"p\">);</span>\n</code></pre></div></div>\n\n<p>The partition key is <code class=\"language-plaintext highlighter-rouge\">(sensor_id, day)</code>, not <code class=\"language-plaintext highlighter-rouge\">sensor_id</code> alone. Bucketing a time series by day bounds the partition: a sensor that reports for two years does not accumulate one enormous partition that has to be read, repaired and compacted as a unit. This is the single most common modeling mistake in Cassandra-family stores, and it does not show up until the partition is already too big to fix comfortably.</p>\n\n<p>Three rows are enough for the whole exercise:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>docker <span class=\"nb\">exec </span>scylla-ha-scylla1-1 cqlsh <span class=\"nt\">-e</span> <span class=\"s2\">\"\nINSERT INTO poc.sensor_by_day (sensor_id, day, ts, value) VALUES ('s1','2026-06-07','2026-06-07 09:00:00',42.0);\nINSERT INTO poc.sensor_by_day (sensor_id, day, ts, value) VALUES ('s1','2026-06-07','2026-06-07 09:01:00',43.5);\nINSERT INTO poc.sensor_by_day (sensor_id, day, ts, value) VALUES ('s1','2026-06-07','2026-06-07 09:02:00',41.2);\"</span>\n</code></pre></div></div>\n\n<p class=\"verify\"><strong>Verify.</strong></p>\n\n<div class=\"language-console highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"gp\">cqlsh&gt;</span><span class=\"w\"> </span>SELECT count<span class=\"o\">(</span><span class=\"k\">*</span><span class=\"o\">)</span> FROM poc.sensor_by_day<span class=\"p\">;</span>\n<span class=\"go\">\n count\n-------\n     3\n\n(1 rows)\n</span></code></pre></div></div>\n\n<h2 id=\"step-4--confirm-rf3-then-lose-a-node\">Step 4 — Confirm RF=3, then lose a node</h2>\n\n<p>Before testing the failure, confirm the replication actually landed where you think it did. <code class=\"language-plaintext highlighter-rouge\">nodetool getendpoints</code> answers “which nodes hold this partition” for a specific partition key.</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>docker <span class=\"nb\">exec </span>scylla-ha-scylla1-1 nodetool getendpoints poc sensor_by_day <span class=\"s2\">\"s1:2026-06-07\"</span>\n</code></pre></div></div>\n\n<p class=\"verify\"><strong>Verify.</strong> Three addresses, one per node, because RF=3 on a three-node ring means every node is a replica:</p>\n\n<div class=\"language-console highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"go\">172.21.0.2\n172.21.0.3\n172.21.0.4\n</span></code></pre></div></div>\n\n<p>Now stop one node and read at QUORUM. QUORUM of RF=3 is 2, so two survivors are exactly enough.</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>docker stop scylla-ha-scylla2-1\n</code></pre></div></div>\n\n<p class=\"verify\"><strong>Verify.</strong></p>\n\n<div class=\"language-console highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"gp\">cqlsh&gt;</span><span class=\"w\"> </span>CONSISTENCY QUORUM<span class=\"p\">;</span>\n<span class=\"go\">Consistency level set to QUORUM.\n</span><span class=\"gp\">cqlsh&gt;</span><span class=\"w\"> </span>SELECT count<span class=\"o\">(</span><span class=\"k\">*</span><span class=\"o\">)</span> FROM poc.sensor_by_day<span class=\"p\">;</span>\n<span class=\"go\">\n count\n-------\n     3\n\n(1 rows)\n</span></code></pre></div></div>\n\n<p>That is the whole availability claim, checked. Stop a second node and the same query starts failing with an unavailable exception, which is also correct — at that point the cluster cannot prove it is returning current data, so it refuses rather than guessing.</p>\n\n<p>Bring the node back before continuing:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>docker start scylla-ha-scylla2-1\n</code></pre></div></div>\n\n<h2 id=\"step-5--take-a-snapshot-on-every-node\">Step 5 — Take a snapshot on every node</h2>\n\n<p><code class=\"language-plaintext highlighter-rouge\">nodetool snapshot</code> flushes memtables and then hardlinks the current SSTables into a <code class=\"language-plaintext highlighter-rouge\">snapshots/&lt;tag&gt;</code> directory under the table. It costs almost nothing at the moment you take it, because hardlinks; it starts costing disk later as compaction replaces the live files and the snapshot keeps the old ones alive.</p>\n\n<p>A snapshot is per node. There is no cluster-wide snapshot command — you run it everywhere.</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"k\">for </span>n <span class=\"k\">in </span>1 2 3<span class=\"p\">;</span> <span class=\"k\">do\n  </span>docker <span class=\"nb\">exec </span>scylla-ha-scylla<span class=\"nv\">$n</span><span class=\"nt\">-1</span> nodetool snapshot <span class=\"nt\">-t</span> poc_bkp poc\n<span class=\"k\">done</span>\n</code></pre></div></div>\n\n<p class=\"verify\"><strong>Verify.</strong> The snapshot directory exists under the table directory:</p>\n\n<div class=\"language-console highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"gp\">$</span><span class=\"w\"> </span>docker <span class=\"nb\">exec </span>scylla-ha-scylla1-1 sh <span class=\"nt\">-c</span> <span class=\"s1\">'ls -d /var/lib/scylla/data/poc/sensor_by_day-*/snapshots/poc_bkp'</span>\n<span class=\"go\">/var/lib/scylla/data/poc/sensor_by_day-6f1c8ad0-6373-11f1-9c4d-3a17e2c3b901/snapshots/poc_bkp\n</span></code></pre></div></div>\n\n<p>The UUID in that path is the table’s identity, and it will be different on your ring. Remember that it is there. It becomes the third gotcha in Step 7.</p>\n\n<h2 id=\"step-6--destroy-the-data\">Step 6 — Destroy the data</h2>\n\n<p>The loss scenario. <code class=\"language-plaintext highlighter-rouge\">TRUNCATE</code> rather than a dropped table, because truncate is the mistake people actually make — it is one word away from a <code class=\"language-plaintext highlighter-rouge\">SELECT</code> in a terminal that is pointed at the wrong environment.</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>docker <span class=\"nb\">exec </span>scylla-ha-scylla1-1 cqlsh <span class=\"nt\">-e</span> <span class=\"s2\">\"TRUNCATE poc.sensor_by_day;\"</span>\n</code></pre></div></div>\n\n<p class=\"verify\"><strong>Verify.</strong></p>\n\n<div class=\"language-console highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"gp\">cqlsh&gt;</span><span class=\"w\"> </span>SELECT count<span class=\"o\">(</span><span class=\"k\">*</span><span class=\"o\">)</span> FROM poc.sensor_by_day<span class=\"p\">;</span>\n<span class=\"go\">\n count\n-------\n     0\n\n(1 rows)\n</span></code></pre></div></div>\n\n<h2 id=\"step-7--why-the-obvious-restore-does-nothing\">Step 7 — Why the obvious restore does nothing</h2>\n\n<p>The obvious restore is: copy the snapshot’s SSTables back next to the live ones, then tell the node to pick them up.</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>docker <span class=\"nb\">exec </span>scylla-ha-scylla1-1 sh <span class=\"nt\">-c</span> <span class=\"s1\">'\n  cp /var/lib/scylla/data/poc/sensor_by_day-*/snapshots/poc_bkp/* \\\n     /var/lib/scylla/data/poc/sensor_by_day-*/'</span>\ndocker <span class=\"nb\">exec </span>scylla-ha-scylla1-1 nodetool refresh poc sensor_by_day\n</code></pre></div></div>\n\n<p>That command exits 0. It prints nothing. And the count is still zero.</p>\n\n<div class=\"callout callout--gotcha\">\n  <p><strong>Gotcha 1 — <code class=\"language-plaintext highlighter-rouge\">refresh</code> only reads <code class=\"language-plaintext highlighter-rouge\">upload/</code>.</strong> <code class=\"language-plaintext highlighter-rouge\">nodetool refresh</code> does not scan the table directory. It scans the table’s <code class=\"language-plaintext highlighter-rouge\">upload/</code> subdirectory, loads whatever valid SSTables it finds there, and returns. Files sitting directly in the table directory are not an error and not a warning; they are simply not looked at. Create <code class=\"language-plaintext highlighter-rouge\">upload/</code> if it does not exist and put the files inside it.</p>\n</div>\n\n<p>Move the files into <code class=\"language-plaintext highlighter-rouge\">upload/</code> and refresh again, and the count is <em>still</em> zero. That is the second behavior.</p>\n\n<div class=\"callout callout--gotcha\">\n  <p><strong>Gotcha 2 — <code class=\"language-plaintext highlighter-rouge\">TRUNCATE</code> leaves a marker that hides older SSTables.</strong> Truncate does not just delete files. It records a truncation record for the table, and every SSTable whose data predates that record is treated as truncated away, no matter how it arrived on disk afterwards. Loading a pre-truncate snapshot into a table that has been truncated gets you nothing. The marker belongs to the table, so the way out is <code class=\"language-plaintext highlighter-rouge\">DROP TABLE</code> and recreate — which discards the marker along with the table.</p>\n</div>\n\n<p>So: drop, recreate, copy into <code class=\"language-plaintext highlighter-rouge\">upload/</code>, refresh. And it is still zero, for a third reason.</p>\n\n<div class=\"callout callout--gotcha\">\n  <p><strong>Gotcha 3 — a recreated table gets a new UUID and a new directory.</strong> The on-disk path is <code class=\"language-plaintext highlighter-rouge\">&lt;table_name&gt;-&lt;table_id&gt;</code>, and <code class=\"language-plaintext highlighter-rouge\">DROP</code> + <code class=\"language-plaintext highlighter-rouge\">CREATE</code> mints a fresh id. The old directory survives on disk with your snapshot inside it, which is why nothing looks broken. You are loading files into a directory that no longer belongs to any live table. The live one is the directory whose suffix matches <code class=\"language-plaintext highlighter-rouge\">system_schema.tables.id</code> for that table.</p>\n</div>\n\n<p>Three behaviors, three silent no-ops, one restore that has to be done in a specific order to work.</p>\n\n<h2 id=\"step-8--the-restore-that-works\">Step 8 — The restore that works</h2>\n\n<p>The ordering matters: stage the snapshot files <strong>out</strong> of the table directory first, because the drop in the next command takes the directory context with it.</p>\n\n<p>Stage on every node:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"k\">for </span>n <span class=\"k\">in </span>1 2 3<span class=\"p\">;</span> <span class=\"k\">do\n  </span>docker <span class=\"nb\">exec </span>scylla-ha-scylla<span class=\"nv\">$n</span><span class=\"nt\">-1</span> sh <span class=\"nt\">-c</span> <span class=\"s1\">'\n    mkdir -p /tmp/stage &amp;&amp;\n    cp /var/lib/scylla/data/poc/sensor_by_day-*/snapshots/poc_bkp/* /tmp/stage/'</span>\n<span class=\"k\">done</span>\n</code></pre></div></div>\n\n<p>Drop and recreate the table, which discards the truncation marker:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>docker <span class=\"nb\">exec </span>scylla-ha-scylla1-1 cqlsh <span class=\"nt\">-e</span> <span class=\"s2\">\"DROP TABLE poc.sensor_by_day;\"</span>\ndocker <span class=\"nb\">exec </span>scylla-ha-scylla1-1 cqlsh <span class=\"nt\">-e</span> <span class=\"s2\">\"\nCREATE TABLE poc.sensor_by_day (\n  sensor_id text, day date, ts timestamp, value double,\n  PRIMARY KEY ((sensor_id, day), ts)\n) WITH CLUSTERING ORDER BY (ts DESC);\"</span>\n</code></pre></div></div>\n\n<p>Confirm which directory is now the live one. The id here is what the directory suffix has to match:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>docker <span class=\"nb\">exec </span>scylla-ha-scylla1-1 cqlsh <span class=\"nt\">-e</span> <span class=\"s2\">\"\nSELECT id FROM system_schema.tables\n WHERE keyspace_name='poc' AND table_name='sensor_by_day';\"</span>\n</code></pre></div></div>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>docker <span class=\"nb\">exec </span>scylla-ha-scylla1-1 sh <span class=\"nt\">-c</span> <span class=\"s1\">'ls -d /var/lib/scylla/data/poc/sensor_by_day-*'</span>\n</code></pre></div></div>\n\n<p>You will see at least two directories: the stale one from before the drop, and the new live one. Copy into the new one’s <code class=\"language-plaintext highlighter-rouge\">upload/</code>, on every node:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"k\">for </span>n <span class=\"k\">in </span>1 2 3<span class=\"p\">;</span> <span class=\"k\">do\n  </span>docker <span class=\"nb\">exec </span>scylla-ha-scylla<span class=\"nv\">$n</span><span class=\"nt\">-1</span> sh <span class=\"nt\">-c</span> <span class=\"s1\">'\n    LIVE=$(ls -dt /var/lib/scylla/data/poc/sensor_by_day-* | head -1)\n    echo \"$LIVE\"\n    mkdir -p \"$LIVE/upload\"\n    cp /tmp/stage/* \"$LIVE/upload/\"\n    chown -R scylla:scylla \"$LIVE/upload\"'</span>\n<span class=\"k\">done</span>\n</code></pre></div></div>\n\n<div class=\"callout callout--warn\">\n  <p><strong>Check the directory, do not trust the sort.</strong> <code class=\"language-plaintext highlighter-rouge\">ls -dt | head -1</code> picks the most recently modified directory, which is the newly created one on a clean run. If you have dropped and recreated the table more than once, or touched the stale directory in between, that heuristic will pick the wrong one. Compare the printed path against the <code class=\"language-plaintext highlighter-rouge\">id</code> you just read from <code class=\"language-plaintext highlighter-rouge\">system_schema.tables</code> before you trust it.</p>\n</div>\n\n<p>Now refresh, on every node:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"k\">for </span>n <span class=\"k\">in </span>1 2 3<span class=\"p\">;</span> <span class=\"k\">do\n  </span>docker <span class=\"nb\">exec </span>scylla-ha-scylla<span class=\"nv\">$n</span><span class=\"nt\">-1</span> nodetool refresh poc sensor_by_day\n<span class=\"k\">done</span>\n</code></pre></div></div>\n\n<p class=\"verify\"><strong>Verify.</strong></p>\n\n<div class=\"language-console highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"gp\">cqlsh&gt;</span><span class=\"w\"> </span>CONSISTENCY QUORUM<span class=\"p\">;</span>\n<span class=\"go\">Consistency level set to QUORUM.\n</span><span class=\"gp\">cqlsh&gt;</span><span class=\"w\"> </span>SELECT count<span class=\"o\">(</span><span class=\"k\">*</span><span class=\"o\">)</span> FROM poc.sensor_by_day<span class=\"p\">;</span>\n<span class=\"go\">\n count\n-------\n     3\n\n(1 rows)\n</span></code></pre></div></div>\n\n<p>Three rows, read at QUORUM, restored from a snapshot taken before a truncate. <code class=\"language-plaintext highlighter-rouge\">nodetool tablestats</code> will confirm the loaded files are being served — on my run it reported a read count of 40 and an SSTable count of 6 for this table after the exercise.</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>docker <span class=\"nb\">exec </span>scylla-ha-scylla1-1 nodetool tablestats poc.sensor_by_day\n</code></pre></div></div>\n\n<p>Two things worth saying about what this restore is and is not. First, snapshots are per node and this ring is RF=3, so every node had a complete copy and loading all three was straightforward; on a larger ring with RF smaller than the node count, each node’s snapshot is a different subset and the staging has to keep them apart. Second, <code class=\"language-plaintext highlighter-rouge\">refresh</code> is a load, not a repair. Real disaster recovery on this engine also involves <code class=\"language-plaintext highlighter-rouge\">nodetool repair</code> for anti-entropy and node <code class=\"language-plaintext highlighter-rouge\">rebuild</code> to restream from replicas, and <code class=\"language-plaintext highlighter-rouge\">gc_grace_seconds</code> governs when tombstones can actually be collected — get that wrong and you resurrect deleted rows.</p>\n\n<p>For monitoring, ScyllaDB exposes Prometheus metrics on port 9180 — <code class=\"language-plaintext highlighter-rouge\">scylla_storage_proxy_*</code> for coordinator-side latency and errors, and reactor utilization for the shard-per-core saturation signal that is specific to this engine.</p>\n\n<h2 id=\"step-9--clean-up\">Step 9 — Clean up</h2>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>docker compose <span class=\"nt\">-f</span> scylla-ha/docker-compose.yml down <span class=\"nt\">-v</span>\n</code></pre></div></div>\n\n<p class=\"verify\"><strong>Verify.</strong></p>\n\n<div class=\"language-console highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"gp\">$</span><span class=\"w\"> </span>docker volume <span class=\"nb\">ls</span> <span class=\"nt\">--filter</span> <span class=\"nv\">name</span><span class=\"o\">=</span>scylla-ha\n<span class=\"go\">DRIVER    VOLUME NAME\n</span></code></pre></div></div>\n\n<p>Three nodes’ worth of SSTables and snapshots come to real disk. <code class=\"language-plaintext highlighter-rouge\">down</code> without <code class=\"language-plaintext highlighter-rouge\">-v</code> keeps all of it.</p>\n\n<h2 id=\"troubleshooting\">Troubleshooting</h2>\n\n<table>\n  <thead>\n    <tr>\n      <th>Symptom</th>\n      <th>Cause</th>\n      <th>Fix</th>\n    </tr>\n  </thead>\n  <tbody>\n    <tr>\n      <td>Node exits at startup: <code class=\"language-plaintext highlighter-rouge\">system does not satisfy minimum AIO requirements</code></td>\n      <td><code class=\"language-plaintext highlighter-rouge\">fs.aio-max-nr</code> in the Docker VM is below what Seastar reserves per reactor</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">docker run --rm --privileged alpine sysctl -w fs.aio-max-nr=1048576</code>, then restart the node</td>\n    </tr>\n    <tr>\n      <td>That AIO fix worked yesterday and does not today</td>\n      <td>Docker Desktop restarted and rebuilt its VM, discarding the sysctl</td>\n      <td>Re-run the privileged sysctl container after every restart</td>\n    </tr>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">nodetool refresh</code> exits 0 and nothing is loaded</td>\n      <td>SSTables are in the table directory, not in <code class=\"language-plaintext highlighter-rouge\">upload/</code></td>\n      <td>Copy them into <code class=\"language-plaintext highlighter-rouge\">&lt;table-dir&gt;/upload/</code> and refresh again</td>\n    </tr>\n    <tr>\n      <td>Files are in <code class=\"language-plaintext highlighter-rouge\">upload/</code>, refresh is clean, rows still missing</td>\n      <td>A <code class=\"language-plaintext highlighter-rouge\">TRUNCATE</code> marker is hiding every SSTable older than the truncate</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">DROP TABLE</code> and recreate, then load the snapshot into the new table</td>\n    </tr>\n    <tr>\n      <td>Restore works on one node, count is short at QUORUM</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">refresh</code> was only run on some nodes</td>\n      <td>Run <code class=\"language-plaintext highlighter-rouge\">nodetool snapshot</code> and <code class=\"language-plaintext highlighter-rouge\">nodetool refresh</code> on every node</td>\n    </tr>\n    <tr>\n      <td>Refresh finds nothing right after a <code class=\"language-plaintext highlighter-rouge\">DROP</code> + <code class=\"language-plaintext highlighter-rouge\">CREATE</code></td>\n      <td>The recreate minted a new table UUID, so the directory you loaded is stale</td>\n      <td>Read <code class=\"language-plaintext highlighter-rouge\">system_schema.tables.id</code> and load into the directory whose suffix matches it</td>\n    </tr>\n    <tr>\n      <td>Refresh logs a permission error</td>\n      <td>Files were copied into <code class=\"language-plaintext highlighter-rouge\">upload/</code> as root, and the server runs as <code class=\"language-plaintext highlighter-rouge\">scylla</code></td>\n      <td><code class=\"language-plaintext highlighter-rouge\">chown -R scylla:scylla &lt;table-dir&gt;/upload</code> before refreshing</td>\n    </tr>\n    <tr>\n      <td>A joiner sits at <code class=\"language-plaintext highlighter-rouge\">UJ</code> for a long time</td>\n      <td>Bootstrap streaming on a one-shard, 1200 MB node is slow, not stuck</td>\n      <td>Wait, and watch progress with <code class=\"language-plaintext highlighter-rouge\">nodetool netstats</code></td>\n    </tr>\n    <tr>\n      <td>A joiner never appears in <code class=\"language-plaintext highlighter-rouge\">nodetool status</code></td>\n      <td>All three nodes were started at once, before the seed was <code class=\"language-plaintext highlighter-rouge\">UN</code></td>\n      <td>Start <code class=\"language-plaintext highlighter-rouge\">scylla1</code>, wait for <code class=\"language-plaintext highlighter-rouge\">UN</code>, then start <code class=\"language-plaintext highlighter-rouge\">scylla2</code> and <code class=\"language-plaintext highlighter-rouge\">scylla3</code> one at a time</td>\n    </tr>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">SELECT</code> fails with an unavailable exception at QUORUM</td>\n      <td>Two of three nodes are down, so QUORUM of RF=3 cannot be met</td>\n      <td>Bring a node back; do not lower the consistency level to hide it</td>\n    </tr>\n  </tbody>\n</table>\n\n<p>The next post in this series is <a href=\"/writing/postgresql-18-docker-volume-path-change/\">PostgreSQL 18 won’t take a volume at /var/lib/postgresql/data</a>. The rig itself, and the full eight-scenario results matrix, is in <a href=\"/writing/failover-lab-six-engines-eight-scenarios/\">A failover lab you can run on one laptop</a>.</p>\n",
      "summary": "Three ScyllaDB behaviors that make a perfectly valid snapshot restore return zero rows, with the repro, the fix, and the AIO limit that stops node three.",
      "date_published": "2026-08-07T09:00:00+05:45",
      "date_modified": "2026-08-11T10:20:00+05:45",
      "tags": ["distributed-databases"],
      "authors": [
        {
          "name": "Basant Bhattarai",
          "url": "https://basantbhattarai.com.np"
        }
      ]
    },
    {
      "id": "https://basantbhattarai.com.np/writing/free-mailbox-stalwart-your-domain/",
      "url": "https://basantbhattarai.com.np/writing/free-mailbox-stalwart-your-domain/",
      "title": "A free mailbox for your domain with Stalwart",
      "content_html": "<h2 id=\"why-bother\">Why bother</h2>\n\n<p>Want <code class=\"language-plaintext highlighter-rouge\">you@yourdomain.com</code> without paying Google Workspace or Fastmail, and without running a four-gigabyte Mailcow stack? <a href=\"https://stalw.art/\">Stalwart</a> is a single Rust binary: IMAP, SMTP, JMAP, a real admin UI, and about 100–200 MB idle. Community edition (AGPL) is enough for a personal or product domain.</p>\n\n<p>You need three things the SaaS plans hide from you:</p>\n\n<ol>\n  <li>A VPS whose provider <strong>allows outbound port 25</strong>. Many cheap clouds block it. Check before you start.</li>\n  <li>DNS you can edit: A, MX, SPF, DKIM, DMARC, and a <strong>PTR</strong> (reverse DNS) at the VPS panel, not at the registrar.</li>\n  <li>About an hour. Most of it is DNS and TLS, not Docker.</li>\n</ol>\n\n<p>If the same VPS already serves a website on 80/443, that is normal. Do <strong>not</strong> let Stalwart’s ACME grab 443. The wizard has a switch for this. Turn it off. Admin HTTPS goes through the nginx (or Caddy) you already run; IMAP and SMTP get a copy of the same Let’s Encrypt cert.</p>\n\n<p>Replace <code class=\"language-plaintext highlighter-rouge\">example.com</code> / <code class=\"language-plaintext highlighter-rouge\">mail.example.com</code> with your domain below. Do not commit mailbox passwords.</p>\n\n<div class=\"callout callout--note\">\n  <p><strong>What “done” means.</strong> Not the admin UI loading. Done is: you can receive a message from Gmail in IMAP, reply, and Gmail → Show original shows SPF, DKIM, and DMARC passing. First deliveries may land in spam. That is reputation, not a broken server.</p>\n</div>\n\n<h2 id=\"step-1--dns-and-firewall-first\">Step 1 — DNS and firewall first</h2>\n\n<p>Point the mail hostname at the VPS and publish the records that do not depend on the wizard yet. DKIM comes later, from Stalwart’s zone file.</p>\n\n<table>\n  <thead>\n    <tr>\n      <th>Type</th>\n      <th>Name</th>\n      <th>Value</th>\n    </tr>\n  </thead>\n  <tbody>\n    <tr>\n      <td>A</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">mail</code></td>\n      <td>your VPS public IPv4</td>\n    </tr>\n    <tr>\n      <td>MX</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">@</code></td>\n      <td><code class=\"language-plaintext highlighter-rouge\">mail.example.com</code> priority <code class=\"language-plaintext highlighter-rouge\">10</code></td>\n    </tr>\n    <tr>\n      <td>TXT</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">@</code></td>\n      <td><code class=\"language-plaintext highlighter-rouge\">v=spf1 mx a:mail.example.com ~all</code></td>\n    </tr>\n    <tr>\n      <td>TXT</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">_dmarc</code></td>\n      <td><code class=\"language-plaintext highlighter-rouge\">v=DMARC1; p=none; rua=mailto:admin@example.com</code></td>\n    </tr>\n  </tbody>\n</table>\n\n<p>Start DMARC at <code class=\"language-plaintext highlighter-rouge\">p=none</code>. Tighten to <code class=\"language-plaintext highlighter-rouge\">p=quarantine</code> after a few clean weeks.</p>\n\n<p>At the <strong>VPS provider</strong> (not the DNS host), set reverse DNS / PTR for the public IP to <code class=\"language-plaintext highlighter-rouge\">mail.example.com</code>. Large receivers still weigh PTR.</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>dig +short mail.example.com A\ndig +short example.com MX\ndig <span class=\"nt\">-x</span> YOUR_VPS_IP +short\n<span class=\"c\"># expect: mail.example.com.</span>\n</code></pre></div></div>\n\n<p>Open the mail ports. Keep the admin port on loopback.</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nb\">sudo </span>ufw allow 25/tcp\n<span class=\"nb\">sudo </span>ufw allow 465/tcp\n<span class=\"nb\">sudo </span>ufw allow 587/tcp\n<span class=\"nb\">sudo </span>ufw allow 993/tcp\n<span class=\"nb\">sudo </span>ufw reload\n</code></pre></div></div>\n\n<p class=\"verify\"><strong>Verify.</strong> <code class=\"language-plaintext highlighter-rouge\">dig</code> returns your A and MX. PTR is <code class=\"language-plaintext highlighter-rouge\">mail.example.com.</code> If PTR still shows a generic <code class=\"language-plaintext highlighter-rouge\">srv….hstgr.cloud</code> (or equivalent), fix that before you chase DKIM failures.</p>\n\n<h2 id=\"step-2--start-stalwart-and-finish-the-wizard\">Step 2 — Start Stalwart and finish the wizard</h2>\n\n<p>This compose file is <strong>standalone</strong>. Admin listens on <code class=\"language-plaintext highlighter-rouge\">127.0.0.1:8088</code> only. It does not bind 80 or 443.</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nb\">mkdir</span> <span class=\"nt\">-p</span> stalwart-mail/docker-data/<span class=\"o\">{</span>etc,data<span class=\"o\">}</span>\n<span class=\"nb\">cd </span>stalwart-mail\n</code></pre></div></div>\n\n<div data-file=\"stalwart-mail/.env\" class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nb\">cat</span> <span class=\"o\">&gt;</span> .env <span class=\"o\">&lt;&lt;</span><span class=\"sh\">'</span><span class=\"no\">EOF</span><span class=\"sh\">'\nMAIL_HOSTNAME=mail.example.com\nMAIL_DOMAIN=example.com\nSTALWART_IMAGE_TAG=v0.16\nSTALWART_ADMIN_PORT=8088\nTZ=UTC\n</span><span class=\"no\">EOF\n</span></code></pre></div></div>\n\n<div data-file=\"stalwart-mail/docker-compose.yml\" class=\"language-yaml highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"na\">services</span><span class=\"pi\">:</span>\n  <span class=\"na\">stalwart</span><span class=\"pi\">:</span>\n    <span class=\"na\">image</span><span class=\"pi\">:</span> <span class=\"s\">stalwartlabs/stalwart:${STALWART_IMAGE_TAG:-v0.16}</span>\n    <span class=\"na\">container_name</span><span class=\"pi\">:</span> <span class=\"s\">stalwart</span>\n    <span class=\"na\">hostname</span><span class=\"pi\">:</span> <span class=\"s\">${MAIL_HOSTNAME:-mail.example.com}</span>\n    <span class=\"na\">restart</span><span class=\"pi\">:</span> <span class=\"s\">unless-stopped</span>\n    <span class=\"na\">environment</span><span class=\"pi\">:</span>\n      <span class=\"pi\">-</span> <span class=\"s\">TZ=${TZ:-UTC}</span>\n      <span class=\"pi\">-</span> <span class=\"s\">STALWART_RECOVERY_ADMIN=${STALWART_RECOVERY_ADMIN:-}</span>\n    <span class=\"na\">ports</span><span class=\"pi\">:</span>\n      <span class=\"pi\">-</span> <span class=\"s2\">\"</span><span class=\"s\">25:25\"</span>\n      <span class=\"pi\">-</span> <span class=\"s2\">\"</span><span class=\"s\">465:465\"</span>\n      <span class=\"pi\">-</span> <span class=\"s2\">\"</span><span class=\"s\">587:587\"</span>\n      <span class=\"pi\">-</span> <span class=\"s2\">\"</span><span class=\"s\">143:143\"</span>\n      <span class=\"pi\">-</span> <span class=\"s2\">\"</span><span class=\"s\">993:993\"</span>\n      <span class=\"pi\">-</span> <span class=\"s2\">\"</span><span class=\"s\">4190:4190\"</span>\n      <span class=\"pi\">-</span> <span class=\"s2\">\"</span><span class=\"s\">127.0.0.1:${STALWART_ADMIN_PORT:-8088}:8080\"</span>\n    <span class=\"na\">volumes</span><span class=\"pi\">:</span>\n      <span class=\"pi\">-</span> <span class=\"s\">./docker-data/etc:/etc/stalwart</span>\n      <span class=\"pi\">-</span> <span class=\"s\">./docker-data/data:/var/lib/stalwart</span>\n    <span class=\"na\">mem_limit</span><span class=\"pi\">:</span> <span class=\"s\">1g</span>\n    <span class=\"na\">cpus</span><span class=\"pi\">:</span> <span class=\"s2\">\"</span><span class=\"s\">1.0\"</span>\n</code></pre></div></div>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>docker compose up <span class=\"nt\">-d</span>\ndocker compose logs stalwart <span class=\"nt\">--tail</span><span class=\"o\">=</span>80\n</code></pre></div></div>\n\n<p>On first boot the log prints a <strong>bootstrap</strong> admin. It exists only until the wizard finishes. Open an SSH tunnel if you are not on the VPS:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>ssh <span class=\"nt\">-L</span> 8088:127.0.0.1:8088 user@your-vps\n<span class=\"c\"># then browse http://127.0.0.1:8088/admin</span>\n</code></pre></div></div>\n\n<p>Wizard choices that matter:</p>\n\n<ol>\n  <li>Hostname <code class=\"language-plaintext highlighter-rouge\">mail.example.com</code>, domain <code class=\"language-plaintext highlighter-rouge\">example.com</code>.</li>\n  <li><strong>Automatically obtain TLS certificate: OFF</strong> if anything else already owns host <code class=\"language-plaintext highlighter-rouge\">:443</code>.</li>\n  <li>Generate DKIM keys: <strong>ON</strong>.</li>\n  <li>RocksDB defaults, logging to console, DNS = manual.</li>\n  <li>Save the <strong>permanent</strong> admin password from the last screen. The bootstrap password dies after this.</li>\n  <li><code class=\"language-plaintext highlighter-rouge\">docker compose restart stalwart</code>.</li>\n</ol>\n\n<p>In Account Manager create at least:</p>\n\n<ul>\n  <li><code class=\"language-plaintext highlighter-rouge\">you@example.com</code> — the inbox you will use</li>\n  <li><code class=\"language-plaintext highlighter-rouge\">noreply@example.com</code> — if an app will send mail</li>\n</ul>\n\n<div class=\"callout callout--gotcha\">\n  <p><strong>Two TLS places.</strong> nginx (or Caddy) can terminate <code class=\"language-plaintext highlighter-rouge\">https://mail.example.com</code> for the admin UI. Phones and Thunderbird talking to 993/465 talk to <strong>Stalwart directly</strong>. If you only cert the proxy, Android and Gmail’s IMAP setup see a self-signed listener. Upload the same Let’s Encrypt fullchain + key in Stalwart → Settings → Server → TLS, and set it as the default certificate.</p>\n</div>\n\n<p>If you already have nginx on the host, add a vhost that proxies to the container on the Docker network (join Stalwart to that network, or proxy to <code class=\"language-plaintext highlighter-rouge\">127.0.0.1:8088</code> from the host). Issue <code class=\"language-plaintext highlighter-rouge\">mail.example.com</code> with the same certbot you use for the site. Then copy <code class=\"language-plaintext highlighter-rouge\">fullchain.pem</code> / <code class=\"language-plaintext highlighter-rouge\">privkey.pem</code> into Stalwart after every renew.</p>\n\n<p class=\"verify\"><strong>Verify.</strong> <code class=\"language-plaintext highlighter-rouge\">docker compose ps</code> shows <code class=\"language-plaintext highlighter-rouge\">stalwart</code> running, <code class=\"language-plaintext highlighter-rouge\">ss -lnt</code> lists 25/465/587/993, and <code class=\"language-plaintext highlighter-rouge\">http://127.0.0.1:8088/admin</code> accepts the permanent admin login.</p>\n\n<h2 id=\"step-3--publish-dkim-and-send-a-real-message\">Step 3 — Publish DKIM and send a real message</h2>\n\n<p>In the admin UI: Management → Domains → <code class=\"language-plaintext highlighter-rouge\">example.com</code> → ⋮ → <strong>View DNS zone file</strong>. Paste the DKIM TXT records (and any SPF/DMARC extras it suggests) at your registrar. Selectors are often named like <code class=\"language-plaintext highlighter-rouge\">v1-ed25519-YYYYMMDD</code> and <code class=\"language-plaintext highlighter-rouge\">v1-rsa-YYYYMMDD</code>. Copy them exactly.</p>\n\n<p>Optional but useful:</p>\n\n<table>\n  <thead>\n    <tr>\n      <th>Type</th>\n      <th>Name</th>\n      <th>Value</th>\n    </tr>\n  </thead>\n  <tbody>\n    <tr>\n      <td>SRV</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">_imaps._tcp</code></td>\n      <td><code class=\"language-plaintext highlighter-rouge\">0 1 993 mail.example.com.</code></td>\n    </tr>\n    <tr>\n      <td>SRV</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">_submissions._tcp</code></td>\n      <td><code class=\"language-plaintext highlighter-rouge\">0 1 465 mail.example.com.</code></td>\n    </tr>\n    <tr>\n      <td>CNAME</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">autoconfig</code></td>\n      <td><code class=\"language-plaintext highlighter-rouge\">mail.example.com.</code></td>\n    </tr>\n    <tr>\n      <td>CNAME</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">autodiscover</code></td>\n      <td><code class=\"language-plaintext highlighter-rouge\">mail.example.com.</code></td>\n    </tr>\n  </tbody>\n</table>\n\n<p>Wait for TTL, then point a real client at the box. Thunderbird, Apple Mail, and FairEmail behave. The Gmail Android app often authenticates on SMTP and never sends <code class=\"language-plaintext highlighter-rouge\">MAIL FROM</code>, so mail sits in Outbox — prefer FairEmail on Android.</p>\n\n<table>\n  <thead>\n    <tr>\n      <th>Setting</th>\n      <th>Value</th>\n    </tr>\n  </thead>\n  <tbody>\n    <tr>\n      <td>IMAP</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">mail.example.com</code> port <strong>993</strong> SSL/TLS</td>\n    </tr>\n    <tr>\n      <td>SMTP</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">mail.example.com</code> port <strong>465</strong> SSL/TLS (or 587 STARTTLS)</td>\n    </tr>\n    <tr>\n      <td>Username</td>\n      <td>the full address, <code class=\"language-plaintext highlighter-rouge\">you@example.com</code></td>\n    </tr>\n    <tr>\n      <td>Password</td>\n      <td>the mailbox password, not the admin password</td>\n    </tr>\n    <tr>\n      <td>Port 25</td>\n      <td>do not use this on a phone; it is MX only</td>\n    </tr>\n  </tbody>\n</table>\n\n<p>Send from Gmail <strong>to</strong> <code class=\"language-plaintext highlighter-rouge\">you@example.com</code> and confirm it arrives. Reply. In Gmail, open the reply → three dots → <strong>Show original</strong>. You want <code class=\"language-plaintext highlighter-rouge\">SPF: PASS</code>, <code class=\"language-plaintext highlighter-rouge\">DKIM: PASS</code>, <code class=\"language-plaintext highlighter-rouge\">DMARC: PASS</code>.</p>\n\n<p>For an app:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nv\">SMTP_HOST</span><span class=\"o\">=</span>mail.example.com\n<span class=\"nv\">SMTP_PORT</span><span class=\"o\">=</span>465\n<span class=\"nv\">SMTP_USER</span><span class=\"o\">=</span>noreply@example.com\n<span class=\"nv\">SMTP_PASSWORD</span><span class=\"o\">=</span>the-mailbox-password\n</code></pre></div></div>\n\n<p class=\"verify\"><strong>Verify.</strong> A message you sent from <code class=\"language-plaintext highlighter-rouge\">you@example.com</code> arrives at a Gmail address, and Show original reports SPF, DKIM, and DMARC all PASS. Inbox versus spam is a reputation question; PASS on the auth trio is the server question.</p>\n\n<h2 id=\"failure-modes\">Failure modes</h2>\n\n<table>\n  <thead>\n    <tr>\n      <th>Symptom</th>\n      <th>Cause</th>\n      <th>Repair</th>\n    </tr>\n  </thead>\n  <tbody>\n    <tr>\n      <td>Wizard wants 443 and the site dies</td>\n      <td>ACME left on</td>\n      <td>Re-run setup with auto-TLS off; keep 80/443 on the existing proxy</td>\n    </tr>\n    <tr>\n      <td>IMAP/SMTP clients warn about the cert</td>\n      <td>Cert only on nginx, not in Stalwart</td>\n      <td>Upload the LE chain as a Stalwart Certificate object</td>\n    </tr>\n    <tr>\n      <td>Nothing inbound</td>\n      <td>Provider blocks 25, or MX/A wrong</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">nc -vz mail.example.com 25</code> from outside; fix SG / MX</td>\n    </tr>\n    <tr>\n      <td>Outbound stuck / greylisted</td>\n      <td>No PTR, or SPF does not name this host</td>\n      <td>Set PTR to <code class=\"language-plaintext highlighter-rouge\">mail.example.com</code>; <code class=\"language-plaintext highlighter-rouge\">dig -x</code> must match</td>\n    </tr>\n    <tr>\n      <td>DKIM fail</td>\n      <td>Zone file not published, or wrong selector</td>\n      <td>Re-copy the TXT from the domain’s DNS zone view</td>\n    </tr>\n    <tr>\n      <td>Gmail app Outbox never sends</td>\n      <td>Gmail IMAP SMTP quirk</td>\n      <td>Use FairEmail or Thunderbird; 465 + full username</td>\n    </tr>\n    <tr>\n      <td>Bootstrap password gone</td>\n      <td>Wizard already completed</td>\n      <td>Use the permanent admin from the last wizard screen</td>\n    </tr>\n  </tbody>\n</table>\n\n<h2 id=\"clean-up-and-operating-consequence\">Clean up and operating consequence</h2>\n\n<p>Back up both bind mounts. They are the mailboxes and the config.</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nb\">tar </span>czf stalwart-backup-<span class=\"si\">$(</span><span class=\"nb\">date</span> +%F<span class=\"si\">)</span>.tgz <span class=\"nt\">-C</span> stalwart-mail docker-data\n</code></pre></div></div>\n\n<p>Pin <code class=\"language-plaintext highlighter-rouge\">v0.16</code> (or a newer minor after reading the upgrade notes). After certbot renews <code class=\"language-plaintext highlighter-rouge\">mail.example.com</code>, copy the new fullchain and key into Stalwart again. Move DMARC from <code class=\"language-plaintext highlighter-rouge\">p=none</code> to <code class=\"language-plaintext highlighter-rouge\">p=quarantine</code> once Show original has been clean for a while.</p>\n\n<p>To leave: <code class=\"language-plaintext highlighter-rouge\">docker compose down</code>, revert MX to your previous host, and keep the tarball if you might restore.</p>\n\n<p>You now have three free OSS services that usually cost a subscription: <a href=\"/writing/self-host-rustdesk-relay-udp-21116/\">remote desktop</a>, <a href=\"/writing/wg-easy-v15-latest-is-still-v14/\">VPN</a>, and mail for your own domain. They fit on one small VPS if you keep 80/443 for the website, 51821 and 8088 off the public internet, and treat DNS plus one backup of keys/mail data as the real state.</p>\n",
      "summary": "Want a free mailbox for your domain? Run Stalwart on Docker, skip ACME when 443 is taken, publish SPF/DKIM/DMARC, and prove a Gmail round-trip.",
      "date_published": "2026-08-04T09:00:00+05:45",
      "date_modified": "2026-08-11T11:30:00+05:45",
      "tags": ["self-hosted"],
      "authors": [
        {
          "name": "Basant Bhattarai",
          "url": "https://basantbhattarai.com.np"
        }
      ]
    },
    {
      "id": "https://basantbhattarai.com.np/writing/failover-lab-six-engines-eight-scenarios/",
      "url": "https://basantbhattarai.com.np/writing/failover-lab-six-engines-eight-scenarios/",
      "title": "A failover lab you can run on one laptop",
      "content_html": "<h2 id=\"the-claim-i-wanted-to-check\">The claim I wanted to check</h2>\n\n<p>Every high-availability document ends with the same sentence in a different typeface: the cluster tolerates the loss of a node. It almost never says which node, how long the gap is, whether the write you just acknowledged is still there afterwards, or what the survivors do about writes while an election runs.</p>\n\n<p>So I wrote a test plan with eight scenarios and ran all of them on one machine, one stack at a time, inside a Docker daemon with 8 CPU and 12.5 GB of RAM. Every cluster was brought up, exercised, and torn down before the next one started, because 12.5 GB does not hold two of these at once.</p>\n\n<p>8 of 8 scenarios passed — <strong>in my own test plan</strong>. That qualifier is the honest one. There is no external conformance suite behind this. I decided what “passed” meant for each engine, wrote it down, and then went and checked it. What follows is the rig, the plan, the numbers it produced, and one scenario run end to end so you can reproduce the method on the other seven.</p>\n\n<p>The compose files are reproduced inline in this post and in the rest of the series. There is no public repository to clone.</p>\n\n<h2 id=\"why-these-six-engines\">Why these six engines</h2>\n\n<p>Six engines, eight scenarios. Redis appears twice because it ships two entirely different failure models, and the last row is the observability stack that watches the others.</p>\n\n<ul>\n  <li><strong>MongoDB 8.0</strong> — a leader-based replica set with RAFT-like elections. This is the “the driver handles it” model, and it is where <code class=\"language-plaintext highlighter-rouge\">w: \"majority\"</code> earns its keep.</li>\n  <li><strong>ScyllaDB 2026.1</strong> — leaderless, quorum per query, RF=3. Nothing gets promoted because nothing was ever a leader. The interesting failure here is not the node loss; it is the restore.</li>\n  <li><strong>SolrCloud 9</strong> — ZooKeeper-coordinated shards and replicas. Its distinctive behavior is that “degraded” and “unavailable” are genuinely different states, and it will tell you which one you are in.</li>\n  <li><strong>MariaDB 11.8 + Galera 4</strong> — virtually synchronous multi-master with certification-based conflict resolution. The only engine in the set where a <em>graceful</em> shutdown and an <em>ungraceful</em> kill of the same node produce opposite cluster states.</li>\n  <li><strong>Redis 8</strong> — Cluster (16,384 hash slots, per-shard failover) and Sentinel (one primary, quorum-based promotion). Two promises, two failure models, one product.</li>\n  <li><strong>PostgreSQL 18</strong> — single primary, streaming replication, and a promotion that you or your orchestrator has to initiate. The one engine here that will not fail over on its own.</li>\n</ul>\n\n<h2 id=\"step-1--size-the-host\">Step 1 — Size the host</h2>\n\n<p>Every stack in this series is deliberately shaped to fit an 8 CPU / 12.5 GB daemon with exactly one stack running. ScyllaDB nodes are pinned to one shard and 1200 MB each; the Galera nodes and the six Redis nodes are similarly small. If Docker has less than this, containers will start and then be OOM-killed halfway through a bootstrap, which looks exactly like a cluster bug and is not one.</p>\n\n<p>On Docker Desktop this lives in Settings → Resources. Set CPUs to 8 and memory to 12.5 GB, then apply and restart.</p>\n\n<p class=\"verify\"><strong>Verify.</strong></p>\n\n<div class=\"language-console highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"gp\">$</span><span class=\"w\"> </span>docker info | <span class=\"nb\">grep</span> <span class=\"nt\">-E</span> <span class=\"s2\">\"^ CPUs|^ Total Memory\"</span>\n<span class=\"go\"> CPUs: 8\n Total Memory: 12.5GiB\n</span></code></pre></div></div>\n\n<p>If <code class=\"language-plaintext highlighter-rouge\">Total Memory</code> reads lower than that, the daemon did not pick up the change — apply and restart it again before continuing.</p>\n\n<h2 id=\"step-2--raise-the-aio-limit\">Step 2 — Raise the AIO limit</h2>\n\n<p>ScyllaDB’s Seastar runtime allocates asynchronous I/O contexts per reactor at startup. The Linux VM behind Docker Desktop ships with a conservative <code class=\"language-plaintext highlighter-rouge\">fs.aio-max-nr</code>, and it is a <em>kernel-wide</em> limit, so the first two Scylla nodes started fine and the third one died with:</p>\n\n<div class=\"language-text highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>system does not satisfy minimum AIO requirements\n</code></pre></div></div>\n\n<p>The limit belongs to the VM, not to any one container, so you raise it from a privileged throwaway container.</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>docker run <span class=\"nt\">--rm</span> <span class=\"nt\">--privileged</span> alpine sysctl <span class=\"nt\">-w</span> fs.aio-max-nr<span class=\"o\">=</span>1048576\n</code></pre></div></div>\n\n<p class=\"verify\"><strong>Verify.</strong></p>\n\n<div class=\"language-console highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"gp\">$</span><span class=\"w\"> </span>docker run <span class=\"nt\">--rm</span> <span class=\"nt\">--privileged</span> alpine sysctl <span class=\"nt\">-w</span> fs.aio-max-nr<span class=\"o\">=</span>1048576\n<span class=\"go\">fs.aio-max-nr = 1048576\n</span></code></pre></div></div>\n\n<div class=\"callout callout--gotcha\">\n  <p><strong>Gotcha.</strong> This setting lives inside the Docker VM, not on your machine. Restarting Docker Desktop resets it, and the symptom comes back looking like a fresh problem. If a Scylla node refuses to start on a day when it worked yesterday, re-run the privileged container before you debug anything else.</p>\n</div>\n\n<h2 id=\"step-3--bring-up-one-stack-mongodb-three-nodes\">Step 3 — Bring up one stack: MongoDB, three nodes</h2>\n\n<p>Three <code class=\"language-plaintext highlighter-rouge\">mongod</code> processes, one replica set, no authentication. Auth is skipped deliberately: the keyfile and RBAC setup is a separate exercise, and mixing it into a failover test means every failure has two possible causes.</p>\n\n<div data-file=\"mongo-ha/docker-compose.yml\" class=\"language-yaml highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"na\">name</span><span class=\"pi\">:</span> <span class=\"s\">mongo-ha</span>\n\n<span class=\"na\">services</span><span class=\"pi\">:</span>\n  <span class=\"na\">mongo1</span><span class=\"pi\">:</span>\n    <span class=\"na\">image</span><span class=\"pi\">:</span> <span class=\"s\">mongo:8.0</span>\n    <span class=\"na\">command</span><span class=\"pi\">:</span> <span class=\"pi\">[</span><span class=\"s2\">\"</span><span class=\"s\">mongod\"</span><span class=\"pi\">,</span> <span class=\"s2\">\"</span><span class=\"s\">--replSet\"</span><span class=\"pi\">,</span> <span class=\"s2\">\"</span><span class=\"s\">rs0\"</span><span class=\"pi\">,</span> <span class=\"s2\">\"</span><span class=\"s\">--bind_ip_all\"</span><span class=\"pi\">,</span> <span class=\"s2\">\"</span><span class=\"s\">--port\"</span><span class=\"pi\">,</span> <span class=\"s2\">\"</span><span class=\"s\">27017\"</span><span class=\"pi\">]</span>\n    <span class=\"na\">ports</span><span class=\"pi\">:</span> <span class=\"pi\">[</span><span class=\"s2\">\"</span><span class=\"s\">27021:27017\"</span><span class=\"pi\">]</span>\n    <span class=\"na\">volumes</span><span class=\"pi\">:</span> <span class=\"pi\">[</span><span class=\"s2\">\"</span><span class=\"s\">m1:/data/db\"</span><span class=\"pi\">]</span>\n  <span class=\"na\">mongo2</span><span class=\"pi\">:</span>\n    <span class=\"na\">image</span><span class=\"pi\">:</span> <span class=\"s\">mongo:8.0</span>\n    <span class=\"na\">command</span><span class=\"pi\">:</span> <span class=\"pi\">[</span><span class=\"s2\">\"</span><span class=\"s\">mongod\"</span><span class=\"pi\">,</span> <span class=\"s2\">\"</span><span class=\"s\">--replSet\"</span><span class=\"pi\">,</span> <span class=\"s2\">\"</span><span class=\"s\">rs0\"</span><span class=\"pi\">,</span> <span class=\"s2\">\"</span><span class=\"s\">--bind_ip_all\"</span><span class=\"pi\">,</span> <span class=\"s2\">\"</span><span class=\"s\">--port\"</span><span class=\"pi\">,</span> <span class=\"s2\">\"</span><span class=\"s\">27017\"</span><span class=\"pi\">]</span>\n    <span class=\"na\">ports</span><span class=\"pi\">:</span> <span class=\"pi\">[</span><span class=\"s2\">\"</span><span class=\"s\">27022:27017\"</span><span class=\"pi\">]</span>\n    <span class=\"na\">volumes</span><span class=\"pi\">:</span> <span class=\"pi\">[</span><span class=\"s2\">\"</span><span class=\"s\">m2:/data/db\"</span><span class=\"pi\">]</span>\n  <span class=\"na\">mongo3</span><span class=\"pi\">:</span>\n    <span class=\"na\">image</span><span class=\"pi\">:</span> <span class=\"s\">mongo:8.0</span>\n    <span class=\"na\">command</span><span class=\"pi\">:</span> <span class=\"pi\">[</span><span class=\"s2\">\"</span><span class=\"s\">mongod\"</span><span class=\"pi\">,</span> <span class=\"s2\">\"</span><span class=\"s\">--replSet\"</span><span class=\"pi\">,</span> <span class=\"s2\">\"</span><span class=\"s\">rs0\"</span><span class=\"pi\">,</span> <span class=\"s2\">\"</span><span class=\"s\">--bind_ip_all\"</span><span class=\"pi\">,</span> <span class=\"s2\">\"</span><span class=\"s\">--port\"</span><span class=\"pi\">,</span> <span class=\"s2\">\"</span><span class=\"s\">27017\"</span><span class=\"pi\">]</span>\n    <span class=\"na\">ports</span><span class=\"pi\">:</span> <span class=\"pi\">[</span><span class=\"s2\">\"</span><span class=\"s\">27023:27017\"</span><span class=\"pi\">]</span>\n    <span class=\"na\">volumes</span><span class=\"pi\">:</span> <span class=\"pi\">[</span><span class=\"s2\">\"</span><span class=\"s\">m3:/data/db\"</span><span class=\"pi\">]</span>\n\n<span class=\"na\">volumes</span><span class=\"pi\">:</span>\n  <span class=\"na\">m1</span><span class=\"pi\">:</span>\n  <span class=\"na\">m2</span><span class=\"pi\">:</span>\n  <span class=\"na\">m3</span><span class=\"pi\">:</span>\n</code></pre></div></div>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>docker compose <span class=\"nt\">-f</span> mongo-ha/docker-compose.yml up <span class=\"nt\">-d</span>\n</code></pre></div></div>\n\n<p>Then initiate the set. <code class=\"language-plaintext highlighter-rouge\">mongo1</code> gets priority 2 so that the set has a <em>preferred</em> primary — that is what makes the failback at the end of Step 5 observable rather than random.</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>docker <span class=\"nb\">exec </span>mongo-ha-mongo1-1 mongosh <span class=\"nt\">--quiet</span> <span class=\"nt\">--eval</span> <span class=\"s1\">'\nrs.initiate({\n  _id: \"rs0\",\n  members: [\n    { _id: 0, host: \"mongo1:27017\", priority: 2 },\n    { _id: 1, host: \"mongo2:27017\", priority: 1 },\n    { _id: 2, host: \"mongo3:27017\", priority: 1 }\n  ]\n})'</span>\n</code></pre></div></div>\n\n<p>I use a three-line status helper throughout, because <code class=\"language-plaintext highlighter-rouge\">rs.status()</code> in full is several hundred lines and the three fields that matter are name, state and health.</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>docker <span class=\"nb\">exec </span>mongo-ha-mongo1-1 mongosh <span class=\"nt\">--quiet</span> <span class=\"nt\">--eval</span> <span class=\"s1\">'\nconst s = rs.status();\nprint(\"set: \" + s.set);\ns.members.forEach(m =&gt; print(\"  \" + m.name + \"  \" + m.stateStr + \"  health=\" + m.health));'</span>\n</code></pre></div></div>\n\n<p class=\"verify\"><strong>Verify.</strong> Within about ten seconds of <code class=\"language-plaintext highlighter-rouge\">rs.initiate()</code>:</p>\n\n<div class=\"language-console highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"go\">set: rs0\n  mongo1:27017  PRIMARY    health=1\n  mongo2:27017  SECONDARY  health=1\n  mongo3:27017  SECONDARY  health=1\n</span></code></pre></div></div>\n\n<p>If all three read <code class=\"language-plaintext highlighter-rouge\">SECONDARY</code>, the election has not finished — wait and run it again. If one reads <code class=\"language-plaintext highlighter-rouge\">STARTUP</code>, that member never got the config; check that the hostnames in <code class=\"language-plaintext highlighter-rouge\">rs.initiate()</code> match the compose service names.</p>\n\n<h2 id=\"step-4--stop-the-preferred-primary\">Step 4 — Stop the preferred primary</h2>\n\n<p>This first pass uses a controlled loss, not a crash: <code class=\"language-plaintext highlighter-rouge\">docker stop</code> sends SIGTERM and lets <code class=\"language-plaintext highlighter-rouge\">mongod</code> shut down cleanly. That isolates election behavior from recovery after an unclean process exit. A separate crash test would use <code class=\"language-plaintext highlighter-rouge\">docker kill --signal=KILL</code> and should be recorded as a different scenario. The distinction matters enormously for Galera; for this MongoDB election, both paths should leave the same two-member majority able to elect.</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>docker stop mongo-ha-mongo1-1\n</code></pre></div></div>\n\n<p class=\"verify\"><strong>Verify.</strong> Re-run the status helper against a surviving member:</p>\n\n<div class=\"language-console highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"go\">set: rs0\n  mongo1:27017  (not reachable/healthy)  health=0\n  mongo2:27017  PRIMARY                  health=1\n  mongo3:27017  SECONDARY                health=1\n</span></code></pre></div></div>\n\n<p><code class=\"language-plaintext highlighter-rouge\">mongo2</code> and <code class=\"language-plaintext highlighter-rouge\">mongo3</code> are two of three members, which is a majority, so they can hold an election and elect one of themselves. If you had killed two of three, you would see all survivors sitting at <code class=\"language-plaintext highlighter-rouge\">SECONDARY</code> forever, refusing writes — that is the correct behavior, not a hang.</p>\n\n<div class=\"callout callout--gotcha\">\n  <p><strong>Gotcha.</strong> The election is not instantaneous, and during it there is no primary. A client that treats a connection error as fatal will surface a failure to the user even though the set recovers on its own moments later. This is why retryable writes and a full replica-set connection string exist. Testing failover against a single-host URI tests nothing.</p>\n</div>\n\n<h2 id=\"step-5--prove-the-write-survived\">Step 5 — Prove the write survived</h2>\n\n<p>A failover that loses acknowledged writes is not a failover, it is data loss with good manners. <code class=\"language-plaintext highlighter-rouge\">w: \"majority\"</code> is the setting that makes the promise: the write is acknowledged only once a majority of members have it, so any node that can win a subsequent election already has it.</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>docker <span class=\"nb\">exec </span>mongo-ha-mongo2-1 mongosh <span class=\"nt\">--quiet</span> <span class=\"nt\">--eval</span> <span class=\"s1\">'\ndb.getSiblingDB(\"poc\").orders.insertOne(\n  { n: 1 },\n  { writeConcern: { w: \"majority\", j: true } }\n);\nprint(\"write after failover: OK\");'</span>\n</code></pre></div></div>\n\n<p class=\"verify\"><strong>Verify.</strong></p>\n\n<div class=\"language-console highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"go\">write after failover: OK\n</span></code></pre></div></div>\n\n<p>On the full run I did the same thing at volume — 1000 documents written with <code class=\"language-plaintext highlighter-rouge\">w: \"majority\", j: true</code>, then read back from a secondary, which returned 1000 documents replicated. The single insert above is the fast version of the same check.</p>\n\n<p>Now bring the old primary back:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>docker start mongo-ha-mongo1-1\n</code></pre></div></div>\n\n<p class=\"verify\"><strong>Verify.</strong> It rejoins as a secondary, catches up from the oplog, and then reclaims the primary role because its priority is 2:</p>\n\n<div class=\"language-console highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"go\">set: rs0\n  mongo1:27017  PRIMARY    health=1\n  mongo2:27017  SECONDARY  health=1\n  mongo3:27017  SECONDARY  health=1\n</span></code></pre></div></div>\n\n<p>That failback is a second election, with a second window where there is no primary. Priorities are useful for keeping a preferred node in front of traffic; they are not free.</p>\n\n<h2 id=\"step-6--clean-up-before-the-next-stack\">Step 6 — Clean up before the next stack</h2>\n\n<p>This is the step that keeps the rig usable. <code class=\"language-plaintext highlighter-rouge\">down</code> without <code class=\"language-plaintext highlighter-rouge\">-v</code> leaves the volumes behind, and nine stacks of orphaned volumes is a lot of disk.</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>docker compose <span class=\"nt\">-f</span> mongo-ha/docker-compose.yml down <span class=\"nt\">-v</span>\n</code></pre></div></div>\n\n<p class=\"verify\"><strong>Verify.</strong></p>\n\n<div class=\"language-console highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"gp\">$</span><span class=\"w\"> </span>docker volume <span class=\"nb\">ls</span> <span class=\"nt\">--filter</span> <span class=\"nv\">name</span><span class=\"o\">=</span>mongo-ha\n<span class=\"go\">DRIVER    VOLUME NAME\n</span></code></pre></div></div>\n\n<p>An empty listing under the header is what you want. To clear the whole series in one go:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"k\">for </span>d <span class=\"k\">in </span>mongo-ha mongo-shard scylla-ha solr-ha galera-ha redis-cluster <span class=\"se\">\\</span>\n         redis-sentinel pg-replication observability<span class=\"p\">;</span> <span class=\"k\">do\n  </span>docker compose <span class=\"nt\">-f</span> <span class=\"nv\">$d</span>/docker-compose.yml down <span class=\"nt\">-v</span> 2&gt;/dev/null\n<span class=\"k\">done</span>\n</code></pre></div></div>\n\n<h2 id=\"the-eight-scenarios-and-the-results\">The eight scenarios, and the results</h2>\n\n<table>\n  <thead>\n    <tr>\n      <th>Engine</th>\n      <th>HA / cluster</th>\n      <th>Sharding</th>\n      <th>Backup / restore</th>\n      <th>Monitoring</th>\n      <th>Result</th>\n    </tr>\n  </thead>\n  <tbody>\n    <tr>\n      <td>MongoDB 8.0</td>\n      <td>3-node replica set, auto-failover</td>\n      <td>hashed-key sharded cluster (2 shards)</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">mongodump</code> / <code class=\"language-plaintext highlighter-rouge\">mongorestore</code></td>\n      <td>serverStatus, repl lag, oplog</td>\n      <td>PASS</td>\n    </tr>\n    <tr>\n      <td>ScyllaDB 2026.1</td>\n      <td>3-node, RF=3, QUORUM survives node loss</td>\n      <td>token ring, 256 vnodes/node</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">nodetool snapshot</code> + <code class=\"language-plaintext highlighter-rouge\">refresh</code></td>\n      <td><code class=\"language-plaintext highlighter-rouge\">nodetool info</code> / <code class=\"language-plaintext highlighter-rouge\">tablestats</code>, <code class=\"language-plaintext highlighter-rouge\">:9180</code></td>\n      <td>PASS</td>\n    </tr>\n    <tr>\n      <td>SolrCloud 9</td>\n      <td>ZK + 2 nodes, 2×2 shards/replicas</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">numShards=2</code></td>\n      <td>collection BACKUP / RESTORE</td>\n      <td>metrics API, solr-exporter</td>\n      <td>PASS</td>\n    </tr>\n    <tr>\n      <td>MariaDB 11.8 + Galera 4</td>\n      <td>3-node multi-master, quorum, IST rejoin</td>\n      <td>n/a (full replicas)</td>\n      <td>mariabackup SST</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">wsrep_*</code> status vars</td>\n      <td>PASS</td>\n    </tr>\n    <tr>\n      <td>Redis 8 Cluster</td>\n      <td>3 primaries + 3 replicas, auto-failover</td>\n      <td>16,384 hash slots, hash tags</td>\n      <td>AOF per node</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">CLUSTER INFO</code> / <code class=\"language-plaintext highlighter-rouge\">SHARDS</code></td>\n      <td>PASS</td>\n    </tr>\n    <tr>\n      <td>Redis 8 Sentinel</td>\n      <td>1 primary + 2 replicas + 3 sentinels</td>\n      <td>n/a</td>\n      <td>AOF / RDB</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">SENTINEL master</code> / <code class=\"language-plaintext highlighter-rouge\">replicas</code></td>\n      <td>PASS</td>\n    </tr>\n    <tr>\n      <td>PostgreSQL 18</td>\n      <td>primary + hot standby, <code class=\"language-plaintext highlighter-rouge\">pg_promote()</code> failover</td>\n      <td>n/a</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">pg_basebackup</code> + WAL / PITR</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">pg_stat_replication</code>, lag</td>\n      <td>PASS</td>\n    </tr>\n    <tr>\n      <td>Observability</td>\n      <td>exporters → Prometheus → Grafana</td>\n      <td>n/a</td>\n      <td>n/a</td>\n      <td>golden-signals dashboard</td>\n      <td>PASS</td>\n    </tr>\n  </tbody>\n</table>\n\n<p>Four columns per engine, because “does it fail over” is only a quarter of the question. A cluster that survives a node loss but cannot be restored from a backup is not highly available, it is briefly lucky.</p>\n\n<h2 id=\"the-numbers-worth-remembering\">The numbers worth remembering</h2>\n\n<p>These are the measurements, not the marketing. All of them came off this rig.</p>\n\n<p><strong>PostgreSQL 18 streaming replication</strong> reported <code class=\"language-plaintext highlighter-rouge\">write_lag ≈ 104 µs</code> and <code class=\"language-plaintext highlighter-rouge\">replay_lag ≈ 383 µs</code> in <code class=\"language-plaintext highlighter-rouge\">pg_stat_replication</code>, with replica lag of 0 bytes and the slot showing <code class=\"language-plaintext highlighter-rouge\">active=t</code>, <code class=\"language-plaintext highlighter-rouge\">wal_status=reserved</code>. <code class=\"language-plaintext highlighter-rouge\">pg_promote()</code> returned <code class=\"language-plaintext highlighter-rouge\">t</code>, <code class=\"language-plaintext highlighter-rouge\">pg_is_in_recovery()</code> flipped to <code class=\"language-plaintext highlighter-rouge\">f</code>, and the timeline went from 1 to 2. Sub-millisecond replay on a single host is not a WAN number, but it establishes that the lag you see in production is network and disk, not the protocol.</p>\n\n<p><strong>Redis Sentinel</strong> promoted a replica in ~5 s with <code class=\"language-plaintext highlighter-rouge\">quorum 2</code> and <code class=\"language-plaintext highlighter-rouge\">down-after-milliseconds 5000</code>, and demoted the old primary to a replica of the new one when it came back. <strong>Redis Cluster</strong> promoted a replica after I killed a master owning slots 5461–10922, bumped the config epoch from 2 to 7, returned to <code class=\"language-plaintext highlighter-rouge\">cluster_state:ok</code> with <code class=\"language-plaintext highlighter-rouge\">cluster_slots_ok:16384</code>, and lost nothing.</p>\n\n<p><strong>MongoDB sharding</strong> on a hashed <code class=\"language-plaintext highlighter-rouge\">userId</code> split 100,000 documents 49,607 / 50,393 across two shards. A query carrying the shard key touched 1 shard; a filter on a non-key field scatter-gathered to 2.</p>\n\n<p><strong>Galera</strong> took 150 concurrent updates of a single row from two masters at once. Node 2 logged 3 certification failures, node 3 logged 5 certification failures and 2 brute-force aborts, and the counter still converged to 300 on all three nodes. Optimistic concurrency caught every conflict at commit time and the arithmetic came out right.</p>\n\n<p><strong>SolrCloud</strong> went from GREEN to ORANGE when I stopped a node — 4 active replicas, 2 down — and kept answering <code class=\"language-plaintext highlighter-rouge\">*:*</code> with <code class=\"language-plaintext highlighter-rouge\">numFound 6</code> the whole time, then recovered to GREEN on its own when the node came back.</p>\n\n<p><strong>ScyllaDB</strong> returned 3 rows at <code class=\"language-plaintext highlighter-rouge\">CONSISTENCY QUORUM</code> with one of three nodes stopped, exactly as RF=3 promises.</p>\n\n<p><strong>The observability stack</strong> scraped a Postgres exporter and a Redis exporter into Prometheus with all targets up, and reported <code class=\"language-plaintext highlighter-rouge\">rate(redis_commands_processed_total[1m])</code> of 27.7 and a 500/500 hit/miss split — a 50% cache hit ratio — through to an auto-provisioned Grafana dashboard.</p>\n\n<h2 id=\"the-rest-of-the-series\">The rest of the series</h2>\n\n<p>Two of the eight are written up in full, and both are the write-ups I went looking for and could not find while I was stuck:</p>\n\n<ul>\n  <li><a href=\"/writing/scylladb-nodetool-refresh-upload-truncate-marker/\">nodetool refresh only reads upload/ — and TRUNCATE hides your SSTables</a> — three separate ScyllaDB behaviors that each make a valid restore look empty, with the repro and the fix for all three.</li>\n  <li><a href=\"/writing/postgresql-18-docker-volume-path-change/\">PostgreSQL 18 won’t take a volume at /var/lib/postgresql/data</a> — the mount-point change that breaks every compose file copied from a PG 12–17 guide, then streaming replication, promotion and PITR on top of the corrected one.</li>\n</ul>\n\n<p>Queued from the same lab notes: the Galera <code class=\"language-plaintext highlighter-rouge\">wsrep_provider_options</code> read-only surprise and the graceful-versus-ungraceful quorum behavior; Redis Cluster against Sentinel with the timings side by side; SolrCloud’s ORANGE state and the <code class=\"language-plaintext highlighter-rouge\">solr.allowPaths</code> backup failure; and the MongoDB shard-key explain plan. They will appear under <a href=\"/writing/\">/writing/</a> as they are finished.</p>\n\n<h2 id=\"troubleshooting\">Troubleshooting</h2>\n\n<table>\n  <thead>\n    <tr>\n      <th>Symptom</th>\n      <th>Cause</th>\n      <th>Fix</th>\n    </tr>\n  </thead>\n  <tbody>\n    <tr>\n      <td>Containers start, then get OOM-killed mid-bootstrap</td>\n      <td>Two stacks running at once, or the daemon has less than 12.5 GB</td>\n      <td>Tear the previous stack down with <code class=\"language-plaintext highlighter-rouge\">docker compose down -v</code> first; run exactly one stack at a time</td>\n    </tr>\n    <tr>\n      <td>A ScyllaDB node exits with <code class=\"language-plaintext highlighter-rouge\">system does not satisfy minimum AIO requirements</code></td>\n      <td><code class=\"language-plaintext highlighter-rouge\">fs.aio-max-nr</code> in the Docker VM is too low for the number of reactors</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">docker run --rm --privileged alpine sysctl -w fs.aio-max-nr=1048576</code>, then restart the node</td>\n    </tr>\n    <tr>\n      <td>That AIO fix stops working after a restart</td>\n      <td>The sysctl lives in the Docker VM, which is recreated on restart</td>\n      <td>Re-run the privileged sysctl container after every Docker Desktop restart</td>\n    </tr>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">rs.initiate()</code> fails with “already initialized”</td>\n      <td>A volume survived an earlier run</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">docker compose -f mongo-ha/docker-compose.yml down -v</code>, then bring it up again</td>\n    </tr>\n    <tr>\n      <td>All members sit at <code class=\"language-plaintext highlighter-rouge\">SECONDARY</code> and writes are refused</td>\n      <td>No majority is reachable — two of three members are down</td>\n      <td>Restart a member; a three-node set needs two to elect</td>\n    </tr>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">not primary</code> errors on a write after a failover</td>\n      <td>The client is pinned to one host instead of the set</td>\n      <td>Use a full replica-set connection string and enable retryable writes</td>\n    </tr>\n    <tr>\n      <td>Disk keeps growing between labs</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">docker compose down</code> without <code class=\"language-plaintext highlighter-rouge\">-v</code> keeps named volumes</td>\n      <td>Always <code class=\"language-plaintext highlighter-rouge\">down -v</code>; then <code class=\"language-plaintext highlighter-rouge\">docker volume prune</code></td>\n    </tr>\n    <tr>\n      <td>A joiner never leaves the joining state</td>\n      <td>Every node was started at once, before the seed was ready</td>\n      <td>Start the seed, wait until it reports healthy, then start the joiners</td>\n    </tr>\n  </tbody>\n</table>\n",
      "summary": "Six database engines, eight failover scenarios, one 8-CPU Docker rig. The results matrix, the headline measurements, and how to run the first scenario.",
      "date_published": "2026-08-04T09:00:00+05:45",
      "date_modified": "2026-08-11T10:20:00+05:45",
      "tags": ["distributed-databases","postgres"],
      "authors": [
        {
          "name": "Basant Bhattarai",
          "url": "https://basantbhattarai.com.np"
        }
      ]
    },
    {
      "id": "https://basantbhattarai.com.np/writing/wg-easy-v15-latest-is-still-v14/",
      "url": "https://basantbhattarai.com.np/writing/wg-easy-v15-latest-is-still-v14/",
      "title": "A free VPN on your VPS with WireGuard",
      "content_html": "<h2 id=\"why-bother\">Why bother</h2>\n\n<p>Want a VPN without paying Mullvad, Tailscale, or a random “free VPN” app that sells your traffic? WireGuard is the modern OSS default: small, fast, audited crypto. <a href=\"https://github.com/wg-easy/wg-easy\">wg-easy</a> puts a web UI on top so you add a phone by scanning a QR instead of editing <code class=\"language-plaintext highlighter-rouge\">[Peer]</code> blocks.</p>\n\n<p>A $5 VPS is enough. Idle RAM is about 50 MB. You will spend more time on DNS and the firewall than on WireGuard itself.</p>\n\n<p>One trap, stated early so you do not copy the wrong blog post:</p>\n\n<div class=\"language-yaml highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"na\">image</span><span class=\"pi\">:</span> <span class=\"s\">ghcr.io/wg-easy/wg-easy:latest</span>\n<span class=\"na\">environment</span><span class=\"pi\">:</span>\n  <span class=\"pi\">-</span> <span class=\"s\">WG_HOST=vpn.example.com</span>\n  <span class=\"pi\">-</span> <span class=\"s\">PASSWORD_HASH=...</span>\n  <span class=\"pi\">-</span> <span class=\"s\">WG_ALLOWED_IPS=0.0.0.0/0</span>\n</code></pre></div></div>\n\n<p><strong><code class=\"language-plaintext highlighter-rouge\">:latest</code> still points at v14.</strong> v15 (May 2025) dropped those env vars. Config now lives in a first-run wizard (or <code class=\"language-plaintext highlighter-rouge\">INIT_*</code> for unattended setup). Only <code class=\"language-plaintext highlighter-rouge\">PORT</code>, <code class=\"language-plaintext highlighter-rouge\">HOST</code>, and <code class=\"language-plaintext highlighter-rouge\">INSECURE</code> remain as ordinary runtime env. Follow a v14 file on v15 and the hostname never sticks. Follow a v15 file on <code class=\"language-plaintext highlighter-rouge\">:latest</code> and the wizard never appears. Pin <strong><code class=\"language-plaintext highlighter-rouge\">:15</code></strong>.</p>\n\n<p>This setup is a <strong>full-tunnel exit node</strong>: your laptop and phone leave the internet from the VPS IP. Great on café Wi-Fi. It also means the VPS acceptable-use policy now applies to your browsing. Split tunnel is one Allowed-IPs change if you do not want that.</p>\n\n<h2 id=\"step-1--dns-forwarding-and-the-v15-compose-file\">Step 1 — DNS, forwarding, and the v15 compose file</h2>\n\n<p>Point <code class=\"language-plaintext highlighter-rouge\">vpn.example.com</code> at the VPS. Open <strong>UDP 51820</strong> for the tunnel. Open <strong>TCP 51821</strong> only from your current public IP. Do not put this UI on 80 or 443 if those already serve a website.</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nb\">sudo </span>ufw allow 51820/udp\n<span class=\"nb\">sudo </span>ufw allow from YOUR_PUBLIC_IP to any port 51821 proto tcp\n<span class=\"nb\">sudo </span>ufw reload\n\n<span class=\"nb\">sudo </span>sysctl <span class=\"nt\">-w</span> net.ipv4.ip_forward<span class=\"o\">=</span>1\n<span class=\"nb\">echo</span> <span class=\"s1\">'net.ipv4.ip_forward=1'</span> | <span class=\"nb\">sudo tee</span> /etc/sysctl.d/99-wireguard.conf\n<span class=\"nb\">sudo </span>sysctl <span class=\"nt\">--system</span>\n</code></pre></div></div>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nb\">mkdir</span> <span class=\"nt\">-p</span> wireguard-vpn/config\n<span class=\"nb\">cd </span>wireguard-vpn\n</code></pre></div></div>\n\n<div data-file=\"wireguard-vpn/docker-compose.yml\" class=\"language-yaml highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"na\">services</span><span class=\"pi\">:</span>\n  <span class=\"na\">wg-easy</span><span class=\"pi\">:</span>\n    <span class=\"na\">image</span><span class=\"pi\">:</span> <span class=\"s\">ghcr.io/wg-easy/wg-easy:15</span>\n    <span class=\"na\">container_name</span><span class=\"pi\">:</span> <span class=\"s\">wg-easy</span>\n    <span class=\"na\">restart</span><span class=\"pi\">:</span> <span class=\"s\">unless-stopped</span>\n    <span class=\"na\">environment</span><span class=\"pi\">:</span>\n      <span class=\"pi\">-</span> <span class=\"s\">INSECURE=true</span>\n      <span class=\"pi\">-</span> <span class=\"s\">PORT=51821</span>\n      <span class=\"pi\">-</span> <span class=\"s\">HOST=0.0.0.0</span>\n    <span class=\"na\">volumes</span><span class=\"pi\">:</span>\n      <span class=\"pi\">-</span> <span class=\"s\">./config:/etc/wireguard</span>\n      <span class=\"pi\">-</span> <span class=\"s\">/lib/modules:/lib/modules:ro</span>\n    <span class=\"na\">ports</span><span class=\"pi\">:</span>\n      <span class=\"pi\">-</span> <span class=\"s2\">\"</span><span class=\"s\">51820:51820/udp\"</span>\n      <span class=\"pi\">-</span> <span class=\"s2\">\"</span><span class=\"s\">51821:51821/tcp\"</span>\n    <span class=\"na\">cap_add</span><span class=\"pi\">:</span>\n      <span class=\"pi\">-</span> <span class=\"s\">NET_ADMIN</span>\n      <span class=\"pi\">-</span> <span class=\"s\">SYS_MODULE</span>\n    <span class=\"na\">sysctls</span><span class=\"pi\">:</span>\n      <span class=\"pi\">-</span> <span class=\"s\">net.ipv4.ip_forward=1</span>\n      <span class=\"pi\">-</span> <span class=\"s\">net.ipv4.conf.all.src_valid_mark=1</span>\n      <span class=\"pi\">-</span> <span class=\"s\">net.ipv6.conf.all.disable_ipv6=0</span>\n      <span class=\"pi\">-</span> <span class=\"s\">net.ipv6.conf.all.forwarding=1</span>\n    <span class=\"na\">mem_limit</span><span class=\"pi\">:</span> <span class=\"s\">256m</span>\n    <span class=\"na\">cpus</span><span class=\"pi\">:</span> <span class=\"s2\">\"</span><span class=\"s\">0.50\"</span>\n</code></pre></div></div>\n\n<p>Three lines are load-bearing.</p>\n\n<p><strong><code class=\"language-plaintext highlighter-rouge\">image: ...:15</code>.</strong> Not <code class=\"language-plaintext highlighter-rouge\">:latest</code>. Confirm after pull:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>docker compose pull\ndocker compose config | <span class=\"nb\">grep </span>image\n<span class=\"c\"># image: ghcr.io/wg-easy/wg-easy:15</span>\n</code></pre></div></div>\n\n<p><strong><code class=\"language-plaintext highlighter-rouge\">/lib/modules:/lib/modules:ro</code>.</strong> Required in v15 so the container can load the WireGuard kernel module. Omit it and the interface never comes up in a way that looks like a permissions problem.</p>\n\n<p><strong><code class=\"language-plaintext highlighter-rouge\">INSECURE=true</code> plus port 51821.</strong> That combination is HTTP on a high port, which is acceptable only because UFW has already limited 51821 to you. For TLS later, add a <code class=\"language-plaintext highlighter-rouge\">vpn.example.com</code> vhost to the <strong>existing</strong> reverse proxy and set <code class=\"language-plaintext highlighter-rouge\">INSECURE=false</code>. Do not start a second process on 80/443.</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>docker compose up <span class=\"nt\">-d</span>\ndocker compose logs <span class=\"nt\">--tail</span><span class=\"o\">=</span>40 wg-easy\ndocker compose <span class=\"nb\">exec </span>wg-easy wg show\n</code></pre></div></div>\n\n<p class=\"verify\"><strong>Verify.</strong> <code class=\"language-plaintext highlighter-rouge\">docker compose ps</code> shows <code class=\"language-plaintext highlighter-rouge\">wg-easy</code> up, <code class=\"language-plaintext highlighter-rouge\">wg show</code> lists <code class=\"language-plaintext highlighter-rouge\">wg0</code> listening on 51820, and <code class=\"language-plaintext highlighter-rouge\">nc -vzu vpn.example.com 51820</code> from your laptop reports that UDP is reachable.</p>\n\n<h2 id=\"step-2--wizard-full-tunnel-is-allowed-ips-not-a-compose-flag\">Step 2 — Wizard: full tunnel is Allowed IPs, not a compose flag</h2>\n\n<p>Open <code class=\"language-plaintext highlighter-rouge\">http://vpn.example.com:51821</code> from the IP you allowed. Complete the wizard:</p>\n\n<table>\n  <thead>\n    <tr>\n      <th>Setting</th>\n      <th>Value</th>\n    </tr>\n  </thead>\n  <tbody>\n    <tr>\n      <td>Admin username</td>\n      <td>yours</td>\n    </tr>\n    <tr>\n      <td>Admin password</td>\n      <td>16+ characters from a password manager</td>\n    </tr>\n    <tr>\n      <td>Host / Endpoint</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">vpn.example.com</code></td>\n    </tr>\n    <tr>\n      <td>Port</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">51820</code></td>\n    </tr>\n    <tr>\n      <td>DNS</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">1.1.1.1</code> or <code class=\"language-plaintext highlighter-rouge\">1.1.1.1,8.8.8.8</code></td>\n    </tr>\n    <tr>\n      <td>Allowed IPs</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">0.0.0.0/0, ::/0</code></td>\n    </tr>\n  </tbody>\n</table>\n\n<p><code class=\"language-plaintext highlighter-rouge\">0.0.0.0/0, ::/0</code> is the full tunnel. For split tunnel use only the VPN subnet, usually <code class=\"language-plaintext highlighter-rouge\">10.8.0.0/24</code>. Enable TOTP on the admin account before you add clients.</p>\n\n<p>Then <strong>New client</strong> → name the device → download the <code class=\"language-plaintext highlighter-rouge\">.conf</code> or show the QR. One client per device. Reusing a single config on a laptop and a phone will fight over the same internal IP.</p>\n\n<p>Unattended alternative, if you refuse the wizard: uncomment an <code class=\"language-plaintext highlighter-rouge\">INIT_*</code> block (<code class=\"language-plaintext highlighter-rouge\">INIT_ENABLED</code>, <code class=\"language-plaintext highlighter-rouge\">INIT_USERNAME</code>, <code class=\"language-plaintext highlighter-rouge\">INIT_PASSWORD</code>, <code class=\"language-plaintext highlighter-rouge\">INIT_HOST</code>, <code class=\"language-plaintext highlighter-rouge\">INIT_PORT</code>, <code class=\"language-plaintext highlighter-rouge\">INIT_DNS</code>, <code class=\"language-plaintext highlighter-rouge\">INIT_ALLOWED_IPS</code>). That is the only remaining place those values belong. They are not <code class=\"language-plaintext highlighter-rouge\">WG_*</code> anymore.</p>\n\n<div class=\"callout callout--gotcha\">\n  <p><strong>Do not copy a password into git.</strong> If you use <code class=\"language-plaintext highlighter-rouge\">INIT_PASSWORD</code>, keep it in a gitignored <code class=\"language-plaintext highlighter-rouge\">.env</code>. The compose file above uses the wizard so the repo never holds a credential.</p>\n</div>\n\n<p class=\"verify\"><strong>Verify.</strong> The UI lists the new client, and the downloaded config contains <code class=\"language-plaintext highlighter-rouge\">Endpoint = vpn.example.com:51820</code> plus <code class=\"language-plaintext highlighter-rouge\">AllowedIPs = 0.0.0.0/0, ::/0</code>.</p>\n\n<h2 id=\"step-3--import-the-client-and-prove-egress\">Step 3 — Import the client and prove egress</h2>\n\n<p><strong>Before</strong> connecting:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>curl <span class=\"nt\">-s</span> https://api.ipify.org <span class=\"o\">&amp;&amp;</span> <span class=\"nb\">echo</span>\n<span class=\"c\"># your ISP address</span>\n</code></pre></div></div>\n\n<p>Import the tunnel:</p>\n\n<ul>\n  <li><strong>macOS:</strong> WireGuard from the App Store → import the <code class=\"language-plaintext highlighter-rouge\">.conf</code> → enable “Activate on system startup” if you want it after reboot.</li>\n  <li><strong>Linux:</strong> <code class=\"language-plaintext highlighter-rouge\">sudo cp client.conf /etc/wireguard/wg0.conf &amp;&amp; sudo systemctl enable --now wg-quick@wg0</code></li>\n  <li><strong>Windows:</strong> official client → import → “Enable on boot”.</li>\n  <li><strong>iOS / Android:</strong> scan the QR, then turn on Always-on / On-Demand.</li>\n</ul>\n\n<p><strong>After</strong> connecting:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>curl <span class=\"nt\">-s</span> https://api.ipify.org <span class=\"o\">&amp;&amp;</span> <span class=\"nb\">echo</span>\n<span class=\"c\"># must now be the VPS public IPv4</span>\nnslookup example.com\n</code></pre></div></div>\n\n<p>If the second <code class=\"language-plaintext highlighter-rouge\">curl</code> still shows the ISP address, you are on split tunnel or the client did not activate. If DNS fails while the IP changed, the wizard DNS field is wrong.</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>docker compose <span class=\"nb\">exec </span>wg-easy wg show\n</code></pre></div></div>\n\n<p>You should see a recent handshake and transfer counters moving.</p>\n\n<p class=\"verify\"><strong>Verify.</strong> <code class=\"language-plaintext highlighter-rouge\">curl https://api.ipify.org</code> after connect returns the VPS IP, DNS still resolves, and <code class=\"language-plaintext highlighter-rouge\">wg show</code> on the server shows a handshake in the last two minutes.</p>\n\n<h2 id=\"failure-modes\">Failure modes</h2>\n\n<table>\n  <thead>\n    <tr>\n      <th>Symptom</th>\n      <th>Cause</th>\n      <th>Repair</th>\n    </tr>\n  </thead>\n  <tbody>\n    <tr>\n      <td>No wizard, old password env</td>\n      <td>Image is v14 (<code class=\"language-plaintext highlighter-rouge\">:latest</code>)</td>\n      <td>Pin <code class=\"language-plaintext highlighter-rouge\">:15</code>, recreate the container</td>\n    </tr>\n    <tr>\n      <td>Wizard present, <code class=\"language-plaintext highlighter-rouge\">WG_HOST</code> ignored</td>\n      <td>You are on v15 with a v14 file</td>\n      <td>Delete the old env; set hostname in the wizard</td>\n    </tr>\n    <tr>\n      <td><code class=\"language-plaintext highlighter-rouge\">wg show</code> empty / module errors</td>\n      <td>Missing <code class=\"language-plaintext highlighter-rouge\">/lib/modules</code> mount</td>\n      <td>Add the read-only mount; confirm kernel WireGuard</td>\n    </tr>\n    <tr>\n      <td>UDP 51820 closed from outside</td>\n      <td>UFW or provider SG</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">ufw allow 51820/udp</code> plus the cloud rule</td>\n    </tr>\n    <tr>\n      <td>UI open to the world</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">51821</code> allowed from <code class=\"language-plaintext highlighter-rouge\">0.0.0.0/0</code></td>\n      <td>Restrict to your IP; enable TOTP</td>\n    </tr>\n    <tr>\n      <td>IP does not change</td>\n      <td>Allowed IPs is the VPN subnet only</td>\n      <td>Set <code class=\"language-plaintext highlighter-rouge\">0.0.0.0/0, ::/0</code> and re-download the client</td>\n    </tr>\n    <tr>\n      <td>Banks / streaming break</td>\n      <td>Full-tunnel egress looks like a VPS</td>\n      <td>Split-tunnel those destinations, or accept the trade</td>\n    </tr>\n    <tr>\n      <td>Every device dies after a wipe</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">./config</code> was deleted</td>\n      <td>Restore <code class=\"language-plaintext highlighter-rouge\">wg0.json</code> / the bind mount from backup</td>\n    </tr>\n  </tbody>\n</table>\n\n<h2 id=\"clean-up-and-operating-consequence\">Clean up and operating consequence</h2>\n\n<p>State lives in <code class=\"language-plaintext highlighter-rouge\">./config</code>. Losing it means re-issuing every peer.</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nb\">tar </span>czf wg-backup-<span class=\"si\">$(</span><span class=\"nb\">date</span> +%F<span class=\"si\">)</span>.tgz <span class=\"nt\">-C</span> wireguard-vpn/config <span class=\"nb\">.</span>\ndocker compose pull   <span class=\"c\"># still pinned to :15</span>\ndocker compose up <span class=\"nt\">-d</span>\n</code></pre></div></div>\n\n<p>To leave: <code class=\"language-plaintext highlighter-rouge\">docker compose down</code>, delete the tunnel on each device. Wiping server state is <code class=\"language-plaintext highlighter-rouge\">rm -rf wireguard-vpn/config</code>.</p>\n\n<p>If <code class=\"language-plaintext highlighter-rouge\">curl</code> shows the VPS IP, you have a free VPN you control. Add a phone, a tablet, a second laptop — one client each in the UI, same server.</p>\n\n<p>Previous: <a href=\"/writing/self-host-rustdesk-relay-udp-21116/\">a free remote desktop with RustDesk</a>. Next: <a href=\"/writing/free-mailbox-stalwart-your-domain/\">a free mailbox for your domain with Stalwart</a>.</p>\n",
      "summary": "Want a free VPN you actually control? Pin wg-easy to :15, mount /lib/modules, keep the UI off 80/443, and prove traffic exits from your VPS.",
      "date_published": "2026-06-03T09:00:00+05:45",
      "date_modified": "2026-08-11T11:30:00+05:45",
      "tags": ["self-hosted"],
      "authors": [
        {
          "name": "Basant Bhattarai",
          "url": "https://basantbhattarai.com.np"
        }
      ]
    },
    {
      "id": "https://basantbhattarai.com.np/writing/self-host-rustdesk-relay-udp-21116/",
      "url": "https://basantbhattarai.com.np/writing/self-host-rustdesk-relay-udp-21116/",
      "title": "A free private remote desktop with RustDesk",
      "content_html": "<h2 id=\"why-bother\">Why bother</h2>\n\n<p>Want a TeamViewer-style remote desktop without a subscription, and without handing connection metadata to someone else’s relay? RustDesk is fully open source. The clients are free. The public servers will connect you — and then drop the session at about thirty seconds unless you pay.</p>\n\n<p>You can skip that tax. Run the official <code class=\"language-plaintext highlighter-rouge\">hbbs</code> (rendezvous) and <code class=\"language-plaintext highlighter-rouge\">hbbr</code> (relay) containers on any small Linux VPS, point <code class=\"language-plaintext highlighter-rouge\">relay.example.com</code> at it, and paste one public key into each client. Idle cost is tens of megabytes of RAM. Bandwidth is whatever your VPS already has.</p>\n\n<p>Two things usually waste the first hour, so we will treat them as part of the setup rather than surprises:</p>\n\n<ol>\n  <li><strong>UDP 21116.</strong> People open TCP 21116 and forget UDP. The app sits on “Connecting…” while Docker looks healthy.</li>\n  <li><strong>Silent P2P.</strong> RustDesk prefers a direct path. On the same LAN a session can succeed without ever touching <code class=\"language-plaintext highlighter-rouge\">hbbr</code>. You have not tested the relay until <code class=\"language-plaintext highlighter-rouge\">ALWAYS_USE_RELAY=Y</code> and you see traffic on 21117.</li>\n</ol>\n\n<p>Substitute your domain below. Do not publish the private key.</p>\n\n<div class=\"callout callout--note\">\n  <p><strong>What “done” means.</strong> Not <code class=\"language-plaintext highlighter-rouge\">docker compose ps</code> saying healthy. Done is: UDP 21116 answers from outside, the client shows <code class=\"language-plaintext highlighter-rouge\">Ready (relay.example.com)</code>, and a session through your relay lasts past sixty seconds.</p>\n</div>\n\n<h2 id=\"step-1--dns-firewall-and-the-compose-file\">Step 1 — DNS, firewall, and the compose file</h2>\n\n<p>Create a directory and an A record for the relay hostname, pointing at the VPS. IPv6 is optional; if you add AAAA, keep using the hostname as the client setting so you are not locked to one stack.</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nb\">mkdir</span> <span class=\"nt\">-p</span> rustdesk-server/data\n<span class=\"nb\">cd </span>rustdesk-server\n</code></pre></div></div>\n\n<p>Open the ports. UDP 21116 is the one people skip. 21118/21119 are only for the web client; leave them closed if you are not using it.</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nb\">sudo </span>ufw allow 21115/tcp\n<span class=\"nb\">sudo </span>ufw allow 21116/tcp\n<span class=\"nb\">sudo </span>ufw allow 21116/udp\n<span class=\"nb\">sudo </span>ufw allow 21117/tcp\n<span class=\"nb\">sudo </span>ufw reload\n<span class=\"nb\">sudo </span>ss <span class=\"nt\">-lntu</span> | <span class=\"nb\">grep</span> <span class=\"nt\">-E</span> <span class=\"s1\">'21115|21116|21117'</span> <span class=\"o\">||</span> <span class=\"nb\">true</span>\n</code></pre></div></div>\n\n<p>Write <code class=\"language-plaintext highlighter-rouge\">.env</code> and the compose file. <code class=\"language-plaintext highlighter-rouge\">RUSTDESK_PUBLIC_HOST</code> must be the name you put in DNS. <code class=\"language-plaintext highlighter-rouge\">hbbs -r</code> tells clients which host:port is the relay.</p>\n\n<div data-file=\"rustdesk-server/.env\" class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nb\">cat</span> <span class=\"o\">&gt;</span> .env <span class=\"o\">&lt;&lt;</span><span class=\"sh\">'</span><span class=\"no\">EOF</span><span class=\"sh\">'\nRUSTDESK_PUBLIC_HOST=relay.example.com\nRUSTDESK_ALWAYS_USE_RELAY=Y\n</span><span class=\"no\">EOF\n</span></code></pre></div></div>\n\n<div data-file=\"rustdesk-server/docker-compose.yml\" class=\"language-yaml highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"na\">services</span><span class=\"pi\">:</span>\n  <span class=\"na\">hbbs</span><span class=\"pi\">:</span>\n    <span class=\"na\">image</span><span class=\"pi\">:</span> <span class=\"s\">rustdesk/rustdesk-server:latest</span>\n    <span class=\"na\">container_name</span><span class=\"pi\">:</span> <span class=\"s\">rustdesk_hbbs</span>\n    <span class=\"na\">restart</span><span class=\"pi\">:</span> <span class=\"s\">unless-stopped</span>\n    <span class=\"na\">environment</span><span class=\"pi\">:</span>\n      <span class=\"pi\">-</span> <span class=\"s\">ALWAYS_USE_RELAY=${RUSTDESK_ALWAYS_USE_RELAY:-Y}</span>\n    <span class=\"na\">command</span><span class=\"pi\">:</span> <span class=\"s\">hbbs -r ${RUSTDESK_PUBLIC_HOST}:21117</span>\n    <span class=\"na\">ports</span><span class=\"pi\">:</span>\n      <span class=\"pi\">-</span> <span class=\"s2\">\"</span><span class=\"s\">21115:21115\"</span>\n      <span class=\"pi\">-</span> <span class=\"s2\">\"</span><span class=\"s\">21116:21116\"</span>\n      <span class=\"pi\">-</span> <span class=\"s2\">\"</span><span class=\"s\">21116:21116/udp\"</span>\n    <span class=\"na\">volumes</span><span class=\"pi\">:</span>\n      <span class=\"pi\">-</span> <span class=\"s\">./data:/root</span>\n    <span class=\"na\">depends_on</span><span class=\"pi\">:</span>\n      <span class=\"pi\">-</span> <span class=\"s\">hbbr</span>\n    <span class=\"na\">networks</span><span class=\"pi\">:</span> <span class=\"pi\">[</span><span class=\"nv\">rustdesk_net</span><span class=\"pi\">]</span>\n    <span class=\"na\">mem_limit</span><span class=\"pi\">:</span> <span class=\"s\">256m</span>\n    <span class=\"na\">cpus</span><span class=\"pi\">:</span> <span class=\"s2\">\"</span><span class=\"s\">0.25\"</span>\n\n  <span class=\"na\">hbbr</span><span class=\"pi\">:</span>\n    <span class=\"na\">image</span><span class=\"pi\">:</span> <span class=\"s\">rustdesk/rustdesk-server:latest</span>\n    <span class=\"na\">container_name</span><span class=\"pi\">:</span> <span class=\"s\">rustdesk_hbbr</span>\n    <span class=\"na\">restart</span><span class=\"pi\">:</span> <span class=\"s\">unless-stopped</span>\n    <span class=\"na\">command</span><span class=\"pi\">:</span> <span class=\"s\">hbbr</span>\n    <span class=\"na\">ports</span><span class=\"pi\">:</span>\n      <span class=\"pi\">-</span> <span class=\"s2\">\"</span><span class=\"s\">21117:21117\"</span>\n    <span class=\"na\">volumes</span><span class=\"pi\">:</span>\n      <span class=\"pi\">-</span> <span class=\"s\">./data:/root</span>\n    <span class=\"na\">networks</span><span class=\"pi\">:</span> <span class=\"pi\">[</span><span class=\"nv\">rustdesk_net</span><span class=\"pi\">]</span>\n    <span class=\"na\">mem_limit</span><span class=\"pi\">:</span> <span class=\"s\">256m</span>\n    <span class=\"na\">cpus</span><span class=\"pi\">:</span> <span class=\"s2\">\"</span><span class=\"s\">0.50\"</span>\n\n<span class=\"na\">networks</span><span class=\"pi\">:</span>\n  <span class=\"na\">rustdesk_net</span><span class=\"pi\">:</span>\n    <span class=\"na\">driver</span><span class=\"pi\">:</span> <span class=\"s\">bridge</span>\n</code></pre></div></div>\n\n<p>Explicit port maps beat host networking here. The relay then sits beside an existing app stack without claiming the host network namespace. Idle cost is small: tens of megabytes of RAM and almost no CPU until a session is relayed.</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>docker compose up <span class=\"nt\">-d</span>\ndocker compose ps\ndocker compose logs hbbs <span class=\"nt\">--tail</span><span class=\"o\">=</span>30\ndocker compose logs hbbr <span class=\"nt\">--tail</span><span class=\"o\">=</span>20\n</code></pre></div></div>\n\n<p>Look for <code class=\"language-plaintext highlighter-rouge\">ALWAYS_USE_RELAY=Y</code> in the <code class=\"language-plaintext highlighter-rouge\">hbbs</code> log and a listen line on 21117 in <code class=\"language-plaintext highlighter-rouge\">hbbr</code>. Treat a healthcheck of <code class=\"language-plaintext highlighter-rouge\">unhealthy</code> as untrusted if the image has no shell for <code class=\"language-plaintext highlighter-rouge\">CMD-SHELL</code>. Ports listening plus an external <code class=\"language-plaintext highlighter-rouge\">nc</code> are the real signals.</p>\n\n<p class=\"verify\"><strong>Verify.</strong> From a machine that is not the VPS, all four of these should report open. If TCP works and UDP fails, the firewall or provider security group is incomplete.</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>nc <span class=\"nt\">-vz</span> relay.example.com 21115\nnc <span class=\"nt\">-vz</span> relay.example.com 21116\nnc <span class=\"nt\">-vz</span> relay.example.com 21117\nnc <span class=\"nt\">-vzu</span> relay.example.com 21116\n</code></pre></div></div>\n\n<h2 id=\"step-2--pin-the-key-then-configure-the-clients\">Step 2 — Pin the key, then configure the clients</h2>\n\n<p>The first start writes an Ed25519 pair into <code class=\"language-plaintext highlighter-rouge\">./data</code>. Clients pin the <strong>public</strong> key. Without that pin they will not talk to your server, which is the whole anti-MITM design.</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nb\">cat</span> ./data/id_ed25519.pub\n<span class=\"c\"># example only: 5fGu2T8aQrXkPnMwZ...kP9vNw=</span>\n</code></pre></div></div>\n\n<p>Treat <code class=\"language-plaintext highlighter-rouge\">./data/id_ed25519</code> as a secret. Back it up off the VPS. If that file is lost, the next start mints a new key and every existing client refuses to connect until you paste the new public key.</p>\n\n<p>On each RustDesk client:</p>\n\n<ol>\n  <li>Install the app (<code class=\"language-plaintext highlighter-rouge\">brew install --cask rustdesk</code> on a Mac).</li>\n  <li>Settings → Network → unlock network settings.</li>\n  <li><strong>ID Server</strong> = <code class=\"language-plaintext highlighter-rouge\">relay.example.com</code>.</li>\n  <li><strong>Relay Server</strong> = leave empty. <code class=\"language-plaintext highlighter-rouge\">hbbs -r</code> advertises the relay; the official docs discourage setting this on every client.</li>\n  <li><strong>API Server</strong> = leave empty (Pro).</li>\n  <li><strong>Key</strong> = the exact contents of <code class=\"language-plaintext highlighter-rouge\">id_ed25519.pub</code>, no quotes, no trailing space.</li>\n  <li>Apply, then restart the app.</li>\n</ol>\n\n<p>The status line should read <code class=\"language-plaintext highlighter-rouge\">Ready (relay.example.com)</code>, not the public-server message.</p>\n\n<div class=\"callout callout--gotcha\">\n  <p><strong>Relay is server-side.</strong> <code class=\"language-plaintext highlighter-rouge\">ALWAYS_USE_RELAY=Y</code> is an <code class=\"language-plaintext highlighter-rouge\">hbbs</code> environment variable. Putting a relay hostname in the client does not force relay. Two clients on the same LAN may still take a direct path on some RustDesk builds; test across two networks if you need to prove <code class=\"language-plaintext highlighter-rouge\">hbbr</code>.</p>\n</div>\n\n<p>On the machine you want to control unattended: Settings → Security → enable a permanent password (16+ random characters, password manager), optionally 2FA, and grant the OS permissions RustDesk asks for (Accessibility and Screen Recording on macOS). That first permission grant needs a local screen. It cannot be done over SSH.</p>\n\n<p class=\"verify\"><strong>Verify.</strong> The controlled client shows <code class=\"language-plaintext highlighter-rouge\">Ready (relay.example.com)</code> and <code class=\"language-plaintext highlighter-rouge\">docker compose logs hbbs --tail=50</code> shows the client ID registering, with no <code class=\"language-plaintext highlighter-rouge\">permission denied</code> or key errors.</p>\n\n<h2 id=\"step-3--connect-and-prove-you-beat-the-public-cutoff\">Step 3 — Connect and prove you beat the public cutoff</h2>\n\n<p>From the controlling laptop, enter the nine-digit ID of the remote machine, use the permanent password, and wait for the desktop. Then do the only check that matters for this post: leave the session up for more than sixty seconds while moving the mouse.</p>\n\n<p>While it is connected:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>docker compose logs <span class=\"nt\">-f</span> hbbr\n<span class=\"nb\">sudo </span>ss <span class=\"nt\">-tunap</span> | <span class=\"nb\">grep </span>21117\n</code></pre></div></div>\n\n<p>With <code class=\"language-plaintext highlighter-rouge\">ALWAYS_USE_RELAY=Y</code> you should see <code class=\"language-plaintext highlighter-rouge\">hbbr</code> activity and established sockets on 21117. If the session is up and <code class=\"language-plaintext highlighter-rouge\">hbbr</code> is silent, you are on P2P and have not tested the relay.</p>\n\n<p>That is also why I default to relay-on. It costs server bandwidth. It makes the failure mode visible. Set <code class=\"language-plaintext highlighter-rouge\">RUSTDESK_ALWAYS_USE_RELAY=N</code> later if you want hole-punched P2P and only fall back to <code class=\"language-plaintext highlighter-rouge\">hbbr</code>.</p>\n\n<p class=\"verify\"><strong>Verify.</strong> The session is still connected at 60 seconds, <code class=\"language-plaintext highlighter-rouge\">hbbr</code> logged traffic, and <code class=\"language-plaintext highlighter-rouge\">ss</code> shows ESTABLISHED on 21117. That is the proof you are not on the public 30-second relay.</p>\n\n<h2 id=\"failure-modes\">Failure modes</h2>\n\n<table>\n  <thead>\n    <tr>\n      <th>Symptom</th>\n      <th>Cause</th>\n      <th>Repair</th>\n    </tr>\n  </thead>\n  <tbody>\n    <tr>\n      <td>Client stays on the public server message</td>\n      <td>Key has spaces/quotes, or ID Server is wrong</td>\n      <td>Paste <code class=\"language-plaintext highlighter-rouge\">id_ed25519.pub</code> exactly; restart the app</td>\n    </tr>\n    <tr>\n      <td>TCP <code class=\"language-plaintext highlighter-rouge\">nc</code> works, UDP 21116 fails</td>\n      <td>UFW or cloud SG missing UDP</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">ufw allow 21116/udp</code> and the matching provider rule</td>\n    </tr>\n    <tr>\n      <td>Session starts, <code class=\"language-plaintext highlighter-rouge\">hbbr</code> stays quiet</td>\n      <td>P2P / same LAN, or <code class=\"language-plaintext highlighter-rouge\">ALWAYS_USE_RELAY</code> not <code class=\"language-plaintext highlighter-rouge\">Y</code></td>\n      <td>Confirm the env in <code class=\"language-plaintext highlighter-rouge\">hbbs</code> logs; test from a different network</td>\n    </tr>\n    <tr>\n      <td>Containers “unhealthy” but ports listen</td>\n      <td>Image healthcheck needs a shell the image may lack</td>\n      <td>Ignore the health label; trust <code class=\"language-plaintext highlighter-rouge\">ss</code> and <code class=\"language-plaintext highlighter-rouge\">nc</code></td>\n    </tr>\n    <tr>\n      <td>Every client dies after a recreate</td>\n      <td><code class=\"language-plaintext highlighter-rouge\">./data/id_ed25519</code> was deleted</td>\n      <td>Restore the backup, or distribute the new public key</td>\n    </tr>\n    <tr>\n      <td>IPv4 hangs after you add AAAA</td>\n      <td>Client tries A first and never falls back</td>\n      <td>Keep the hostname in the client; do not switch <code class=\"language-plaintext highlighter-rouge\">RUSTDESK_PUBLIC_HOST</code> to a raw address unless you want one stack</td>\n    </tr>\n  </tbody>\n</table>\n\n<h2 id=\"clean-up-and-operating-consequence\">Clean up and operating consequence</h2>\n\n<p>Quarterly:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code>docker compose pull\ndocker compose up <span class=\"nt\">-d</span>\n</code></pre></div></div>\n\n<p>Backup recipe — the keys are under a kilobyte:</p>\n\n<div class=\"language-bash highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"nb\">tar </span>czf rustdesk-keys-<span class=\"si\">$(</span><span class=\"nb\">date</span> +%F<span class=\"si\">)</span>.tgz <span class=\"nt\">-C</span> rustdesk-server data/id_ed25519 data/id_ed25519.pub\n</code></pre></div></div>\n\n<p>To walk away from the private server, <code class=\"language-plaintext highlighter-rouge\">docker compose down</code> and clear ID Server / Key on each client. They return to the public infrastructure in about thirty seconds per device.</p>\n\n<p>If you can keep a session up for a minute through <code class=\"language-plaintext highlighter-rouge\">hbbr</code>, you have a free private remote desktop. Add more devices the same way: same hostname, same public key, no server change.</p>\n\n<p>Next, the other thing people usually want on the same VPS: <a href=\"/writing/wg-easy-v15-latest-is-still-v14/\">a free VPN with WireGuard</a>.</p>\n",
      "summary": "Want a free private remote desktop? Self-host RustDesk on Docker, open UDP 21116, pin the key, and keep sessions past the public cutoff.",
      "date_published": "2026-06-02T09:00:00+05:45",
      "date_modified": "2026-08-11T11:30:00+05:45",
      "tags": ["self-hosted"],
      "authors": [
        {
          "name": "Basant Bhattarai",
          "url": "https://basantbhattarai.com.np"
        }
      ]
    }
  ]
}
