<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Saimul Islam</title>
    <description>The latest articles on DEV Community by Saimul Islam (@saimulislamdl).</description>
    <link>https://dev.to/saimulislamdl</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2102508%2F731a7a40-a68a-4ee1-8318-0d1d14988552.png</url>
      <title>DEV Community: Saimul Islam</title>
      <link>https://dev.to/saimulislamdl</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/saimulislamdl"/>
    <language>en</language>
    <item>
      <title>How to upgrade Postgres in Docker to 18, and be ready for 19: dump and restore or pg_upgrade --link</title>
      <dc:creator>Saimul Islam</dc:creator>
      <pubDate>Mon, 02 Nov 2026 13:44:24 +0000</pubDate>
      <link>https://dev.to/saimulislamdl/how-to-upgrade-postgres-in-docker-to-18-and-be-ready-for-19-dump-and-restore-or-pgupgrade-link-3gff</link>
      <guid>https://dev.to/saimulislamdl/how-to-upgrade-postgres-in-docker-to-18-and-be-ready-for-19-dump-and-restore-or-pgupgrade-link-3gff</guid>
      <description>&lt;p&gt;I moved a Postgres database on Dokploy from 16 to 18 with &lt;code&gt;pg_upgrade&lt;/code&gt;. It went fine, with the database down for under a minute, but it stopped twice on the way. First, &lt;code&gt;pg_upgrade&lt;/code&gt; stopped at its checks: &lt;code&gt;old cluster does not use data checksums but the new one does&lt;/code&gt;. Then 18 started, and my app couldn't connect.&lt;/p&gt;

&lt;p&gt;Neither stop was bad luck. Both follow from how the 18 tooling works, and both wait in a typical &lt;code&gt;pg_upgrade&lt;/code&gt; of a 14 to 17 database made with the image's defaults. You'll meet them soon: 14 gets its final release on November 12, 2026, and 19 is planned for October 29 (as of October 2026).&lt;/p&gt;

&lt;p&gt;This guide is for you if you run Postgres 14 to 18 in a Docker volume, Dokploy and Coolify included. It gives you one timed rehearsal that picks your path: dump and restore, or &lt;code&gt;pg_upgrade --link&lt;/code&gt;. Then both paths step by step, the exact errors with their fixes, the rollback for each, and five checks that prove it worked.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key takeaways&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The 18 image moved &lt;code&gt;PGDATA&lt;/code&gt; to &lt;code&gt;/var/lib/postgresql/18/docker&lt;/code&gt; and the volume to &lt;code&gt;/var/lib/postgresql&lt;/code&gt;, so &lt;code&gt;pg_upgrade --link&lt;/code&gt; can hard-link inside one mount. A volume mounted at &lt;code&gt;PGDATA&lt;/code&gt; itself, as Dokploy does for 18, blocks it.&lt;/li&gt;
&lt;li&gt;Rehearse a dump and restore on a copy and time it. If it fits your downtime window, take that path. If not, &lt;code&gt;--link&lt;/code&gt; took 12.9 to 14.4 seconds from 1.4 to 8.5 GB in &lt;a href="https://www.endpointdev.com/blog/2015/07/how-fast-is-pgupgrade-anyway/" rel="noopener noreferrer"&gt;End Point's 2015 test&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;A 17 cluster made with the image's defaults has data checksums off, and 18's &lt;code&gt;initdb&lt;/code&gt; turns them on. &lt;code&gt;pg_upgrade&lt;/code&gt; refuses the mismatch, and the fix you pick follows you to 19.&lt;/li&gt;
&lt;li&gt;An upgraded cluster doesn't get the image's &lt;code&gt;pg_hba.conf&lt;/code&gt; line for other containers, so your app is refused until you copy your old file back.&lt;/li&gt;
&lt;li&gt;Once the new server starts, a restored backup is &lt;code&gt;--link&lt;/code&gt;'s only way back.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;How this guide was checked:&lt;/strong&gt; I didn't run any of these commands for this post. They're assembled from the PostgreSQL 18 docs, the official image's source (&lt;code&gt;18.6&lt;/code&gt;, as of October 2026), and the upgrade image's scripts, and each block says "Untested." Error messages come from that source and from GitHub issues. The examples use a Compose project &lt;code&gt;myapp&lt;/code&gt;, a service &lt;code&gt;db&lt;/code&gt;, a volume &lt;code&gt;pgdata&lt;/code&gt;, and the superuser &lt;code&gt;postgres&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Table of contents&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Why the Postgres 18 image moved your data&lt;/li&gt;
&lt;li&gt;Dump and restore or pg_upgrade --link: let one timed rehearsal decide&lt;/li&gt;
&lt;li&gt;Before either path: rehearse on a copy and time it&lt;/li&gt;
&lt;li&gt;Path A: dump and restore into a fresh 18 volume&lt;/li&gt;
&lt;li&gt;Path B: pg_upgrade --link in four steps&lt;/li&gt;
&lt;li&gt;How to check the upgrade worked&lt;/li&gt;
&lt;li&gt;Errors you might see, and what fixes them&lt;/li&gt;
&lt;li&gt;On Dokploy: the mount that blocks an in-place upgrade to 19&lt;/li&gt;
&lt;li&gt;Upgrading to 19: what's ready and what isn't&lt;/li&gt;
&lt;li&gt;When not to use pg_upgrade --link&lt;/li&gt;
&lt;li&gt;Check two lines before 19 ships&lt;/li&gt;
&lt;li&gt;Sources&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why the Postgres 18 image moved your data
&lt;/h2&gt;

&lt;p&gt;Three answers from a developer would worry me before a database upgrade, because each one skips a step that decides whether the data survives. The first is "just change the version." In Docker, current 18 images refuse it outright.&lt;/p&gt;

&lt;p&gt;The 18 image moved the data down one level so that two clusters can share one mount. That's what &lt;code&gt;pg_upgrade --link&lt;/code&gt; needs, because a hard link can't cross a mount point.&lt;/p&gt;

&lt;p&gt;Here's the mechanism. &lt;code&gt;--link&lt;/code&gt; makes one &lt;code&gt;link(2)&lt;/code&gt; call per data file instead of copying it. The &lt;a href="https://man7.org/linux/man-pages/man2/link.2.html" rel="noopener noreferrer"&gt;link(2) manual&lt;/a&gt; says the call fails with &lt;code&gt;EXDEV&lt;/code&gt; across different mounts, "even if the same filesystem is mounted on both." Docker makes every volume its own mount.&lt;/p&gt;

&lt;p&gt;On 17 and earlier, the volume &lt;em&gt;is&lt;/em&gt; the data directory (&lt;code&gt;/var/lib/postgresql/data&lt;/code&gt;). There's no room inside it for a second cluster, so the new one has to live on a second mount, and linking fails. A maintainer said volumes were "stuck using the slow version" (&lt;a href="https://github.com/docker-library/postgres/pull/1259" rel="noopener noreferrer"&gt;PR #1259&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;From 18, &lt;code&gt;PGDATA&lt;/code&gt; is &lt;code&gt;/var/lib/postgresql/18/docker&lt;/code&gt; and the &lt;code&gt;VOLUME&lt;/code&gt; is &lt;code&gt;/var/lib/postgresql&lt;/code&gt; (&lt;a href="https://github.com/docker-library/docs/blob/master/postgres/content.md" rel="noopener noreferrer"&gt;image docs&lt;/a&gt;). Mount the parent, and &lt;code&gt;17/docker&lt;/code&gt;, &lt;code&gt;18/docker&lt;/code&gt;, and later &lt;code&gt;19/docker&lt;/code&gt; sit side by side in one volume.&lt;/p&gt;

&lt;p&gt;What the old mount does on 18 depends on when the image was built:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Current Debian images&lt;/strong&gt; (since October 22, 2025): the entrypoint finds old data, or an unused mount at &lt;code&gt;/var/lib/postgresql/data&lt;/code&gt;, prints &lt;code&gt;Error: in 18+, these Docker images are configured to store database data in a format which is compatible with "pg_ctlcluster"&lt;/code&gt;, and exits 1 without touching your data (&lt;a href="https://github.com/docker-library/postgres/blob/e00e1bd34ec5c8a8e7ad89b273b3d42efaf6d5bc/18/trixie/docker-entrypoint.sh" rel="noopener noreferrer"&gt;entrypoint source&lt;/a&gt;, &lt;a href="https://github.com/docker-library/postgres/pull/1372" rel="noopener noreferrer"&gt;PR #1372&lt;/a&gt;). Under &lt;code&gt;restart: always&lt;/code&gt;, that's a restart loop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;18.0-era images:&lt;/strong&gt; a deliberate &lt;code&gt;data&lt;/code&gt; symlink made Docker fail on many setups with &lt;code&gt;error mounting ... to rootfs at "/var/lib/postgresql/data" ... no such file or directory&lt;/code&gt;. Alpine images kept the symlink until January 29, 2026.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alpine images built before the April 21, 2026 fix:&lt;/strong&gt; their check missed bind mounts at the old path, so some setups started an empty cluster in an anonymous volume and ran that way for weeks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A maintainer predicted that last symptom, "a running empty database," for anyone who starts a new major on old data without upgrading (&lt;a href="https://github.com/docker-library/postgres/pull/1259" rel="noopener noreferrer"&gt;PR #1259&lt;/a&gt;). The refusal in the first bullet exists to stop it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verify it:&lt;/strong&gt; check which directory your server writes to.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Untested.&lt;/span&gt;
docker inspect &amp;lt;container&amp;gt; &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{ json .Mounts }}'&lt;/span&gt;
docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;db psql &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'SHOW data_directory;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You want one mount whose &lt;code&gt;Destination&lt;/code&gt; is &lt;code&gt;/var/lib/postgresql&lt;/code&gt;, and a data directory inside it. Next question: do you need any of this, or is a dump simpler?&lt;/p&gt;

&lt;h2&gt;
  
  
  Dump and restore or pg_upgrade --link: let one timed rehearsal decide
&lt;/h2&gt;

&lt;p&gt;Rehearse a dump and restore on a copy and time it. If it fits your downtime window, use dump and restore. If it doesn't, use &lt;code&gt;pg_upgrade --link&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One published rule of thumb puts a number on it, &lt;a href="https://www.cybertec-postgresql.com/en/a-primer-on-postgresql-upgrade-methods/" rel="noopener noreferrer"&gt;CYBERTEC's from 2019&lt;/a&gt;: dump-and-restore trouble starts "around 100GB." Your own timing beats any general rule.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://www.postgresql.org/docs/18/upgrading.html" rel="noopener noreferrer"&gt;PostgreSQL docs&lt;/a&gt; call dump and restore the method that "can be slow" and say &lt;code&gt;pg_upgrade&lt;/code&gt; finishes "in minutes, particularly with &lt;code&gt;--link&lt;/code&gt; mode." Here's what each path does to your data, per the &lt;a href="https://www.postgresql.org/docs/18/pgupgrade.html" rel="noopener noreferrer"&gt;pg_upgrade docs&lt;/a&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;What happens to the data&lt;/th&gt;
&lt;th&gt;Extra disk during the upgrade&lt;/th&gt;
&lt;th&gt;Needs&lt;/th&gt;
&lt;th&gt;Way back&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Dump and restore&lt;/td&gt;
&lt;td&gt;Every row re-inserted, indexes rebuilt&lt;/td&gt;
&lt;td&gt;The dump plus a full new cluster&lt;/td&gt;
&lt;td&gt;The new image's client tools&lt;/td&gt;
&lt;td&gt;The old volume, untouched&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pg_upgrade --copy&lt;/code&gt; (default)&lt;/td&gt;
&lt;td&gt;Every file copied&lt;/td&gt;
&lt;td&gt;About the cluster's size again&lt;/td&gt;
&lt;td&gt;Old and new binaries&lt;/td&gt;
&lt;td&gt;The old cluster, untouched&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pg_upgrade --link&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Hard links; nothing copied&lt;/td&gt;
&lt;td&gt;Almost none&lt;/td&gt;
&lt;td&gt;One mount for both clusters&lt;/td&gt;
&lt;td&gt;Rename &lt;code&gt;pg_control.old&lt;/code&gt; if the new server never started; after that, your backup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pg_upgrade --clone&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reflinks&lt;/td&gt;
&lt;td&gt;Almost none at first&lt;/td&gt;
&lt;td&gt;Btrfs, XFS with reflink, or APFS (not ext4)&lt;/td&gt;
&lt;td&gt;The old cluster, untouched&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pg_upgrade --swap&lt;/code&gt; (new in 18)&lt;/td&gt;
&lt;td&gt;Directories moved&lt;/td&gt;
&lt;td&gt;Almost none&lt;/td&gt;
&lt;td&gt;One filesystem&lt;/td&gt;
&lt;td&gt;Your backup, once transfer starts&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The only controlled comparison with published numbers is from 2015, but its shape is the point. In Greg Sabino Mullane's &lt;a href="https://www.endpointdev.com/blog/2015/07/how-fast-is-pgupgrade-anyway/" rel="noopener noreferrer"&gt;test&lt;/a&gt; (9.3 to 9.4, pgbench data, fsync off on the target, hardware not stated), dump and restore took 210 seconds at 1.4 GB and 1,927 seconds at 8.5 GB. The whole &lt;code&gt;--link&lt;/code&gt; run took 12.9 to 14.4 seconds at every size, and &lt;code&gt;--copy&lt;/code&gt; landed in between, from 74.7 to 607 seconds.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--link&lt;/code&gt; time follows the number of tables and indexes, not bytes. Daniel Westermann &lt;a href="https://www.dbi-services.com/blog/postgresql-18-swap-mode-for-pg_upgrade/" rel="noopener noreferrer"&gt;measured&lt;/a&gt; 13.8 seconds for a &lt;code&gt;--link&lt;/code&gt; upgrade of 10,000 tables and 10,000 indexes, from 17.2 to an 18 development build (March 2025).&lt;/p&gt;

