<?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: Chloe</title>
    <description>The latest articles on DEV Community by Chloe (@chloe_smith0104).</description>
    <link>https://dev.to/chloe_smith0104</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%2F4131404%2F4c43c853-f6e5-49c4-ade9-b037aedb2cfb.jpg</url>
      <title>DEV Community: Chloe</title>
      <link>https://dev.to/chloe_smith0104</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/chloe_smith0104"/>
    <language>en</language>
    <item>
      <title>Postgres for local dev: one container, one Makefile</title>
      <dc:creator>Chloe</dc:creator>
      <pubDate>Mon, 21 Sep 2026 05:45:00 +0000</pubDate>
      <link>https://dev.to/chloe_smith0104/postgres-for-local-dev-one-container-one-makefile-58l4</link>
      <guid>https://dev.to/chloe_smith0104/postgres-for-local-dev-one-container-one-makefile-58l4</guid>
      <description>&lt;p&gt;Most local database setups fail in one of two ways. Either everyone shares a staging database and steps on each other's data, or the repo ships a 200-line compose file with six services that nobody fully understands. You need neither. One Postgres container, plain SQL migrations, and a reset command that runs in seconds will carry you a long way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The container
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.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:17&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5432:5432"&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app_dev&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/data&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="s"&gt;postgres&lt;/span&gt;
      &lt;span class="s"&gt;-c fsync=off&lt;/span&gt;
      &lt;span class="s"&gt;-c synchronous_commit=off&lt;/span&gt;
      &lt;span class="s"&gt;-c full_page_writes=off&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pg_isready&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;app&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;app_dev"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;15&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;pgdata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pin the major version to whatever you run in production. &lt;code&gt;postgres:latest&lt;/code&gt; is how you discover a version mismatch at deploy time. (If you pin 18 or later, the image expects the volume mounted at &lt;code&gt;/var/lib/postgresql&lt;/code&gt; instead.)&lt;/p&gt;

&lt;p&gt;The three &lt;code&gt;-c&lt;/code&gt; flags trade durability for speed. Your dev data is disposable, so take the trade, and never copy those lines into a production config.&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;# .env&lt;/span&gt;
&lt;span class="nv"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;postgres://app:app@localhost:5432/app_dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one variable is the only thing your app knows about the database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrations are SQL files
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;migrations/
  001_users.sql
  002_sessions.sql
  003_add_user_timezone.sql
seed.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They're numbered, append-only, and written in plain SQL. No ORM-generated diffs, no DSL to learn. When you need to know what the schema looks like, you read the files.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reset command
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight make"&gt;&lt;code&gt;&lt;span class="k"&gt;include&lt;/span&gt;&lt;span class="sx"&gt; .env&lt;/span&gt;
&lt;span class="err"&gt;export&lt;/span&gt;

&lt;span class="nv"&gt;ADMIN_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; postgres://app:app@localhost:5432/postgres

&lt;span class="nl"&gt;db-up&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--wait&lt;/span&gt; db

&lt;span class="nl"&gt;db-reset&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;db-up&lt;/span&gt;
    psql &lt;span class="p"&gt;$(&lt;/span&gt;ADMIN_URL&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"DROP DATABASE IF EXISTS app_dev WITH (FORCE)"&lt;/span&gt;
    psql &lt;span class="p"&gt;$(&lt;/span&gt;ADMIN_URL&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"CREATE DATABASE app_dev"&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;f &lt;span class="k"&gt;in &lt;/span&gt;migrations/&lt;span class="k"&gt;*&lt;/span&gt;.sql&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
      psql &lt;span class="p"&gt;$(&lt;/span&gt;DATABASE_URL&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nv"&gt;ON_ERROR_STOP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nv"&gt;$$&lt;/span&gt;f &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="k"&gt;done&lt;/span&gt;
    psql &lt;span class="p"&gt;$(&lt;/span&gt;DATABASE_URL&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nv"&gt;ON_ERROR_STOP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; seed.sql

&lt;span class="nl"&gt;db-shell&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    psql &lt;span class="p"&gt;$(&lt;/span&gt;DATABASE_URL&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;make db-reset&lt;/code&gt; is the primitive everything else rests on. If it's fast, nobody hoards a precious local database they're afraid to touch. After a bad migration, a branch switch, or a weird state, you reset and move on.&lt;/p&gt;

&lt;p&gt;It also means every migration gets replayed from zero on every reset, so your migration history is continuously tested instead of only tested in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tests: template databases
&lt;/h2&gt;

&lt;p&gt;Don't run tests against &lt;code&gt;app_dev&lt;/code&gt;, and don't truncate tables between tests. Postgres can clone a database at the file level:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- once per test run, after migrating app_template&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="n"&gt;test_a1b2c3&lt;/span&gt; &lt;span class="k"&gt;TEMPLATE&lt;/span&gt; &lt;span class="n"&gt;app_template&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- when the test finishes&lt;/span&gt;
&lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="n"&gt;test_a1b2c3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cloning a small schema takes tens of milliseconds. Each test, or each test worker, gets a real, isolated, fully migrated database, which means no mocks and no shared state. It also removes "passes alone, fails in the suite." The one catch is that nothing can be connected to the template while it's being cloned, so migrate it and then leave it alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this breaks
&lt;/h2&gt;

&lt;p&gt;This setup is right for projects that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use stock Postgres or extensions with a published image&lt;/li&gt;
&lt;li&gt;Have a seed dataset small enough to rebuild in seconds&lt;/li&gt;
&lt;li&gt;Run on a single database&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It gets less comfortable when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You need extensions. Swap the image (&lt;code&gt;pgvector/pgvector&lt;/code&gt;, &lt;code&gt;postgis/postgis&lt;/code&gt;) instead of building your own until you truly have to.&lt;/li&gt;
&lt;li&gt;Performance bugs hide behind tiny data. A query that's instant on 50 seed rows can be a sequential scan on 5 million. Keep a script that generates volume with &lt;code&gt;generate_series&lt;/code&gt;, and check &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; against it before shipping anything query-heavy.&lt;/li&gt;
&lt;li&gt;Your production is managed Postgres. Hosted providers restrict superuser and some extensions. If your migrations assume superuser locally, they'll fail on deploy, so run them as a non-superuser role in dev too.&lt;/li&gt;
&lt;li&gt;You run several projects. They'll all want port 5432, so give each project its own host port in compose and its own &lt;code&gt;DATABASE_URL&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of these changes a line or two, and the shape of the setup stays the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;Local database setups fail when they're slow or fragile, because people stop resetting them, and then every machine drifts into its own private schema. A fast reset and a versioned container prevent that, and template databases keep tests isolated.&lt;/p&gt;

&lt;p&gt;For a batteries-included setup with Postgres, auth, and realtime prebundled, tinbase.dev is worth a look. And if the thing you're building on top of that database is a mobile app, &lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=postgres-local-dev&amp;amp;utm_content=wrapup-cta" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; generates a full-stack React Native app from a prompt. Otherwise, the compose file and Makefile above will have you running in ten minutes.&lt;/p&gt;

&lt;p&gt;Make the reset cheap. Everything else follows.&lt;/p&gt;

</description>
      <category>database</category>
      <category>devops</category>
      <category>docker</category>
      <category>postgres</category>
    </item>
  </channel>
</rss>
