diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,17 @@
 
 ## [Unreleased]
 
+## 0.18.0.0 — 2026-09-20
+
+
+### Other Changes
+
+- Both test suites require `ephemeral-pg >=0.3.1 && <0.4` and start their
+  clusters under a stable per-user temporary root. The legacy suite uses
+  `withCachedConfig` in place of its hand-rolled `startCached`/`finally`
+  bracket. No migration payload changed, so no recorded checksum moves and no
+  ledger fixup is required.
+
 ## 0.17.0.0 — 2026-09-17
 
 ### Other Changes
diff --git a/keiro-migrations.cabal b/keiro-migrations.cabal
--- a/keiro-migrations.cabal
+++ b/keiro-migrations.cabal
@@ -1,6 +1,6 @@
 cabal-version: 3.0
 name: keiro-migrations
-version: 0.17.0.0
+version: 0.18.0.0
 synopsis: Schema migrations for keiro
 description:
   Embedded PostgreSQL schema migrations and a migration runner for the Keiro
@@ -410,7 +410,7 @@
     bytestring >=0.11 && <0.13,
     containers >=0.6 && <0.8,
     directory >=1.3 && <1.4,
-    ephemeral-pg >=0.2 && <0.3,
+    ephemeral-pg >=0.3.1 && <0.4,
     filepath >=1.4 && <1.6,
     hasql >=1.10 && <1.11,
     hspec >=2.10 && <2.12,
@@ -420,6 +420,7 @@
     pg-migrate-import-codd ^>=1.1.0.0,
     pg-migrate-test-support ^>=1.1.0.0,
     text >=2.0 && <2.2,
+    unix >=2.8 && <2.9,
 
 test-suite keiro-migrations-legacy-test
   import: common
@@ -443,7 +444,7 @@
     containers,
     contravariant-extras,
     directory,
-    ephemeral-pg >=0.2,
+    ephemeral-pg >=0.3.1 && <0.4,
     filepath,
     generic-lens >=2.2 && <2.4,
     hasql >=1.10 && <1.11,
@@ -456,4 +457,5 @@
     temporary,
     text >=2.0 && <2.2,
     time >=1.12 && <1.15,
+    unix >=2.8 && <2.9,
     vector >=0.13 && <0.14,
diff --git a/test-legacy/Main.hs b/test-legacy/Main.hs
--- a/test-legacy/Main.hs
+++ b/test-legacy/Main.hs
@@ -9,16 +9,17 @@
 import Codd.Types (ConnectionString, SchemaAlgo (..), SchemaSelection (..), SqlSchema (..), TxnIsolationLvl (..), singleTryPolicy)
 import Contravariant.Extras (contrazip3)
 import Control.Concurrent.Async (concurrently)
-import Control.Exception (finally)
 import Control.Monad (filterM)
 import Data.Attoparsec.Text (endOfInput, parseOnly)
 import Data.Int (Int32)
 import Data.List (isSuffixOf, sort)
+import Data.Monoid (Last (..))
 import Data.Text (Text)
 import Data.Text qualified as T
 import Data.Text.IO qualified as TIO
 import Data.Time (UTCTime (..), fromGregorian, secondsToDiffTime)
 import EphemeralPg qualified as Pg
+import EphemeralPg.Config qualified as Pg.Config
 import Hasql.Connection.Settings qualified as Conn
 import Hasql.Decoders qualified as D
 import Hasql.Encoders qualified as E
@@ -40,27 +41,36 @@
     verifySchema,
   )
 import Keiro.Migrations.New (migrationFileName, migrationSlug, newMigrationFile)
-import System.Directory (doesDirectoryExist, doesFileExist, listDirectory)
+import System.Directory (createDirectoryIfMissing, doesDirectoryExist, doesFileExist, listDirectory)
 import System.FilePath (takeFileName)
 import System.IO.Temp (withSystemTempDirectory)
+import System.Posix.User (getEffectiveUserID)
 import Test.Hspec
 
 -- | Pin the ephemeral PostgreSQL superuser to the fixed name @keiro@ so the
 -- captured snapshot identity (roles, database owner, per-object owners) is
 -- deterministic across machines and CI rather than the local OS username. This is
 -- the portability fix for the strict drift gate.
-keiroPgConfig :: Pg.Config
-keiroPgConfig = Pg.defaultConfig {Pg.user = "keiro"}
+keiroPgConfig :: IO Pg.Config
+keiroPgConfig = withStableRoot Pg.defaultConfig {Pg.Config.user = "keiro"}
 