&lt;p&gt;Two more options sit between them. With &lt;code&gt;--copy&lt;/code&gt;, you need no layout move and keep the old cluster as your way back, at the cost of the disk and minutes of copying. With &lt;code&gt;--clone&lt;/code&gt;, you keep it too, if your filesystem can do reflinks. And know what you're betting with &lt;code&gt;--link&lt;/code&gt;: after first start, your rollback is the restore half of that timing, so check it against the window too.&lt;/p&gt;

&lt;p&gt;Dump and restore buys real things, though. The new cluster gets the 18 layout and checksums by default, every index is rebuilt on the new image's C library, and the old volume stays as your rollback. Windmill, BloodHound, authentik, Discourse, Coolify, and Immich's community guide all document dump and restore for the Postgres their apps run on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My pick:&lt;/strong&gt; for a production database, I'd use &lt;code&gt;pg_upgrade&lt;/code&gt;, because it's faster on big databases. The rehearsal tells you whether yours counts as big.&lt;/p&gt;

&lt;p&gt;Either way, the rehearsal comes first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before either path: rehearse on a copy and time it
&lt;/h2&gt;

&lt;p&gt;"We have backups" is the second answer that would worry me, when nobody has restored them. Dump your database with the 18 tools, restore it into a scratch volume, and time both halves. The total is your Path A downtime. The restore half is your rollback time for Path B, because once the new server has started, the docs' answer to any failure is "restore from backup."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you need before you start:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;postgres:18.6&lt;/code&gt;, the newest 18 minor as of October 2026, pinned.&lt;/li&gt;
&lt;li&gt;For Path B, &lt;code&gt;tianon/postgres-upgrade&lt;/code&gt; (tags &lt;code&gt;14-to-18&lt;/code&gt; to &lt;code&gt;17-to-18&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Free disk for a full dump, plus room for a second cluster on Path A.&lt;/li&gt;
&lt;li&gt;A scratch volume, and a quiet hour on the calendar for the real run.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Take the dump with the new version's tools: the docs recommend &lt;code&gt;pg_dump&lt;/code&gt; and &lt;code&gt;pg_dumpall&lt;/code&gt; "from the newer version of PostgreSQL." Path A shows the commands; for the rehearsal, point them at a scratch volume and leave the app running.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;War story:&lt;/strong&gt; with three weeks of production data on Dokploy, I ran a migration late at night and for about 90 seconds believed I'd wiped the users table. I hadn't. But when I went looking for backups, there were none.&lt;/p&gt;

&lt;p&gt;I set up Dokploy's scheduled &lt;code&gt;pg_dump&lt;/code&gt; to Cloudflare R2, and the week after, I restored one into a throwaway Postgres service on purpose. It took 10 minutes; the database was a couple hundred MB at the time. A backup you've never restored isn't a backup. It's a file you feel good about.&lt;/p&gt;

&lt;p&gt;GitLab learned the same lesson at scale in its &lt;a href="https://about.gitlab.com/blog/postmortem-of-database-outage-of-january-31/" rel="noopener noreferrer"&gt;January 2017 outage&lt;/a&gt;. Its backup job had been failing on a &lt;code&gt;pg_dump&lt;/code&gt; version mismatch, the S3 bucket was empty, and "nobody was responsible for testing this procedure."&lt;/p&gt;

&lt;p&gt;For Path B, rehearse on a 17 copy, not the 18 restore. Dump with your current image's own tools and restore into a scratch &lt;code&gt;postgres:17&lt;/code&gt; volume created with production's &lt;code&gt;POSTGRES_USER&lt;/code&gt; and &lt;code&gt;POSTGRES_INITDB_ARGS&lt;/code&gt;, so the install user and checksum setting match. Then move it into the new layout, run &lt;code&gt;--check --link&lt;/code&gt; and the real upgrade against it, and time the whole thing. (The docs' own test advice is lighter: "create a schema-only copy of the old cluster, insert dummy data, and upgrade that.")&lt;/p&gt;

&lt;p&gt;A logical copy won't carry production's recorded collation version unless the scratch 17 runs production's image, and it never carries damage inside the data files. And run &lt;code&gt;--check&lt;/code&gt; against a stopped cluster, production or the copy: it reaches the old server through its Unix socket, which lives inside the other container.&lt;/p&gt;

&lt;p&gt;The backup is usually your biggest disk cost. &lt;code&gt;--link&lt;/code&gt; needs almost nothing extra; a full dump of your data does. With a timed rehearsal in hand, you know your path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Path A: dump and restore into a fresh 18 volume
&lt;/h2&gt;

&lt;p&gt;Dump with the 18 client, restore into a new volume mounted the 18 way, and keep the old volume until you've checked. The new cluster gets the new layout and data checksums for free.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Untested. 1) Stop the app so nothing writes during the dump.&lt;/span&gt;
docker compose stop app

&lt;span class="c"&gt;# 2) Dump everything with the 18 tools, against the running 17 server.&lt;/span&gt;
&lt;span class="c"&gt;#    Export PGPASSWORD in your shell first; -e passes it into the container.&lt;/span&gt;
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--network&lt;/span&gt; myapp_default &lt;span class="nt"&gt;-e&lt;/span&gt; PGPASSWORD postgres:18.6 &lt;span class="se"&gt;\&lt;/span&gt;
  pg_dumpall &lt;span class="nt"&gt;-h&lt;/span&gt; db &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; all.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then point the &lt;code&gt;db&lt;/code&gt; service at a new volume and the new image. Keep &lt;code&gt;POSTGRES_PASSWORD&lt;/code&gt;: an empty volume won't initialize without it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Untested. docker-compose.yml&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:18.6&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${POSTGRES_PASSWORD}&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;pgdata18:/var/lib/postgresql&lt;/span&gt;   &lt;span class="c1"&gt;# new volume, mounted at the parent&lt;/span&gt;
&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pgdata18&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Untested. 3) Start 18 and wait for its first-run setup to finish.&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; db
docker compose logs &lt;span class="nt"&gt;-f&lt;/span&gt; db   &lt;span class="c"&gt;# wait for "PostgreSQL init process complete; ready for start up."&lt;/span&gt;

&lt;span class="c"&gt;# 4) Restore, keep the errors, refresh statistics, start the app.&lt;/span&gt;
docker compose &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-T&lt;/span&gt; db psql &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-f&lt;/span&gt; - &amp;lt; all.sql 2&amp;gt; restore-errors.log
docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;db vacuumdb &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="nt"&gt;--analyze-in-stages&lt;/span&gt;
docker compose start app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wait for that log line: during first init, a temporary server listens on the Unix socket only, and a restore that reaches it gets cut off when the entrypoint stops it. In &lt;code&gt;restore-errors.log&lt;/code&gt;, expect &lt;code&gt;role "postgres" already exists&lt;/code&gt; (and &lt;code&gt;database ... already exists&lt;/code&gt; if you set &lt;code&gt;POSTGRES_DB&lt;/code&gt;); anything else needs a look.&lt;/p&gt;

&lt;p&gt;Rollback is one edit: put &lt;code&gt;postgres:17&lt;/code&gt; and the old volume back in the Compose file. If your rehearsal didn't fit the window, Path B is for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Path B: pg_upgrade --link in four steps
&lt;/h2&gt;

&lt;p&gt;Path B keeps your data where it is and links it into an 18 cluster in the same volume. It takes four steps: move the layout on your current major, check two settings, run the upgrade, then give 18 what it didn't inherit. My own 16 to 18 &lt;code&gt;pg_upgrade&lt;/code&gt; hit two of the problems below: the checksum mismatch (step 2) and the &lt;code&gt;pg_hba.conf&lt;/code&gt; lockout (step 4).&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Move your volume into the one-mount layout
&lt;/h3&gt;

&lt;p&gt;Move the existing cluster into &lt;code&gt;&amp;lt;your major&amp;gt;/docker&lt;/code&gt; inside the same volume, set &lt;code&gt;PGDATA&lt;/code&gt;, and mount the parent. Do it on your current major, so the layout change and the version change happen on different days.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/docker-library/docs/blob/master/postgres/content.md" rel="noopener noreferrer"&gt;image docs&lt;/a&gt; describe this opt-in: set &lt;code&gt;PGDATA=/var/lib/postgresql/17/docker&lt;/code&gt;, mount at &lt;code&gt;/var/lib/postgresql&lt;/code&gt;, and first move "all database files into a &lt;code&gt;PG_MAJOR/docker&lt;/code&gt; subdirectory." Inside one volume the move is a rename, so it's near-instant at any size.&lt;/p&gt;

&lt;p&gt;Stop the database so nothing restarts it. &lt;code&gt;docker compose stop&lt;/code&gt; keeps it stopped; for a Swarm service, &lt;code&gt;docker service scale &amp;lt;service&amp;gt;=0&lt;/code&gt; &lt;a href="https://docs.docker.com/reference/cli/docker/service/scale/" rel="noopener noreferrer"&gt;stops all replicas&lt;/a&gt; while keeping the service. Compose names the volume &lt;code&gt;&amp;lt;project&amp;gt;_pgdata&lt;/code&gt;, so check the real name with &lt;code&gt;docker volume ls&lt;/code&gt;; a bare &lt;code&gt;pgdata&lt;/code&gt; would create a new, empty volume.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Untested. Runs as the postgres user so ownership stays the same.&lt;/span&gt;
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--user&lt;/span&gt; postgres &lt;span class="nt"&gt;--entrypoint&lt;/span&gt; sh &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; myapp_pgdata:/var/lib/postgresql/data postgres:17 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'
    cd /var/lib/postgresql/data &amp;amp;&amp;amp;
    mkdir -p 17/docker &amp;amp;&amp;amp; chmod 700 17/docker &amp;amp;&amp;amp;
    for f in *; do [ "$f" = 17 ] || mv "$f" 17/docker/; done'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then change the service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Untested.&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:17&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;PGDATA&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/var/lib/postgresql/17/docker&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;pgdata:/var/lib/postgresql&lt;/span&gt;   &lt;span class="c1"&gt;# was /var/lib/postgresql/data&lt;/span&gt;
    &lt;span class="na"&gt;stop_grace_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2m&lt;/span&gt;            &lt;span class="c1"&gt;# default is 10s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Gotcha:&lt;/strong&gt; both lines matter. Mount the parent on 17 without setting &lt;code&gt;PGDATA&lt;/code&gt;, and the image's own &lt;code&gt;VOLUME /var/lib/postgresql/data&lt;/code&gt; gives you an anonymous volume that won't survive the container being removed. Don't test it with &lt;code&gt;docker compose up --force-recreate&lt;/code&gt;: Compose keeps anonymous volumes across a recreate (&lt;a href="https://docs.docker.com/reference/cli/docker/compose/up/" rel="noopener noreferrer"&gt;docker compose up&lt;/a&gt;), so a test row survives anyway. Run &lt;code&gt;SHOW data_directory;&lt;/code&gt; instead; it should print &lt;code&gt;/var/lib/postgresql/17/docker&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The longer grace period matters because &lt;code&gt;pg_upgrade&lt;/code&gt; refuses a cluster that wasn't shut down cleanly, and the image's Dockerfile asks for a stop timeout longer than Docker's 10-second default. The docs don't describe an undo; moving the files back, dropping &lt;code&gt;PGDATA&lt;/code&gt;, and mounting &lt;code&gt;/var/lib/postgresql/data&lt;/code&gt; again should work, but that's an inference.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Check data checksums and the superuser
&lt;/h3&gt;

&lt;p&gt;This is where my 16 to 18 upgrade stopped first. Two settings decide whether the upgrade runs at all, so check both while 17 is still up.&lt;/p&gt;

&lt;p&gt;First, checksums: &lt;code&gt;docker compose exec db psql -U postgres -c 'SHOW data_checksums;'&lt;/code&gt;. 18's &lt;code&gt;initdb&lt;/code&gt; turns them on by default (&lt;a href="https://www.postgresql.org/docs/release/18.0/" rel="noopener noreferrer"&gt;18.0 release notes&lt;/a&gt;), and &lt;code&gt;pg_upgrade&lt;/code&gt; refuses a mismatch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;off&lt;/code&gt;&lt;/strong&gt; (the 17 image's default): &lt;code&gt;pg_upgrade&lt;/code&gt; stops with &lt;code&gt;old cluster does not use data checksums but the new one does&lt;/code&gt;. Create the new cluster with &lt;code&gt;--no-data-checksums&lt;/code&gt;, as a docker-library maintainer suggests in &lt;a href="https://github.com/tianon/docker-postgres-upgrade/issues/121" rel="noopener noreferrer"&gt;tianon issue #121&lt;/a&gt;. That's the flag that got mine through.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;on&lt;/code&gt;&lt;/strong&gt; (Immich's Compose file sets it, for example): leave the flag out, or you'll get the mirror error, &lt;code&gt;old cluster uses data checksums but the new one does not&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The upgrade image's README example never hits this, because it creates its 17 test cluster with checksums on. Real 17 clusters made with the defaults don't have them.&lt;/p&gt;

&lt;p&gt;Second, the superuser. If your Compose file sets &lt;code&gt;POSTGRES_USER=app&lt;/code&gt;, the cluster's install user is &lt;code&gt;app&lt;/code&gt;, and the upgrade image's plain &lt;code&gt;initdb&lt;/code&gt; creates &lt;code&gt;postgres&lt;/code&gt;, so the run fails. The workaround reported in tianon's issues is &lt;code&gt;-e PGUSER=app&lt;/code&gt; plus &lt;code&gt;--username=app&lt;/code&gt; in &lt;code&gt;POSTGRES_INITDB_ARGS&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Run pg_upgrade --check --link, then the real run
&lt;/h3&gt;

&lt;p&gt;There's no official way to get two Postgres versions into one container. &lt;a href="https://github.com/tianon/docker-postgres-upgrade" rel="noopener noreferrer"&gt;&lt;code&gt;tianon/postgres-upgrade&lt;/code&gt;&lt;/a&gt; is &lt;code&gt;postgres:18&lt;/code&gt; plus 17's binaries from the PostgreSQL apt repository. Its own README calls it a proof of concept to learn from and adapt.&lt;/p&gt;

&lt;p&gt;Stop the database again (&lt;code&gt;docker compose stop db&lt;/code&gt;) and keep it stopped. Nikolay Samokhvalov of Postgres.ai &lt;a href="https://hackorum.dev/topics/253409" rel="noopener noreferrer"&gt;reported&lt;/a&gt; on the pgsql-hackers list a production case where an upgrade's target was started mid-run and corruption surfaced weeks later. The cause isn't proven, but nothing in &lt;code&gt;pg_upgrade&lt;/code&gt; prevents it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Untested. One volume, mounted at the parent, for both clusters.&lt;/span&gt;
&lt;span class="c"&gt;# Keep POSTGRES_INITDB_ARGS only if SHOW data_checksums said off.&lt;/span&gt;
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; myapp_pgdata:/var/lib/postgresql &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;PGDATAOLD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/var/lib/postgresql/17/docker &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;PGDATANEW&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/var/lib/postgresql/18/docker &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_INITDB_ARGS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"--no-data-checksums"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  tianon/postgres-upgrade:17-to-18 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--check&lt;/span&gt; &lt;span class="nt"&gt;--link&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set &lt;code&gt;PGDATAOLD&lt;/code&gt; yourself: the image defaults it to &lt;code&gt;/var/lib/postgresql/17/data&lt;/code&gt;. Arguments that start with &lt;code&gt;-&lt;/code&gt; go straight to &lt;code&gt;pg_upgrade&lt;/code&gt;. If the check passes, run the same command without &lt;code&gt;--check&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pro tip:&lt;/strong&gt; always pass the mode flag with &lt;code&gt;--check&lt;/code&gt;. &lt;code&gt;--check --link&lt;/code&gt; makes a test hard link between the two directories and fails with &lt;code&gt;could not create hard link between old and new data directories: Invalid cross-device link&lt;/code&gt; if they're on different mounts. A plain &lt;code&gt;--check&lt;/code&gt; doesn't test linking at all.&lt;/p&gt;

&lt;p&gt;The check writes one thing: the image runs &lt;code&gt;initdb&lt;/code&gt; into &lt;code&gt;18/docker&lt;/code&gt; whenever that directory has no &lt;code&gt;PG_VERSION&lt;/code&gt;, and skips it on every later run. So after a failed check or run, delete &lt;code&gt;18/docker&lt;/code&gt; (only that one) before you try again. Otherwise a rerun with the checksum fix still meets the 18 cluster the failed attempt created.&lt;/p&gt;

&lt;p&gt;Two more things the upgrade container doesn't know about your setup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Extensions with C code&lt;/strong&gt; (PostGIS, pgvector, pg_cron) must be installed for both majors. &lt;code&gt;pg_upgrade&lt;/code&gt; loads every library the old cluster uses into the new one. The PostgreSQL apt repository has &lt;code&gt;postgresql-18-pgvector&lt;/code&gt; and &lt;code&gt;postgresql-18-postgis-3&lt;/code&gt;, so build your own image on top of tianon's when you need them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Settings from &lt;code&gt;command: postgres -c ...&lt;/code&gt;&lt;/strong&gt; don't exist in the upgrade container. Pass them with &lt;code&gt;-o '&amp;lt;options&amp;gt;'&lt;/code&gt; for the old server and &lt;code&gt;-O '&amp;lt;options&amp;gt;'&lt;/code&gt; for the new one; a missing library in &lt;code&gt;shared_preload_libraries&lt;/code&gt; stops the old server from starting.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Where's the point of no return? The moment &lt;code&gt;pg_upgrade&lt;/code&gt; renames the old &lt;code&gt;global/pg_control&lt;/code&gt; to &lt;code&gt;pg_control.old&lt;/code&gt;. Every check, the schema dump, and the schema restore happen before that, so a failure there leaves the old cluster untouched.&lt;/p&gt;

&lt;p&gt;Between the rename and the new server's first start, you can remove the &lt;code&gt;.old&lt;/code&gt; suffix and start 17 again. After that first start, the docs say: restore from backup.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Start 18 and give it what it didn't inherit
&lt;/h3&gt;

&lt;p&gt;This is the step that locked my app out. Before 18's first start, copy your access rules. Then switch the Compose file to &lt;code&gt;postgres:18.6&lt;/code&gt;, drop the &lt;code&gt;PGDATA&lt;/code&gt; line (18's default is &lt;code&gt;/var/lib/postgresql/18/docker&lt;/code&gt;), start it, and finish four jobs the upgrade leaves to you.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Restore client access.&lt;/strong&gt; The official image adds a &lt;code&gt;host all all all&lt;/code&gt; line to &lt;code&gt;pg_hba.conf&lt;/code&gt; only when it creates a cluster. An upgraded cluster skips that, so other containers get &lt;code&gt;no pg_hba.conf entry for host&lt;/code&gt;. That's what my app got, and blogger &lt;a href="https://rezhajul.io/posts/upgrading-postgresql-18-docker/" rel="noopener noreferrer"&gt;Rezha Julio&lt;/a&gt; hit it on all four instances he upgraded.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The &lt;a href="https://www.postgresql.org/docs/18/pgupgrade.html" rel="noopener noreferrer"&gt;pg_upgrade docs&lt;/a&gt; say the new cluster's configuration files may need adjusting to match the old one's (&lt;code&gt;postgresql.conf&lt;/code&gt;, &lt;code&gt;postgresql.auto.conf&lt;/code&gt;). In Docker that includes &lt;code&gt;pg_hba.conf&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   &lt;span class="c"&gt;# Untested. Before 18's first start.&lt;/span&gt;
   docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--user&lt;/span&gt; postgres &lt;span class="nt"&gt;--entrypoint&lt;/span&gt; sh &lt;span class="se"&gt;\&lt;/span&gt;
     &lt;span class="nt"&gt;-v&lt;/span&gt; myapp_pgdata:/var/lib/postgresql postgres:18.6 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'
       cp /var/lib/postgresql/17/docker/pg_hba.conf /var/lib/postgresql/18/docker/'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your &lt;code&gt;ALTER SYSTEM&lt;/code&gt; settings are in &lt;code&gt;17/docker/postgresql.auto.conf&lt;/code&gt;; re-apply the ones you still want. If you copy &lt;code&gt;pg_hba.conf&lt;/code&gt; after 18 has started, run &lt;code&gt;SELECT pg_reload_conf();&lt;/code&gt;.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Refresh statistics the right way.&lt;/strong&gt; 18 carries most planner statistics across, but not extended statistics or the counters that trigger autovacuum. Run the two commands &lt;code&gt;pg_upgrade&lt;/code&gt; prints:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   &lt;span class="c"&gt;# Untested.&lt;/span&gt;
   docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;db vacuumdb &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="nt"&gt;--analyze-in-stages&lt;/span&gt; &lt;span class="nt"&gt;--missing-stats-only&lt;/span&gt;
   docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;db vacuumdb &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="nt"&gt;--analyze-only&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep &lt;code&gt;--missing-stats-only&lt;/code&gt;: without it, &lt;code&gt;--analyze-in-stages&lt;/code&gt; replaces the statistics 18 just carried over with coarse ones for a while (&lt;a href="https://www.postgresql.org/docs/18/app-vacuumdb.html" rel="noopener noreferrer"&gt;vacuumdb docs&lt;/a&gt;).&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Update extensions and find MD5 passwords.&lt;/strong&gt; If an extension needs it, &lt;code&gt;pg_upgrade&lt;/code&gt; writes &lt;code&gt;update_extensions.sql&lt;/code&gt; to the volume root in tianon's image; run it with &lt;code&gt;psql&lt;/code&gt;. Role passwords carry over exactly as stored, and the advisory for &lt;a href="https://www.postgresql.org/support/security/CVE-2026-6478/" rel="noopener noreferrer"&gt;CVE-2026-6478&lt;/a&gt; notes MD5 hashes that came along from upgrades of 13 or earlier. List them with &lt;code&gt;SELECT rolname FROM pg_authid WHERE rolpassword LIKE 'md5%';&lt;/code&gt; and have those users set new passwords.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check collations.&lt;/strong&gt; The images moved from Debian bookworm (glibc 2.36) to trixie (glibc 2.41), and floating tags moved on August 11, 2025. &lt;code&gt;pg_upgrade&lt;/code&gt; keeps the old index files and the old recorded collation version. So you may see &lt;code&gt;The database was created using collation version 2.36, but the operating system provides version 2.41.&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Whether that changed how ordinary text sorts is unproven. The docs' rule is rebuild, then refresh (&lt;a href="https://www.postgresql.org/docs/18/sql-altercollation.html" rel="noopener noreferrer"&gt;ALTER COLLATION notes&lt;/a&gt;). Run it before the app starts, because a plain &lt;code&gt;REINDEX&lt;/code&gt; &lt;a href="https://www.postgresql.org/docs/18/sql-reindex.html" rel="noopener noreferrer"&gt;blocks most queries&lt;/a&gt; on each table it rebuilds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   &lt;span class="c"&gt;# Untested. Rebuild every database's indexes, then refresh each one's recorded version.&lt;/span&gt;
   docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;db reindexdb &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;--all&lt;/span&gt;
   docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;db psql &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'ALTER DATABASE &amp;lt;db&amp;gt; REFRESH COLLATION VERSION;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repeat the refresh for each database except &lt;code&gt;template0&lt;/code&gt;, which records no version (&lt;code&gt;template1&lt;/code&gt; and &lt;code&gt;postgres&lt;/code&gt; included). The refresh alone only silences the warning. &lt;code&gt;REINDEX&lt;/code&gt; rebuilds every index, the same work a restore spends building them, so if your 17 ran on bookworm, budget it into your window or take Path A.&lt;/p&gt;

&lt;p&gt;Now prove it worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to check the upgrade worked
&lt;/h2&gt;

&lt;p&gt;Five checks tell you the upgrade landed where you think it did. They work after either path.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Untested. On the upgraded server.&lt;/span&gt;
docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;db psql &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'SHOW server_version;'&lt;/span&gt;
docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;db psql &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'SHOW data_directory;'&lt;/span&gt;
docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;db psql &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'SHOW data_checksums;'&lt;/span&gt;
docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;db psql &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'SELECT datname, datcollversion, pg_database_collation_actual_version(oid) FROM pg_database WHERE datcollversion IS NOT NULL;'&lt;/span&gt;
&lt;span class="c"&gt;# Over the network, the way your app connects (export PGPASSWORD first):&lt;/span&gt;
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--network&lt;/span&gt; myapp_default &lt;span class="nt"&gt;-e&lt;/span&gt; PGPASSWORD postgres:18.6 &lt;span class="se"&gt;\&lt;/span&gt;
  psql &lt;span class="nt"&gt;-h&lt;/span&gt; db &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'SELECT 1;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What you should see:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;server_version&lt;/code&gt; starts with &lt;code&gt;18.6&lt;/code&gt;, or whichever 18 minor you pinned.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;data_directory&lt;/code&gt; is &lt;code&gt;/var/lib/postgresql/18/docker&lt;/code&gt;, inside the one mount.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;data_checksums&lt;/code&gt; matches your choice: &lt;code&gt;off&lt;/code&gt; if you passed &lt;code&gt;--no-data-checksums&lt;/code&gt;, &lt;code&gt;on&lt;/code&gt; after Path A or for a cluster that had them.&lt;/li&gt;