+-- | Give @config@ a stable, per-user temporary root, so ephemeral-pg's startup
+-- sweep reclaims clusters abandoned by earlier killed runs instead of searching a
+-- per-session @$TMPDIR@. Must match the root in @Keiro.Test.Postgres@
+-- (keiro-test-support) so every Keiro suite shares it.
+withStableRoot :: Pg.Config -> IO Pg.Config
+withStableRoot config = do
+  uid <- getEffectiveUserID
+  let root = "/tmp/ephpg-keiro-" <> show uid
+  createDirectoryIfMissing True root
+  pure config {Pg.temporaryRoot = Last (Just root)}
+
 -- | Start a cached ephemeral server whose PostgreSQL superuser is the fixed
--- name @keiro@. Mirrors 'Pg.withCached' but pins the user; 'Pg.withCachedConfig'
--- is not exported, so we use 'Pg.startCached' + 'finally'.
+-- name @keiro@. Mirrors 'Pg.withCached' but pins the user.
 withKeiroPg :: (Pg.Database -> IO a) -> IO (Either Pg.StartError a)
 withKeiroPg action = do
-  started <- Pg.startCached keiroPgConfig Pg.defaultCacheConfig
-  case started of
-    Left err -> pure (Left err)
-    Right db -> Right <$> (action db `finally` Pg.stop db)
+  config <- keiroPgConfig
+  Pg.withCachedConfig config Pg.defaultCacheConfig action
 
 main :: IO ()
 main =
diff --git a/test/Main.hs b/test/Main.hs
--- a/test/Main.hs
+++ b/test/Main.hs
@@ -14,6 +14,7 @@
 import Data.List.NonEmpty (NonEmpty (..))
 import Data.List.NonEmpty qualified as NonEmpty
 import Data.Map.Strict qualified as Map
+import Data.Monoid (Last (..))
 import Data.Set qualified as Set
 import Data.Text (Text)
 import Data.Text qualified as Text
@@ -29,8 +30,9 @@
     planDescription,
   )
 import Database.PostgreSQL.Migrate.Internal qualified as Migrate.Internal
-import Database.PostgreSQL.Migrate.Test (withMigratedDatabase)
+import Database.PostgreSQL.Migrate.Test (MigratedDatabaseError, withMigratedDatabaseConfig)
 import EphemeralPg qualified as Pg
+import EphemeralPg.Config qualified as Pg.Config
 import Hasql.Connection qualified as Connection
 import Hasql.Connection.Settings qualified as Settings
 import Hasql.Decoders qualified as Decoders
@@ -46,9 +48,10 @@
 import Kiroku.Store.Migrations.History.Codd qualified as Kiroku.Codd
 import Lint
 import Numeric qualified
-import System.Directory (doesDirectoryExist, doesFileExist, listDirectory)
+import System.Directory (createDirectoryIfMissing, doesDirectoryExist, doesFileExist, listDirectory)
 import System.Environment (lookupEnv)
 import System.FilePath (takeExtension, (</>))
+import System.Posix.User (getEffectiveUserID)
 import Test.Hspec
 
 main :: IO ()
@@ -1347,12 +1350,32 @@
 importOutcomes :: HistoryImportReport -> [HistoryImportOutcome]
 importOutcomes HistoryImportReport {importResults} = importOutcome <$> toList importResults
 
-keiroPgConfig :: Pg.Config
-keiroPgConfig = Pg.defaultConfig {Pg.user = "keiro"}
+-- | Give @config@ a stable, per-user temporary root, so ephemeral-pg's startup
+-- sweep reclaims clusters abandoned by earlier killed runs instead of searching a
+-- per-session @$TMPDIR@. Must match the root in @Keiro.Test.Postgres@
+-- (keiro-test-support) so every Keiro suite shares it.
+withStableRoot :: Pg.Config -> IO Pg.Config
+withStableRoot config = do
+  uid <- getEffectiveUserID
+  let root = "/tmp/ephpg-keiro-" <> show uid
+  createDirectoryIfMissing True root
+  pure config {Pg.temporaryRoot = Last (Just root)}
 
+keiroPgConfig :: IO Pg.Config
+keiroPgConfig = withStableRoot Pg.defaultConfig {Pg.Config.user = "keiro"}
+
+withMigratedDatabase ::
+  MigrationPlan ->
+  (Connection.Connection -> IO value) ->
+  IO (Either MigratedDatabaseError value)
+withMigratedDatabase plan callback = do
+  config <- withStableRoot Pg.defaultConfig
+  withMigratedDatabaseConfig config defaultRunOptions plan callback
+
 withKeiroPg :: (Pg.Database -> IO ()) -> IO ()
 withKeiroPg action = do
-  started <- Pg.startCached keiroPgConfig Pg.defaultCacheConfig
+  config <- keiroPgConfig
+  started <- Pg.startCached config Pg.defaultCacheConfig
   case started of
     Left startError -> expectationFailure (show startError)
     Right database -> action database `finally` Pg.stop database