&lt;li&gt;The two collation columns: if they differ, step 4's &lt;code&gt;REINDEX&lt;/code&gt; and refresh are still due, which is expected after Path B from a bookworm cluster. Matching columns mean the recorded version equals the library's; they never prove a &lt;code&gt;REINDEX&lt;/code&gt; ran.&lt;/li&gt;
&lt;li&gt;The network query prints one row. &lt;code&gt;no pg_hba.conf entry for host&lt;/code&gt; means other containers are still refused, which is the stop I hit. The &lt;code&gt;exec&lt;/code&gt; checks above can't catch it: they use the local socket, not the network.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then read every table once, because &lt;code&gt;--link&lt;/code&gt; never reads your data pages. CYBERTEC's Kaarel Moppel &lt;a href="https://www.cybertec-postgresql.com/en/a-primer-on-postgresql-upgrade-methods/" rel="noopener noreferrer"&gt;checks&lt;/a&gt; that a dump to &lt;code&gt;/dev/null&lt;/code&gt; "runs through clean": &lt;code&gt;docker compose exec db pg_dumpall -U postgres &amp;gt; /dev/null&lt;/code&gt;. It reads tables, not indexes, and takes as long as a dump.&lt;/p&gt;

&lt;p&gt;Last, cleanup. After Path B, run the &lt;code&gt;delete_old_cluster.sh&lt;/code&gt; script &lt;code&gt;pg_upgrade&lt;/code&gt; left in the volume root, and expect it to free almost no space: after &lt;code&gt;--link&lt;/code&gt;, the old and new names point at the same files. After Path A, delete the old volume only once the app has run cleanly on 18. If a step stopped with a message instead, find it below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Errors you might see, and what fixes them
&lt;/h2&gt;

&lt;p&gt;Each of the first five rows points back to where this guide fixes it; the last six are rarer.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Message&lt;/th&gt;
&lt;th&gt;Cause&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Error: in 18+, these Docker images are configured to store database data in a format...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Old data or a mount at &lt;code&gt;/var/lib/postgresql/data&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Path A, or step 1 first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;old cluster does not use data checksums but the new one does&lt;/code&gt; (or the reverse)&lt;/td&gt;
&lt;td&gt;Checksum settings differ&lt;/td&gt;
&lt;td&gt;Step 2, then empty &lt;code&gt;18/docker&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;could not create hard link ... Invalid cross-device link&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The clusters are on different mounts&lt;/td&gt;
&lt;td&gt;One volume at the parent (step 1)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no pg_hba.conf entry for host&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No rule for other containers&lt;/td&gt;
&lt;td&gt;Step 4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;database "&amp;lt;db&amp;gt;" has a collation version mismatch&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Old C library's indexes and recorded version&lt;/td&gt;
&lt;td&gt;Step 4: &lt;code&gt;REINDEX&lt;/code&gt;, then refresh&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;error mounting ... to rootfs at "/var/lib/postgresql/data"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;An 18.0-era image with the &lt;code&gt;data&lt;/code&gt; symlink&lt;/td&gt;
&lt;td&gt;Mount the parent; use a current 18 tag&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;You must identify the directory where the old cluster binaries reside&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pg_upgrade&lt;/code&gt; ran inside the plain 18 image (&lt;a href="https://github.com/docker-library/postgres/issues/1377" rel="noopener noreferrer"&gt;issue #1377&lt;/a&gt;)&lt;/td&gt;
&lt;td&gt;Use an image with both versions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;The source cluster was not shut down cleanly&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The old container was killed, often by a short stop timeout (&lt;a href="https://github.com/tianon/docker-postgres-upgrade/issues/27" rel="noopener noreferrer"&gt;tianon #27&lt;/a&gt;)&lt;/td&gt;
&lt;td&gt;Start 17 on the volume, stop it cleanly, rerun&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FATAL: role "postgres" does not exist&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The cluster's superuser isn't &lt;code&gt;postgres&lt;/code&gt; (&lt;a href="https://github.com/tianon/docker-postgres-upgrade/issues/88" rel="noopener noreferrer"&gt;tianon #88&lt;/a&gt;)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;PGUSER&lt;/code&gt; plus &lt;code&gt;--username&lt;/code&gt; (step 2)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Your installation references loadable libraries that are missing from the new installation.&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;An extension's library isn't in the upgrade image for 18&lt;/td&gt;
&lt;td&gt;Install it; the list is in &lt;code&gt;loadable_libraries.txt&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Only the install user can be defined in the new cluster.&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The new cluster already has extra roles, for example from &lt;code&gt;/docker-entrypoint-initdb.d&lt;/code&gt; scripts when &lt;code&gt;postgres:18&lt;/code&gt; was started on it first&lt;/td&gt;
&lt;td&gt;Empty &lt;code&gt;18/docker&lt;/code&gt; and let the upgrade image create it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On Dokploy, one more problem waits for the upgrade to 19.&lt;/p&gt;

&lt;h2&gt;
  
  
  On Dokploy: the mount that blocks an in-place upgrade to 19
&lt;/h2&gt;

&lt;p&gt;Dokploy mounts a Postgres volume directly at &lt;code&gt;PGDATA&lt;/code&gt; for 18 and later, so a &lt;code&gt;--link&lt;/code&gt; upgrade to 19 can't happen inside that volume as it is. My 16 to 18 &lt;code&gt;pg_upgrade&lt;/code&gt; ran on Dokploy, and my database there runs 18 today, but the routes below are the general ones, not a replay of that run. Check your own service's mount with &lt;code&gt;docker inspect&lt;/code&gt; before you plan 19.&lt;/p&gt;

&lt;p&gt;Dokploy's source (v0.30.8, the latest release as of October 2026) defaults new Postgres services to &lt;code&gt;postgres:18&lt;/code&gt;. For an 18+ image, it mounts the named volume at &lt;code&gt;/var/lib/postgresql/&amp;lt;major&amp;gt;/docker&lt;/code&gt;, so &lt;code&gt;19/docker&lt;/code&gt; would sit outside it.&lt;/p&gt;

&lt;p&gt;The mount is set once, at creation. A maintainer's &lt;a href="https://github.com/Dokploy/dokploy/pull/5005" rel="noopener noreferrer"&gt;PR #5005&lt;/a&gt; says changing the image never moves it, and that &lt;code&gt;postgres:16&lt;/code&gt; to &lt;code&gt;postgres:18&lt;/code&gt; "starts with an empty cluster"; the fix was a warning in the UI. Current Debian 18 images should refuse to start instead, as in the first section.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;Where the volume mounts for 18+&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;--link&lt;/code&gt; to 19 in place&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Plain Compose (as in step 1)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/var/lib/postgresql&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Works&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Coolify, services created on 18+&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/var/lib/postgresql&lt;/code&gt; (&lt;a href="https://coolify.io/docs/databases/configuration/persistent-storage" rel="noopener noreferrer"&gt;Coolify docs&lt;/a&gt;)&lt;/td&gt;
&lt;td&gt;Works&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dokploy&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/var/lib/postgresql/18/docker&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Blocked by the mount&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You have two routes on Dokploy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dump and restore into a new 19 service.&lt;/strong&gt; Dokploy's backups run &lt;code&gt;pg_dump -Fc --no-acl --no-owner&lt;/code&gt; for one database, so they don't carry roles or grants. Take a &lt;code&gt;pg_dumpall&lt;/code&gt; with the 19 tools for a full copy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remount, then link.&lt;/strong&gt; Scale the service to 0, move the volume's contents into an &lt;code&gt;18/docker&lt;/code&gt; subdirectory (step 1's command, with 18 in place of 17 and Dokploy's volume name, &lt;code&gt;&amp;lt;appName&amp;gt;-data&lt;/code&gt;), then change the mount to &lt;code&gt;/var/lib/postgresql&lt;/code&gt;. Dokploy's Volumes settings can edit a mount, but its docs don't cover this route, so rehearse it on a copy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Either way, 19 itself isn't ready yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Upgrading to 19: what's ready and what isn't
&lt;/h2&gt;

&lt;p&gt;As of October 9, 2026, 19 is at Beta 4, with release candidate 1 planned for October 15 and general availability for October 29 (&lt;a href="https://wiki.postgresql.org/wiki/PostgreSQL_19_Open_Items" rel="noopener noreferrer"&gt;19 Open Items&lt;/a&gt;). There's no &lt;code&gt;postgres:19&lt;/code&gt; tag and no &lt;code&gt;18-to-19&lt;/code&gt; upgrade image yet.&lt;/p&gt;

&lt;p&gt;That's where the 18 layout pays off: &lt;code&gt;18/docker&lt;/code&gt; and &lt;code&gt;19/docker&lt;/code&gt; in one volume, linked in seconds. Three things to know first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Build the upgrade image yourself&lt;/strong&gt; until tianon publishes one: copy his &lt;code&gt;17-to-18&lt;/code&gt; Dockerfile and &lt;code&gt;docker-upgrade&lt;/code&gt; script and change the versions, including the pinned old-version package. The PostgreSQL apt repository already carries &lt;code&gt;postgresql-18&lt;/code&gt; next to the 19 beta packages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The checksum choice follows you.&lt;/strong&gt; An 18 cluster created with &lt;code&gt;--no-data-checksums&lt;/code&gt; fails against 19's default &lt;code&gt;initdb&lt;/code&gt; the same way. Pass the flag again, or turn checksums on in the stopped 18 cluster with 18's &lt;a href="https://www.postgresql.org/docs/18/app-pgchecksums.html" rel="noopener noreferrer"&gt;&lt;code&gt;pg_checksums --enable&lt;/code&gt;&lt;/a&gt;, an offline rewrite of the data files. Online enabling was added to 19 and then &lt;a href="https://www.postgresql.org/about/news/postgresql-19-beta-4-released-3386/" rel="noopener noreferrer"&gt;reverted in Beta 4&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;19's &lt;code&gt;pg_upgrade&lt;/code&gt; refuses two things&lt;/strong&gt; a 14 to 18 cluster can have: btree_gist indexes on &lt;code&gt;inet&lt;/code&gt; or &lt;code&gt;cidr&lt;/code&gt; columns, and carriage returns or line feeds in database, role, or tablespace names (&lt;a href="https://www.postgresql.org/docs/19/release-19.html" rel="noopener noreferrer"&gt;19 release notes, beta&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Should you go on release day? 18.3 shipped out of cycle with a catalog fix that 18.0 to 18.2 users had to &lt;a href="https://www.postgresql.org/docs/release/18.3/" rel="noopener noreferrer"&gt;apply by hand&lt;/a&gt;, while clusters upgraded straight into 18.3 or later got it for free. So upgrade into the newest minor and treat 19.0 as a test target.&lt;/p&gt;

&lt;p&gt;When clients ask me whether to upgrade, I say yes, before support ends, only after a tested backup, and in a quiet hour we've scheduled. On 14 or 15, don't wait for 19 at all: go to the latest 18 minor before 14's final release on November 12, 2026 (&lt;a href="https://www.postgresql.org/support/versioning/" rel="noopener noreferrer"&gt;versioning policy&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  When not to use pg_upgrade --link
&lt;/h2&gt;

&lt;p&gt;Even when your rehearsal doesn't fit the window, think twice about &lt;code&gt;--link&lt;/code&gt; in five cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You're coming from Alpine.&lt;/strong&gt; Alpine and Debian use different C libraries and different &lt;code&gt;postgres&lt;/code&gt; user IDs, and tianon's image is Debian-based. Take Path A.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You're also moving from bookworm to trixie.&lt;/strong&gt; &lt;code&gt;pg_upgrade&lt;/code&gt; keeps the old text indexes, and the docs say to rebuild them when the library changes. Budget the &lt;code&gt;REINDEX&lt;/code&gt; from step 4, or take Path A, which rebuilds them anyway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You haven't restored your backup.&lt;/strong&gt; After the new server starts, it's your only rollback.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You can only afford seconds of downtime.&lt;/strong&gt; Logical replication gets the switchover to "several seconds," per the docs, but it's a project for a small team: schema copied by hand, sequences synced by hand, no DDL during the copy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You're moving between x86 and ARM.&lt;/strong&gt; Upgrade on the original machine first, then move. If the data already moved, the docs' &lt;code&gt;--set-char-signedness&lt;/code&gt; option exists for this.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The third answer that would worry me is "it's managed, not our problem." Amazon RDS and Supabase run &lt;code&gt;pg_upgrade&lt;/code&gt; for you, and Neon has you dump and restore, or replicate, into a new project. Moving to managed Postgres doesn't remove the upgrade; it moves it to someone else's runbook, with the same "restore from backup" as the way back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check two lines before 19 ships
&lt;/h2&gt;

&lt;p&gt;Mine went fine, under a minute down, after two stops. I've also seen the other ending, with a client: an upgrade that came up empty, and an app that couldn't connect for a few hours. My two stops are two lines you can check today, on your current server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Untested.&lt;/span&gt;
docker &lt;span class="nb"&gt;exec&lt;/span&gt; &amp;lt;container&amp;gt; psql &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'SHOW data_checksums;'&lt;/span&gt;
docker &lt;span class="nb"&gt;exec&lt;/span&gt; &amp;lt;container&amp;gt; psql &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"SELECT type, database, user_name, address, auth_method FROM pg_hba_file_rules WHERE type LIKE 'host%';"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second (&lt;a href="https://www.postgresql.org/docs/18/view-pg-hba-file-rules.html" rel="noopener noreferrer"&gt;pg_hba_file_rules&lt;/a&gt;, superuser only) shows the rule your other containers connect through, the one an upgraded cluster won't have until you copy it. Then make the decision:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Check the mount with &lt;code&gt;docker inspect&lt;/code&gt; (first section): one mount at &lt;code&gt;/var/lib/postgresql&lt;/code&gt;, or, for Path B, a layout move first.&lt;/li&gt;
&lt;li&gt;[ ] Dump and restore into a scratch volume, and time it.&lt;/li&gt;
&lt;li&gt;[ ] It fits your downtime window: Path A, into a fresh 18 volume.&lt;/li&gt;
&lt;li&gt;[ ] It doesn't: move to the one-mount layout on your current major, then &lt;code&gt;--check --link&lt;/code&gt;, then the real run.&lt;/li&gt;
&lt;li&gt;[ ] Either way: copy &lt;code&gt;pg_hba.conf&lt;/code&gt; before 18's first start, and run the five checks after.&lt;/li&gt;
&lt;li&gt;[ ] On Dokploy: plan the remount or a new service before 19.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Run the first command now. What it prints decides whether you pass &lt;code&gt;--no-data-checksums&lt;/code&gt;, at 18 and again at 19.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;a href="https://github.com/docker-library/docs/blob/master/postgres/content.md" rel="noopener noreferrer"&gt;postgres official image documentation&lt;/a&gt;, docker-library, accessed 2026-09-27.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/docker-library/postgres/pull/1259" rel="noopener noreferrer"&gt;Pull request #1259, version-specific PGDATA&lt;/a&gt;, docker-library/postgres, accessed 2026-09-27.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/docker-library/postgres/pull/1372" rel="noopener noreferrer"&gt;Pull request #1372, data symlink removed, better detection&lt;/a&gt;, docker-library/postgres, accessed 2026-09-27.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/docker-library/postgres/blob/e00e1bd34ec5c8a8e7ad89b273b3d42efaf6d5bc/18/trixie/docker-entrypoint.sh" rel="noopener noreferrer"&gt;docker-entrypoint.sh, 18/trixie at commit e00e1bd&lt;/a&gt;, docker-library/postgres, accessed 2026-10-08.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.postgresql.org/docs/18/pgupgrade.html" rel="noopener noreferrer"&gt;pg_upgrade&lt;/a&gt;, PostgreSQL 18 documentation, accessed 2026-10-08.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.postgresql.org/docs/18/upgrading.html" rel="noopener noreferrer"&gt;Upgrading a PostgreSQL Cluster&lt;/a&gt;, PostgreSQL 18 documentation, accessed 2026-09-27.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.postgresql.org/docs/release/18.0/" rel="noopener noreferrer"&gt;PostgreSQL 18.0 release notes&lt;/a&gt;, PostgreSQL, accessed 2026-09-27.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.postgresql.org/docs/release/18.3/" rel="noopener noreferrer"&gt;PostgreSQL 18.3 release notes&lt;/a&gt;, PostgreSQL, accessed 2026-10-08.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.postgresql.org/docs/18/app-vacuumdb.html" rel="noopener noreferrer"&gt;vacuumdb&lt;/a&gt;, PostgreSQL 18 documentation, accessed 2026-10-08.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.postgresql.org/docs/18/app-pgchecksums.html" rel="noopener noreferrer"&gt;pg_checksums&lt;/a&gt;, PostgreSQL 18 documentation, accessed 2026-10-08.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.postgresql.org/docs/18/sql-altercollation.html" rel="noopener noreferrer"&gt;ALTER COLLATION&lt;/a&gt;, PostgreSQL 18 documentation, accessed 2026-10-08.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.postgresql.org/support/security/CVE-2026-6478/" rel="noopener noreferrer"&gt;CVE-2026-6478&lt;/a&gt;, PostgreSQL security, accessed 2026-09-27.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.postgresql.org/support/versioning/" rel="noopener noreferrer"&gt;Versioning policy&lt;/a&gt;, PostgreSQL, accessed 2026-10-08.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wiki.postgresql.org/wiki/PostgreSQL_19_Open_Items" rel="noopener noreferrer"&gt;PostgreSQL 19 Open Items&lt;/a&gt;, PostgreSQL wiki, accessed 2026-10-09.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.postgresql.org/about/news/postgresql-19-beta-4-released-3386/" rel="noopener noreferrer"&gt;PostgreSQL 19 Beta 4 Released!&lt;/a&gt;, PostgreSQL, accessed 2026-10-08.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.postgresql.org/docs/19/release-19.html" rel="noopener noreferrer"&gt;PostgreSQL 19 release notes (beta)&lt;/a&gt;, PostgreSQL, accessed 2026-10-08.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.postgresql.org/docs/18/view-pg-hba-file-rules.html" rel="noopener noreferrer"&gt;pg_hba_file_rules&lt;/a&gt;, PostgreSQL 18 documentation, accessed 2026-10-09.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://man7.org/linux/man-pages/man2/link.2.html" rel="noopener noreferrer"&gt;link(2) manual page&lt;/a&gt;, man7.org, accessed 2026-10-08.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.docker.com/reference/cli/docker/compose/up/" rel="noopener noreferrer"&gt;docker compose up&lt;/a&gt;, Docker documentation, accessed 2026-10-08.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.docker.com/reference/cli/docker/service/scale/" rel="noopener noreferrer"&gt;docker service scale&lt;/a&gt;, Docker documentation, accessed 2026-10-08.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/tianon/docker-postgres-upgrade" rel="noopener noreferrer"&gt;docker-postgres-upgrade&lt;/a&gt;, tianon, accessed 2026-10-08.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/tianon/docker-postgres-upgrade/issues/121" rel="noopener noreferrer"&gt;tianon issue #121, checksum mismatch&lt;/a&gt;, accessed 2026-10-08.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/Dokploy/dokploy/pull/5005" rel="noopener noreferrer"&gt;Dokploy pull request #5005&lt;/a&gt;, Dokploy, accessed 2026-10-08.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://coolify.io/docs/databases/configuration/persistent-storage" rel="noopener noreferrer"&gt;Configure persistent storage&lt;/a&gt;, Coolify documentation, accessed 2026-10-08.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.endpointdev.com/blog/2015/07/how-fast-is-pgupgrade-anyway/" rel="noopener noreferrer"&gt;How fast is pg_upgrade anyway?&lt;/a&gt;, Greg Sabino Mullane, End Point, 2015, accessed 2026-10-08.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.dbi-services.com/blog/postgresql-18-swap-mode-for-pg_upgrade/" rel="noopener noreferrer"&gt;PostgreSQL 18: swap mode for pg_upgrade&lt;/a&gt;, Daniel Westermann, dbi services, 2025, accessed 2026-10-08.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://about.gitlab.com/blog/postmortem-of-database-outage-of-january-31/" rel="noopener noreferrer"&gt;Postmortem of database outage of January 31&lt;/a&gt;, GitLab, 2017, accessed 2026-10-08.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.cybertec-postgresql.com/en/a-primer-on-postgresql-upgrade-methods/" rel="noopener noreferrer"&gt;Upgrade to a new PostgreSQL major version&lt;/a&gt;, Kaarel Moppel, CYBERTEC, 2019, accessed 2026-09-27.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://rezhajul.io/posts/upgrading-postgresql-18-docker/" rel="noopener noreferrer"&gt;Upgrading four PostgreSQL instances to 18.2 in Docker&lt;/a&gt;, Rezha Julio, 2026, accessed 2026-10-08.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://hackorum.dev/topics/253409" rel="noopener noreferrer"&gt;Prevent premature startup of pg_upgrade targets&lt;/a&gt;, Nikolay Samokhvalov, pgsql-hackers, 2026, accessed 2026-10-08.&lt;/li&gt;
&lt;/ol&gt;




&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tested with:&lt;/strong&gt; nothing run for this post. Commands are assembled from the PostgreSQL 18 docs and the official image source for &lt;code&gt;postgres:18.6&lt;/code&gt; and &lt;code&gt;postgres:17.11&lt;/code&gt; (Debian trixie), plus &lt;code&gt;tianon/postgres-upgrade:17-to-18&lt;/code&gt;, as of October 2026.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Changelog:&lt;/strong&gt; First drafted October 2026, rewritten October 9, 2026, and published October 24, 2026.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>postgres</category>
      <category>docker</category>
      <category>pgupgrade</category>
      <category>postgresql18</category>
    </item>
    <item>
      <title>Postgres 14 end of life: what it means for your business, and what to ask your developer</title>
      <dc:creator>Saimul Islam</dc:creator>
      <pubDate>Mon, 02 Nov 2026 13:42:48 +0000</pubDate>
      <link>https://dev.to/saimulislamdl/postgres-14-end-of-life-what-it-means-for-your-business-and-what-to-ask-your-developer-52cm</link>
      <guid>https://dev.to/saimulislamdl/postgres-14-end-of-life-what-it-means-for-your-business-and-what-to-ask-your-developer-52cm</guid>
      <description>&lt;p&gt;If your app runs on Postgres 14, yes, you need to upgrade, and soon. Postgres is the database many web apps keep their customers, orders, and accounts in, and version 14 stops getting security fixes on November 12, 2026. The upgrade is safe when it's done in the right order, and risky when it isn't.&lt;/p&gt;

&lt;p&gt;Here's what a failed upgrade looked like for a client of mine. Their database was upgraded, came back empty, and the app couldn't connect. They were down for a few hours.&lt;/p&gt;

&lt;p&gt;My own upgrade, on a database I run myself, went the other way: under a minute of downtime. It still stopped twice, once on a settings mismatch and once because the app couldn't connect afterward.&lt;/p&gt;

&lt;p&gt;This guide is for founders and owners whose developer or agency runs their database. You'll learn whether this affects you, what's at stake, your options, and the questions that tell you your developer has it covered.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key takeaways&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Postgres 14 gets its last fixes on November 12, 2026. After that, new security problems in 14 stay unfixed.&lt;/li&gt;
&lt;li&gt;A safe upgrade starts with a backup that someone has restored on purpose, with the time it took written down.&lt;/li&gt;
&lt;li&gt;"Just change the version" is the risky way. If your database runs in Docker (a tool that packages it to run on a server), Postgres 18 moved where the data lives. A quick version swap can refuse to start or come up empty.&lt;/li&gt;
&lt;li&gt;The fastest method can keep downtime to seconds or minutes, but it has no undo once the new version starts. Your restored backup is the undo.&lt;/li&gt;
&lt;li&gt;Postgres 19 is planned for October 29, 2026 (as of October 2026). Don't put your live app on it on day one.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Table of contents&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Does this affect you?&lt;/li&gt;
&lt;li&gt;What's at stake&lt;/li&gt;
&lt;li&gt;Why upgrades go wrong&lt;/li&gt;
&lt;li&gt;Your options&lt;/li&gt;
&lt;li&gt;What to ask your developer&lt;/li&gt;
&lt;li&gt;How to tell it's done&lt;/li&gt;
&lt;li&gt;When this doesn't apply to you&lt;/li&gt;
&lt;li&gt;Upgrade before November, with proof&lt;/li&gt;
&lt;li&gt;Sources&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Does this affect you?
&lt;/h2&gt;

&lt;p&gt;Ask your developer these four questions. Each takes a minute to answer.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Is our database Postgres, and which version?&lt;/strong&gt; Version 14 is the urgent one. Version 13 stopped getting fixes in November 2025, and older versions before that, so they're overdue. Version 15 has until November 2027.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does it run in Docker?&lt;/strong&gt; If yes, the version 18 change below applies to you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is it on a tool like Dokploy or Coolify?&lt;/strong&gt; These are tools your developer uses to run apps on a server you rent. Dokploy needs one extra decision before the next upgrade.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When was our backup last restored as a test?&lt;/strong&gt; If the answer is "never," start there.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're on 14 or 15 and nobody has restored a backup, this is your next database job.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's at stake
&lt;/h2&gt;

&lt;p&gt;There are two risks: staying on an old version, and a botched upgrade.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Staying on 14.&lt;/strong&gt; As of October 2026, the Postgres project's &lt;a href="https://www.postgresql.org/support/security/" rel="noopener noreferrer"&gt;security page&lt;/a&gt; lists 38 security problems fixed in version 14 across its last four quarterly releases. Twenty-four of them scored 7.0 or more out of 10 on the standard severity scale. After November 12, problems like those stop getting fixed in 14 at all (&lt;a href="https://www.postgresql.org/support/versioning/" rel="noopener noreferrer"&gt;PostgreSQL versioning policy&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;How likely is an attack? Most of those 38 (28 of them) need someone who can already log in to the database, and none is on &lt;a href="https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json" rel="noopener noreferrer"&gt;CISA's list&lt;/a&gt; of flaws known to be used in attacks (as of October 2026). So none of these is known to be under attack. The bigger risk is that every problem found after November 12 stays open.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A botched upgrade.&lt;/strong&gt; My client's few hours looked like this: the app was down, and the database looked empty. What would a few hours of your app being down cost you?&lt;/p&gt;

&lt;p&gt;The worse version is a backup that doesn't work when you need it. In January 2017, GitLab's backups had been failing quietly. Its &lt;a href="https://about.gitlab.com/blog/postmortem-of-database-outage-of-january-31/" rel="noopener noreferrer"&gt;public write-up of the outage&lt;/a&gt; says it lost over six hours of database changes, affecting roughly 5,000 projects, 5,000 comments, and 700 new user accounts.&lt;/p&gt;

&lt;p&gt;How likely is the bad version? The research for this guide found no reliable published failure rate for database upgrades, only small, old community polls (as of October 2026). What the documented failures have in common is a skipped step: a backup nobody tested, or a version swap without moving the data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why upgrades go wrong
&lt;/h2&gt;

&lt;p&gt;Postgres 18's official Docker setup moved where the data lives on the server. Think of a warehouse that changed which shelf it keeps your stock on. If your developer only changes the version number, the new database looks on the new shelf and finds nothing.&lt;/p&gt;

&lt;p&gt;On current versions it then refuses to start, which is the safe outcome. On some older setups it started fresh and empty instead. The analogy stops there: your data isn't gone in that case, it's on the old shelf, as long as nobody "fixes" things by starting over.&lt;/p&gt;

&lt;p&gt;Two more things trip people up, and they're the two stops in my own upgrade. Version 18 turns on a data safety feature by default that older databases usually don't have, so the upgrade stops with an error until your developer matches the setting. And the upgraded database starts with fresh access rules, which is why my app couldn't connect. Both are routine to fix when someone expects them.&lt;/p&gt;

&lt;p&gt;Your developer can find the full walkthrough in &lt;a href="https://developerlook.com/blog/postgres-docker-upgrade" rel="noopener noreferrer"&gt;the developer version of this guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your options
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;What it costs&lt;/th&gt;
&lt;th&gt;Risk left&lt;/th&gt;
&lt;th&gt;Who it suits&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Do nothing&lt;/td&gt;
&lt;td&gt;Nothing now&lt;/td&gt;
&lt;td&gt;No security fixes for 14 after November 12, 2026&lt;/td&gt;
&lt;td&gt;Nobody past November&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Copy and reload (dump and restore)&lt;/td&gt;
&lt;td&gt;Downtime while everything is copied out and back in. My test restore alone took 10 minutes (the database was a couple hundred MB)&lt;/td&gt;
&lt;td&gt;Low: the old database stays untouched as your way back&lt;/td&gt;
&lt;td&gt;Most small and medium apps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fast upgrade on the same server (pg_upgrade)&lt;/td&gt;
&lt;td&gt;Seconds to minutes of downtime, plus one-time preparation&lt;/td&gt;
&lt;td&gt;In its fastest mode, no undo once the new version starts, except your restored backup&lt;/td&gt;
&lt;td&gt;Bigger databases, or apps that can't be down for long&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Move to a hosted database (a company runs it for you)&lt;/td&gt;
&lt;td&gt;A monthly bill and a one-time move&lt;/td&gt;
&lt;td&gt;As of October 2026, some providers (Amazon RDS, Supabase) run the upgrade for you; others, like Neon, leave it to you. You still schedule it and test it&lt;/td&gt;
&lt;td&gt;Teams with nobody to own the database&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;How big is the speed gap? In one published test from 2015, on older versions, copying and reloading 8.5 GB took 1,927 seconds, about 32 minutes. The fast method's quickest mode took about 13 seconds at every size tested (&lt;a href="https://www.endpointdev.com/blog/2015/07/how-fast-is-pgupgrade-anyway/" rel="noopener noreferrer"&gt;End Point&lt;/a&gt;). For a small database, copy and reload is usually fast enough, and it's simpler.&lt;/p&gt;

&lt;p&gt;My own pick for a live database is pg_upgrade, the fast method, because it's faster on big ones. On a small database, copy and reload is simpler and fast enough. A practice run on a copy shows which one you have.&lt;/p&gt;

&lt;p&gt;When a client asks me whether to upgrade, I say yes, before support ends. But only after a backup has been restored as a test, and in a quiet hour we've scheduled.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to ask your developer
&lt;/h2&gt;

&lt;p&gt;Three answers would worry me before any database upgrade: "we'll just change the version," "we have backups" from someone who has never restored one, and "it's managed, not our problem." These questions get past all three.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;"Which version are we on, and when does it stop getting security fixes?"&lt;/strong&gt; A good answer is a number and a date: for 14, November 12, 2026.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"When did you last restore our backup, and how long did it take?"&lt;/strong&gt; A good answer is a date and a duration. "We have backups" isn't an answer until one has been restored.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Which upgrade method will you use, and how long will the app be down?"&lt;/strong&gt; A good answer names the method and gives a downtime number from a practice run on a copy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"If it goes wrong halfway, how do we get back?"&lt;/strong&gt; A good answer is either "the old database stays untouched until we've checked," or "we restore the backup, and that takes about X minutes."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"After the upgrade, how will you check the app connects and all the data is there?"&lt;/strong&gt; A good answer mentions logging in to the app, checking key records, and reading the whole database once without errors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you're on Dokploy: "Is our database set up so the next upgrade can be the fast kind?"&lt;/strong&gt; Dokploy's default setup for Postgres 18 (as of October 2026) blocks the fastest kind of upgrade to 19. A good answer is a plan: a copy and reload, or a one-time storage change first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Will we move to 19 on release day?"&lt;/strong&gt; A good answer is "no, we'll wait for the first fixes." Version 18's early months needed fixes, including one that some early adopters had to apply by hand (&lt;a href="https://www.postgresql.org/docs/release/18.3/" rel="noopener noreferrer"&gt;18.3 release notes&lt;/a&gt;).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you hear "it's managed, not our problem," ask who tests the backup. Someone still has to.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to tell it's done
&lt;/h2&gt;

&lt;p&gt;Ask for proof, not a promise:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The new version number&lt;/strong&gt;, shown by the database itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The restore test&lt;/strong&gt;: the date, how long it took, and what was checked afterward.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The downtime&lt;/strong&gt;: when the app went down and when it came back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A check after the upgrade&lt;/strong&gt;: logins work, recent orders or records are there, and the app's logs show no connection errors.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When this doesn't apply to you
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You're already on 16, 17 or 18, with a tested backup.&lt;/strong&gt; You have time. Plan 19 for after its first fixes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your database isn't Postgres.&lt;/strong&gt; The deadline here is Postgres's. Ask your developer when your database's support ends.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A hosting company runs your database.&lt;/strong&gt; Ask whether it runs the upgrade or leaves it to you, when, and whether you can test first.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Upgrade before November, with proof
&lt;/h2&gt;

&lt;p&gt;My client was down for a few hours. My own upgrade had the database down for under a minute. Both ended with an app that couldn't connect at first. That's what questions 2, 4 and 5 are for.&lt;/p&gt;

&lt;p&gt;Here's the decision. On 14, schedule the upgrade before November 12, 2026. On 15, you have until November 2027, and the same steps apply. Either way, start with a restored backup.&lt;/p&gt;

&lt;p&gt;Your next step is one message to your developer with questions 1 and 2, and a link to &lt;a href="https://developerlook.com/blog/postgres-docker-upgrade" rel="noopener noreferrer"&gt;the developer version of this guide&lt;/a&gt; for them. A backup you've never restored isn't a backup. It's a file you feel good about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;a href="https://www.postgresql.org/support/versioning/" rel="noopener noreferrer"&gt;Versioning policy&lt;/a&gt;, PostgreSQL, accessed 2026-10-08.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.postgresql.org/support/security/" rel="noopener noreferrer"&gt;PostgreSQL security information&lt;/a&gt;, PostgreSQL, accessed 2026-10-08.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://about.gitlab.com/blog/postmortem-of-database-outage-of-january-31/" rel="noopener noreferrer"&gt;Postmortem of database outage of January 31&lt;/a&gt;, GitLab, 2017, accessed 2026-10-08.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.endpointdev.com/blog/2015/07/how-fast-is-pgupgrade-anyway/" rel="noopener noreferrer"&gt;How fast is pg_upgrade anyway?&lt;/a&gt;, Greg Sabino Mullane, End Point, 2015, accessed 2026-10-08.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.postgresql.org/docs/release/18.3/" rel="noopener noreferrer"&gt;PostgreSQL 18.3 release notes&lt;/a&gt;, PostgreSQL, accessed 2026-10-08.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.postgresql.org/docs/18/pgupgrade.html" rel="noopener noreferrer"&gt;pg_upgrade&lt;/a&gt;, PostgreSQL 18 documentation, accessed 2026-10-08.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wiki.postgresql.org/wiki/PostgreSQL_19_Open_Items" rel="noopener noreferrer"&gt;PostgreSQL 19 Open Items&lt;/a&gt;, PostgreSQL wiki, accessed 2026-10-09.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/Dokploy/dokploy/pull/5005" rel="noopener noreferrer"&gt;Dokploy pull request #5005&lt;/a&gt;, Dokploy, accessed 2026-10-08.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json" rel="noopener noreferrer"&gt;Known Exploited Vulnerabilities catalog&lt;/a&gt;, CISA, catalog version 2026.10.04, accessed 2026-10-08.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>postgres</category>
      <category>databaseupgrade</category>
      <category>endoflife</category>
      <category>smallbusiness</category>
    </item>
    <item>
      <title>10 Essential Tools I Actually Use to Keep My Side Projects From Falling Over</title>
      <dc:creator>Saimul Islam</dc:creator>
      <pubDate>Sun, 06 Sep 2026 12:47:56 +0000</pubDate>
      <link>https://dev.to/saimulislamdl/10-essential-tools-i-actually-use-to-keep-my-side-projects-from-falling-over-1751</link>
      <guid>https://dev.to/saimulislamdl/10-essential-tools-i-actually-use-to-keep-my-side-projects-from-falling-over-1751</guid>
      <description>&lt;p&gt;&lt;em&gt;Docker management, monitoring that goes deeper than a green dot, backups I have actually restored, and everything else that showed up once deploying stopped being the hard part.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Moving off Vercel solved exactly one problem: deploying. Everything else I used to get for free, quietly, as part of the platform, I now had to go find and wire up myself. A month into running my own server, I had a list of ten tools taped to the inside of my head, each one solving a problem I did not know I had until it happened to me at a bad time.&lt;/p&gt;

&lt;p&gt;This is that list, in the order I actually needed them, with the mistake or the moment that made me install each one. I lean JS and Rust wherever I can, partly out of preference and partly because those are the tools that keep pace with how fast the rest of my stack moves. A couple of these are not JS or Rust at all, and I kept them anyway because they were simply the best tool for the job.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Dokploy, for everything I wrote about yesterday
&lt;/h2&gt;

&lt;p&gt;This is the one I already spent an entire post on, so I will keep it short here. Push to main, Dokploy builds the container, Traefik points a domain at it, done. Four apps running on one $24 droplet, and adding a fifth would not move the bill. If you deploy anything with Docker and are still doing it over SSH, start here. Everything else on this list assumes you already have a platform under you, not just a server.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Neon, for the database half of preview environments
&lt;/h2&gt;

&lt;p&gt;The first crack after Dokploy was previews. Dokploy gives every pull request its own preview URL, which is one of the nicest things about the whole setup, right up until every preview hits the same production database. I corrupted a batch of test data twice before I noticed what was happening. Neon branches Postgres the way git branches code, copy-on-write, so a preview PR gets its own preview database that costs almost nothing until it actually diverges from main. The storage engine underneath is written in Rust, and it quietly closed the other half of preview environments that nobody's setup guide mentions.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. OpenObserve, for monitoring that actually explains itself
&lt;/h2&gt;

&lt;p&gt;Dokploy's own dashboard will tell you a container is using too much memory. It will not tell you why, which request caused it, or whether it has happened before. I moved to OpenObserve for that: logs, metrics, and traces in one place, shipped as a single Rust binary, one docker-compose file to run it. The first afternoon I had it wired up, I found a query that had been running slow for three weeks, in the trace waterfall, in about four minutes. My old approach to that same investigation was grep and vibes.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. restic, for the backups Dokploy does not know about
&lt;/h2&gt;

&lt;p&gt;Dokploy's built-in backups cover Postgres, and I use them. They do not cover the config folder for my API, a SQLite file one of my smaller tools writes to, or a volume attached to a service that is not a database at all. restic backs up anything, encrypts it before it leaves the box, deduplicates so a nightly backup of a mostly-unchanged folder costs almost nothing in storage, and restores with one command I have run on purpose more than once, just to check.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;restic &lt;span class="nt"&gt;-r&lt;/span&gt; s3:https://&amp;lt;account&amp;gt;.r2.cloudflarestorage.com/backups backup /var/lib/myapp
restic &lt;span class="nt"&gt;-r&lt;/span&gt; s3:https://&amp;lt;account&amp;gt;.r2.cloudflarestorage.com/backups restore latest &lt;span class="nt"&gt;--target&lt;/span&gt; /tmp/restore-test
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It ships as a single static binary, which after living through more dependency hell than I would like to admit, I have come to treat as a feature in its own right.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Sentry, for the bugs logs make you scroll past
&lt;/h2&gt;

&lt;p&gt;Logs tell you something broke. They do not tell you it broke for the same handful of users, three times a day, on the same line of code, until you have already lost interest in scrolling through them. Sentry groups the exception, attaches the stack trace, and tells me exactly which release introduced it. The self-hosted stack is heavier than I would like, but the JS SDK alone, dropped into a Next.js app, has caught more real bugs before a user reported them than anything else on this list.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. OpenReplay, for the tickets that just say “it's broken”
&lt;/h2&gt;

&lt;p&gt;A user once emailed to say a form “just doesn't work,” with no other detail, which used to mean opening dev tools and hoping I could reproduce it. OpenReplay records the session and replays it back like a video, console errors and network requests included, exactly as the user hit them. It is open source and self-hosted, and it has turned half-day investigations into two-minute videos more times than I can count.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Appium, for testing on an actual device instead of a hunch
&lt;/h2&gt;

&lt;p&gt;Once a project had a real mobile wrapper around it, testing it by hand on my one physical phone stopped being a strategy. Appium drives real devices and simulators the same way Selenium drives a browser, through the WebDriver protocol, so the same testing habits carry over. I script the login flow and the checkout flow on both iOS and Android before every release, and it has caught two platform-specific bugs that a browser would never have shown me.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. k6, for finding the ceiling before traffic does
&lt;/h2&gt;

&lt;p&gt;A newsletter mention once doubled my traffic for six hours. The API held, barely, and I had no idea by how much margin. k6 lets me write load tests in JavaScript, the same language as the thing being tested, and run them against staging before a launch instead of finding the ceiling live.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;k6/http&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;vus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2m&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://staging.myapp.com/api/health&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ten minutes to write that tells you more than any amount of guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Infisical, for replacing a very bad system
&lt;/h2&gt;

&lt;p&gt;My “secrets management” before this was environment variables pasted into Dokploy's UI and, if I am honest, a couple of .env files sitting in a private Slack channel. Infisical is open source, written in TypeScript, and gives every project versioned secrets, per-environment values, and an audit log of who changed what. Rotating a leaked key used to mean a search-and-replace across three servers. Now it is one dashboard and a redeploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. n8n, for the glue that runs everything else
&lt;/h2&gt;

&lt;p&gt;n8n is the part that quietly holds the other nine together. When OpenObserve crosses an alert threshold, when a restic backup fails, when a new signup needs a welcome sequence, n8n is the thing that notices and does something about it, wired together as a workflow instead of a script I will forget I wrote. It runs on Node, it is free to self-host, and it is the tool I have extended the most since installing it, because every new problem seems to end with “I should probably wire that into n8n.”&lt;/p&gt;




&lt;h3&gt;
  
  
  So, is this too many tools for a side project?
&lt;/h3&gt;

&lt;p&gt;Maybe. Ten tools running on one box is still one box, and the more of these you add, the more you are choosing to own instead of rent. What changed for me is that each one solves a problem I actually had, in the order I actually had it, rather than a checklist I installed on day one and mostly ignored. If you are one platform migration into doing this yourself, start with whichever one of these already hurts.&lt;/p&gt;




&lt;h3&gt;
  
  
  Sources and further reading
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dokploy.com" rel="noopener noreferrer"&gt;Dokploy documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://neon.tech/docs/introduction/branching" rel="noopener noreferrer"&gt;Neon docs: database branching&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://openobserve.ai/docs/" rel="noopener noreferrer"&gt;OpenObserve documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://restic.net" rel="noopener noreferrer"&gt;restic documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://develop.sentry.dev/self-hosted/" rel="noopener noreferrer"&gt;Sentry self-hosted&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.openreplay.com" rel="noopener noreferrer"&gt;OpenReplay documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://appium.io/docs/en/latest/" rel="noopener noreferrer"&gt;Appium documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://k6.io/docs/" rel="noopener noreferrer"&gt;k6 documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://infisical.com/docs" rel="noopener noreferrer"&gt;Infisical documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.n8n.io" rel="noopener noreferrer"&gt;n8n documentation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
      <category>docker</category>
      <category>webdev</category>
      <category>rust</category>
    </item>
    <item>
      <title>Stop Paying for Vercel: How to Self-Host Your Deployments on a VPS and Keep the Same Workflow</title>
      <dc:creator>Saimul Islam</dc:creator>
      <pubDate>Fri, 04 Sep 2026 05:19:58 +0000</pubDate>
      <link>https://dev.to/saimulislamdl/stop-paying-for-vercel-how-to-self-host-your-deployments-on-a-vps-and-keep-the-same-workflow-4cce</link>
      <guid>https://dev.to/saimulislamdl/stop-paying-for-vercel-how-to-self-host-your-deployments-on-a-vps-and-keep-the-same-workflow-4cce</guid>
      <description>&lt;p&gt;Push-to-deploy, automatic HTTPS, and preview environments on a $24 droplet with Dokploy, plus the four mistakes to skip on the way.&lt;/p&gt;

&lt;p&gt;For two years, deploying was the easiest part of my week. I pushed to main, closed my laptop, and the site updated itself. I never thought about servers, which was the entire point of paying for it.&lt;/p&gt;

&lt;p&gt;Then I opened the invoice.&lt;/p&gt;

&lt;p&gt;$198 for the month. I read it twice, because the number did not match what I was running: a Next.js frontend with a few thousand monthly visitors, a small API moving JSON around, one Postgres database holding a couple hundred megabytes, and a nightly cron job. Four small things, and I knew that launching a fifth would push the bill up again.&lt;/p&gt;

&lt;p&gt;Here is what is worth understanding before you decide whether any of this applies to you. You are not paying managed platforms for compute. Compute is cheap. You are paying because somebody else already set up the reverse proxy, the TLS certificates that renew themselves, and the pipeline that turns a git push into a running container. That is real work, and it was worth every cent for two years. So the question got narrower: is it still worth it, now that I know which of those features I actually use?&lt;/p&gt;

&lt;p&gt;What follows is the whole setup, end to end: installing Dokploy, connecting your Git provider, getting deploys on every push, domains with automatic HTTPS, object storage for user files, scheduled backups, and monitoring. The workflow you land on is the one you already have. You push to main, you close your laptop, and the site updates itself.&lt;/p&gt;

&lt;p&gt;It took me a weekend, and I have left all four of my mistakes in, because those are the parts no documentation warns you about. If you can ship an application but have never really run a server, this is written for you.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why My First Attempt at This Failed
&lt;/h2&gt;

&lt;p&gt;My first instinct was to do it entirely by hand, and that failure is the reason Dokploy made sense to me later. I rented a VPS, installed Node, set up PM2 to keep the process alive, hand-wrote an Nginx config, and ran Certbot for a certificate. I felt clever for about four days.&lt;/p&gt;

&lt;p&gt;Then deploying turned out to mean SSH-ing in to run &lt;code&gt;git pull&lt;/code&gt;, which is fine right up until you are doing it from your phone because something has broken. No build logs, no deployment history, and a rollback plan that was really just hope. Adding a second app meant another Nginx block, another port, another process to babysit.&lt;/p&gt;

&lt;p&gt;The problem was never that servers are hard. It was that I had hand-built a worse deployment platform and was now responsible for maintaining it. Once I wrote down what I actually wanted, it became shoppable: something that handled the reverse proxy, the certificates, the builds, the restarts, and the rollbacks, on hardware I paid for directly. That is what Dokploy is. So I went and got a server to put it on.&lt;/p&gt;




&lt;h2&gt;
  
  
  Choosing the Server, and the Mistake I Made in the First Hour
&lt;/h2&gt;

&lt;p&gt;I went with DigitalOcean. Hetzner, Vultr, and OVH are all fine too, and cheaper if you are optimising purely on price, but I already had projects there and wanted one dashboard rather than four.&lt;/p&gt;

&lt;p&gt;Dokploy says the minimum is 2GB of RAM. That is technically true and practically a trap, because you build your application on the same box you deploy to.&lt;/p&gt;

&lt;p&gt;I found out the hard way. My first build died on the $12 droplet. Not failed, died. The log stopped part way through with no error, and I spent twenty minutes blaming my Dockerfile before finding the kernel had killed it for using too much memory. Skip the entry tiers and start on the 4GB droplet at $24, which fits the app, the database, and the build at once. Ubuntu 24.04, because boring is correct here.&lt;/p&gt;

&lt;p&gt;One more thing before you install anything, and I nearly skipped it. Ten minutes on the basics: SSH key instead of password login, password login disabled, firewall with ports 22, 80, 443, and 3000 open. When I checked my auth log a day later there were thousands of failed login attempts. Nobody knew my server existed. That is simply what the internet does to a public IP.&lt;/p&gt;

&lt;p&gt;With the box locked down, the install itself is almost anticlimactic.&lt;/p&gt;




&lt;h2&gt;
  
  
  Installing Dokploy Takes One Command
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sSL&lt;/span&gt; https://dokploy.com/install.sh | sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few minutes of output, and it is done. It is worth seeing what that script did for you, because you now own a real stack rather than a magic box. It installed Docker, set up Swarm mode so containers restart on their own when they die, deployed Traefik as your reverse proxy, and started a small database for Dokploy itself. Every one of those I configured by hand the first time, and got wrong at least once.&lt;/p&gt;

&lt;p&gt;Open your server IP on port 3000 and create an admin account. Then, before deploying anything, point a subdomain like &lt;code&gt;panel.yourdomain.com&lt;/code&gt; at the server, give the dashboard that domain, turn on HTTPS, and close port 3000. Your control panel should not sit on an open IP with only a password in front of it.&lt;/p&gt;

&lt;p&gt;You now have a platform. What it does not have yet is your code.&lt;/p&gt;




&lt;h2&gt;
  
  
  Connecting GitHub Is the Decision That Pays Off Later
&lt;/h2&gt;

&lt;p&gt;Dokploy pulls your code two ways, and the choice matters more than it looks at the time. The first is a generic Git connection over SSH, which works with GitLab, Bitbucket, Gitea, or anything else that speaks SSH. Generate a key in the SSH Keys section, add the public half to your provider, and use the &lt;code&gt;git@&lt;/code&gt; URL rather than &lt;code&gt;https&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The second, if you are on GitHub, is the Dokploy GitHub App. Two extra minutes, and it is the best decision in this story. That single step gave me automatic deploys on push, watch paths, and preview environments for pull requests, with no further configuration. Those are exactly the three things people assume they give up when they leave managed hosting, and this is where you keep them. Click the right button early, because switching later is far more annoying.&lt;/p&gt;

&lt;p&gt;Then create a project, pick your repository and branch, and paste your environment variables into a box that works like a &lt;code&gt;.env&lt;/code&gt; file. One detail that confused me: Dokploy clones the repository onto your own server and builds it there, which is why server memory mattered so much a moment ago.&lt;/p&gt;

&lt;p&gt;Then I hit deploy. And then I hit it three more times.&lt;/p&gt;




&lt;h2&gt;
  
  
  My First Three Deploys Failed, and That Was the Useful Part
&lt;/h2&gt;

&lt;p&gt;Every tutorial I read showed a green checkmark on the first attempt, so when mine went red I assumed something was structurally wrong. It was not. Three ordinary mistakes, each of which explained a piece of how the system fits together.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The Port
&lt;/h3&gt;

&lt;p&gt;The build worked, the container started, and the site returned a gateway error, because I had never told Dokploy which port my app listens on. That field lives in the domain settings rather than the build settings, which is why I missed it. The lesson is worth keeping: your app is never exposed to the internet directly. Traefik forwards public traffic inward, which is why you never open your app port in the firewall.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2l0e33lsxkpeh60swimy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2l0e33lsxkpeh60swimy.png" alt="The path a request takes, and why the container port lives in the domain settings" width="799" height="316"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The path a request takes, and why the container port lives in the domain settings.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  2. Missing Environment Variable
&lt;/h3&gt;

&lt;p&gt;The second was a missing environment variable. It built fine and crashed on startup, and the container log said so in its first line while I was staring at the build log. Those answer different questions: why it would not compile, versus why the compiled thing will not run.&lt;/p&gt;
&lt;h3&gt;
  
  
  3. The Build Type
&lt;/h3&gt;

&lt;p&gt;The third was the build type, which is how Dokploy turns your repository into a running app at all. There are five options, and the default guesses for you.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;If your project is&lt;/th&gt;
&lt;th&gt;Choose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;A standard Next.js, Django, or Laravel app&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Railpack, the modern default, or Nixpacks if it gives you trouble&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Already carrying a Dockerfile you trust&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Dockerfile, for full control including build secrets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;A static site from Vite, Astro, or Hugo&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Nixpacks with a Publish Directory, or Static if pre-built&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Being moved directly off Heroku&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Buildpack, using your existing Heroku or Paketo config&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I switched to a Dockerfile because my build needed a step the automatic builders were skipping. Fourth deploy, green checkmark. The site loaded, at an IP address, which did not feel like it counted yet.&lt;/p&gt;


&lt;h2&gt;
  
  
  Giving It a Real Domain and Free HTTPS
&lt;/h2&gt;

&lt;p&gt;This happens in two steps, and doing them in the wrong order caused my next mistake. At your registrar, create an A record pointing your domain at the server IP. Then in Dokploy, open your app, go to Domains, and add one. Set the Host, Path, and Container Port, flip the HTTPS toggle, choose Let’s Encrypt, and the certificate and its renewal are handled from then on.&lt;/p&gt;

&lt;p&gt;My mistake was enabling HTTPS fifteen seconds after creating the DNS record. Let’s Encrypt tried to verify I owned the domain, found nothing, and failed with an error that never mentioned timing. Run &lt;code&gt;dig +short yourdomain.com&lt;/code&gt; first, and only enable HTTPS once it prints your server IP back.&lt;/p&gt;

&lt;p&gt;One more trap catches a lot of people. If your DNS is on Cloudflare, the orange cloud means Cloudflare is proxying your traffic, and verification fails with an error that never mentions Cloudflare either. Set the record to DNS only until the certificate is issued, then switch proxying back on if you want it.&lt;/p&gt;

&lt;p&gt;While you are in the dashboard, add Postgres too. Resist the urge to expose its port, because open database ports are among the most scanned things on the internet. Your app reaches it by service name over the internal Docker network, as in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;postgresql://user:pass@my-app-db:5432/mydb
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And that was the setup finished. Custom domain, valid certificate, deploys on push, a database, all for $24 a month. It ran perfectly for two weeks, which is when the real education started.&lt;/p&gt;




&lt;h2&gt;
  
  
  Two Weeks In: The Monorepo Problem
&lt;/h2&gt;

&lt;p&gt;The first crack was small. My repo is a monorepo, and every push rebuilt everything in it. I would fix a typo in a markdown file and watch three services redeploy and send me notifications for things that had not changed.&lt;/p&gt;

&lt;p&gt;Watch paths fix this. In your app’s Git settings you list the files and folders that app actually cares about, and it only rebuilds when something inside them changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;apps/web/**
packages/ui/**
!apps/web/**/*.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two asterisks match across directories, and a leading exclamation mark excludes. One caveat before you go debugging: this works out of the box on GitHub, but on GitLab, Bitbucket, or Gitea you need automatic deployment working first, because watch paths filter automatic deploys. With nothing to filter, they look ignored.&lt;/p&gt;

&lt;p&gt;That one was an annoyance. The next one was not.&lt;/p&gt;




&lt;h2&gt;
  
  
  Three Weeks In: The Day All My User Uploads Disappeared
&lt;/h2&gt;

&lt;p&gt;I shipped a routine change, nothing dramatic, and somebody told me their profile picture was gone. Not just theirs. Every file any user had uploaded since launch.&lt;/p&gt;

&lt;p&gt;The cause is obvious in hindsight and catches almost everyone new to containers. I had been saving uploads to a folder inside the application, so when I deployed, Dokploy built a fresh container from my repository and replaced the old one. My repository does not contain user uploads. Containers are meant to be disposable, and that is exactly what makes deploys and rollbacks safe. My mistake was putting data that has to survive inside a thing designed not to.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz5orpw65d5n252vvinpd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz5orpw65d5n252vvinpd.png" alt="Why the uploads vanished, and where they should have been living instead" width="799" height="373"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Why the uploads vanished, and where they should have been living instead.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For user files the answer is object storage, and I picked Cloudflare R2 for one reason: it charges nothing for egress. Everyone else charges you to read your own files back out, and that cost scales with traffic rather than storage. R2 is $0.015 per GB per month with zero egress, against roughly $0.023 per GB plus $0.09 per GB egress on S3, and the free tier covers 10GB and 10 million reads a month.&lt;/p&gt;

&lt;p&gt;Best of all, R2 speaks the S3 API. Create a bucket, generate an API token with Object Read and Write, note your account ID, and point your existing S3 client at it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;S3Client&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@aws-sdk/client-s3&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s3&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;S3Client&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;region&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;auto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`https://&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ACCOUNT_ID&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.r2.cloudflarestorage.com`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;accessKeyId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secretAccessKey&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Setting that up turned out to be lucky, because a few days later I needed somewhere to put backups.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Night I Realised I Had No Backups at All
&lt;/h2&gt;

&lt;p&gt;I was running a migration late one night, and for about ninety seconds I believed I had destroyed my users table. I had not, in the end. But in those ninety seconds I went looking for my backups and found something worse than a bad migration. There were none. Three weeks of production data, not a single copy anywhere.&lt;/p&gt;

&lt;p&gt;The embarrassing part is that Dokploy had this built in the whole time, in a tab I had scrolled past a dozen times. On the schedule you set, it runs a proper database dump, the same &lt;code&gt;pg_dump&lt;/code&gt; you would run by hand, and ships it to S3-compatible storage. Which is why the R2 bucket from the last section quietly became my backup destination too, on the same free tier.&lt;/p&gt;

&lt;p&gt;Add the destination once under Settings, then Destinations, using your R2 access key, secret key, bucket, the region set to &lt;code&gt;auto&lt;/code&gt;, and the same endpoint as above. Hit the test button before you trust it, because a destination that fails silently is worse than none. Then open your database, go to Backups, and set a schedule. I use &lt;code&gt;0 3 * * *&lt;/code&gt; for daily at 3am.&lt;/p&gt;

&lt;p&gt;Now the part that matters more than everything above it. A backup you have never restored is not a backup, it is a file you feel good about. The week after setting this up I spun up a throwaway Postgres service and restored into it on purpose. Ten minutes, and it worked. Do that once while nothing is on fire. Then be as pleased with yourself as I was, right up until you try to back up the next thing.&lt;/p&gt;




&lt;h2&gt;
  
  
  What to Do When There Is No Database to Back Up
&lt;/h2&gt;

&lt;p&gt;My other service had no database. It kept its state in a folder, which is the shape of anything using SQLite or any self-hosted tool with a working directory, so the backup feature had nothing to point at.&lt;/p&gt;

&lt;p&gt;Dokploy handles this with volume backups, which meant finally understanding a distinction I had been ignoring. A bind mount points at a folder on the host machine. A named volume is storage Docker itself creates and manages, referred to by name. I assumed that was stylistic. It is not, because volume backups only work with named volumes, so a bind mount means migrating first.&lt;/p&gt;

&lt;p&gt;Creating one is easy. Go to Advanced, then Mounts, and add a Volume Mount with a name like &lt;code&gt;app_data&lt;/code&gt; and the path where it appears inside the container. One detail that will confuse you at restore time: for Compose services, Dokploy prefixes the volume name with the app name, so &lt;code&gt;app_data&lt;/code&gt; exists as &lt;code&gt;myapp-abc123_app_data&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;You also choose whether to stop the container while the backup runs. Stopping is safer, since nothing is writing while files are copied. I stop mine, because forty seconds of downtime at 3am costs nothing and a corrupted backup would cost a great deal.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2w0tup5h2xh1nnx0kch1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2w0tup5h2xh1nnx0kch1.png" alt="The finished storage setup: uploads written live, database and volumes backed up on a schedule, all into one bucket" width="799" height="318"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The finished storage setup: uploads written live, database and volumes backed up on a schedule, all into one bucket.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;With that, my data was safe. So naturally the server got slow.&lt;/p&gt;


&lt;h2&gt;
  
  
  A Month Later: Why Is Everything Suddenly Slow?
&lt;/h2&gt;

&lt;p&gt;Everything got sluggish at once, across all my apps, which is the least helpful symptom possible because it tells you the problem is shared and nothing more.&lt;/p&gt;

&lt;p&gt;Under my old setup I would have SSH-ed in, run &lt;code&gt;top&lt;/code&gt;, squinted at container-generated process names, and guessed. Instead I opened the dashboard, where every application has its own view of CPU, memory, disk, and network usage. I found the culprit in two minutes: a background job in my API had a runaway loop and was eating almost all the memory on the box. Live logs and deployment history did the rest. A surprising number of mysteries resolve the moment you notice the slowdown began at the exact minute of a deploy.&lt;/p&gt;

&lt;p&gt;I fixed the loop, obviously. More usefully, I set a memory limit on that app under Advanced, then Resources, so it can never take the whole server down again. If it exceeds the limit it restarts rather than consuming everything around it. I now set limits on everything at double normal usage, plus alert thresholds pointed at Discord so the server tells me before a user does.&lt;/p&gt;

&lt;p&gt;Worth being straight about the limits. Self-hosted gives you per-container metrics, logs, deployment history, resource limits, and threshold alerts. The advanced dashboard is part of Dokploy’s paid cloud tier. For a handful of apps the built-in view is plenty, and if you outgrow it, Dokploy ships a Prometheus extension as a template that gives you Grafana on the same box.&lt;/p&gt;

&lt;p&gt;That is the last of my four mistakes. One more warning, though, and it is the thing that quietly kills self-hosted setups around the three month mark.&lt;/p&gt;


&lt;h2&gt;
  
  
  The Thing That Will Break in Three Months
&lt;/h2&gt;

&lt;p&gt;Your disk will fill up. Every deploy builds a new Docker image, and old images and build caches do not delete themselves. You go from comfortable to out of space with no warning at all, and your deploys start failing with errors that never say "disk full".&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt;                    &lt;span class="c"&gt;# how much space is left&lt;/span&gt;
docker system prune &lt;span class="nt"&gt;-af&lt;/span&gt;  &lt;span class="c"&gt;# remove unused images and caches&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run that occasionally, and update Dokploy itself from the dashboard every month or so, since it is your software now.&lt;/p&gt;




&lt;h2&gt;
  
  
  So, Was It Worth It?
&lt;/h2&gt;

&lt;p&gt;$198 a month became $24, and R2 is still inside the free tier. It cost me a weekend including every mistake above, and about thirty minutes a month since. Nothing about my day-to-day changed: deploy on push, automatic HTTPS, preview environments, and one-click rollbacks all survived the move. That droplet now runs four applications instead of one, and adding a fifth would not change the bill.&lt;/p&gt;

&lt;p&gt;What I gave up is real, and I would rather say it plainly than sell you something. One server is one point of failure, and if the machine dies, everything on it is down until I bring it back. My answer is backups I have actually tested, and knowing a fresh server plus a restore is a couple of hours rather than a catastrophe.&lt;/p&gt;

&lt;p&gt;So do not do this if you are pre-launch and your only real job is shipping features, because every hour on infrastructure then is an hour not spent finding out whether anyone wants the thing. Do not do this if nobody can respond when the server has a problem, because a managed platform is partly an on-call rotation you are renting. And if your traffic is global and latency-sensitive, remember that one server is in one place.&lt;/p&gt;

&lt;p&gt;What I got beyond the money mattered more than I expected. Deployment used to be a black box I posted code into and hoped about. Now I know what a reverse proxy does, why containers are disposable, and where my data actually lives. The anxiety about next month’s bill is gone too, not because $24 is nothing, but because it does not move when I have a good week.&lt;/p&gt;

&lt;p&gt;If you want to try this, rent a box, spend ten minutes on SSH keys and a firewall, run one install command, and deploy something small you would not mind breaking. Give yourself an afternoon. Worst case, you destroy a server costing a few dollars and start again, which is a cheap way to learn how your own infrastructure actually works.&lt;/p&gt;




&lt;h2&gt;
  
  
  Sources and Further Reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.dokploy.com" rel="noopener noreferrer"&gt;Dokploy Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.dokploy.com/docs/core/applications/build-type" rel="noopener noreferrer"&gt;Dokploy Build Types&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.dokploy.com/docs/core/watch-paths" rel="noopener noreferrer"&gt;Dokploy Watch Paths&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.dokploy.com/docs/core/databases/backups" rel="noopener noreferrer"&gt;Dokploy Backups&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.cloudflare.com/r2/pricing/" rel="noopener noreferrer"&gt;Cloudflare R2 Pricing&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
      <category>nextjs</category>
      <category>docker</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
