One docker compose up, One Seeded Postgres
By Mikhail Shytsko, Founder at Seedfast ·
Docker Compose made your dev database reproducible, one command bringing up the same Postgres for the whole team, down to the version and extensions, and the schema it hands you arrives perfect and empty. That emptiness is the part nobody standardized, so docker compose up gets you a database while filling it remains a separate step every project reinvents.
A docker compose seed database workflow comes down to two decisions, where the seed command runs and how it knows Postgres is ready for it. Get both right and the empty-database step folds into docker compose up. The rest of the page applies database seeding to this one stack, using the compose file I run most days.
TL;DR: Seedfast seeds a Postgres running under Docker Compose in one command. Bring the stack up with docker compose up -d --wait so the pg_isready healthcheck gates readiness, then run seedfast seed from the host against the published port. Keep schema bootstrap in /docker-entrypoint-initdb.d, and let the healthcheck-gated seed step handle the test data you actually iterate on.
Three spots can hold the seed, and they aren't interchangeable. The first is /docker-entrypoint-initdb.d, the postgres image's init directory, where scripts run exactly once, on the first boot of an empty data directory. Inside the compose file itself sits the second, a one-shot service that waits for Postgres to report healthy, runs the seed, and exits — and the third stays outside compose altogether, a seed run from your host shell once the healthcheck clears.
Which one fits turns on how often the data changes. Schema bootstrap and a CREATE EXTENSION line belong at first boot, where they run once and stay put, while test data you regenerate as the schema moves wants a health-gated placement, one that re-runs on demand instead of hiding behind a volume populated weeks ago.
Here is the compose file I start from. It defines one postgres service on the official postgres:16 image, publishes the port to the host, and keeps its data in a named volume so a restart doesn't wipe your work:
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 5s
timeout: 3s
retries: 5
start_period: 5s
volumes:
pgdata:
Of everything in that file, the healthcheck carries the most weight. Docker reports a container "running" the instant its process starts, but PostgreSQL needs a moment to finish recovery and open its socket, and a seed that fires inside that window dies on "connection refused" — the gap pg_isready closes, since it exits 0 only once the server is accepting connections. Binding the container's health to that check makes "healthy" and "ready to seed" the same event, and the interval, timeout, retries, and start_period fields tune how long Docker probes before it declares failure.
The init directory is where most people meet this pattern, and where they hit its sharp edge. On first boot, and only while the data directory is still empty, the postgres image runs everything in /docker-entrypoint-initdb.d in alphabetical order (every *.sql, *.sql.gz, and *.sh file). Mount 01-schema.sql and 02-seed.sql there and a fresh volume picks them up.
The trap is the word "fresh." Because the named volume persists across docker compose down and every ordinary docker compose up, the init scripts never fire again. Edit 02-seed.sql, run docker compose up again, and nothing changes, because the data directory isn't empty anymore and the image skips the whole directory. Only docker compose down -v drops the volume and lets the next up start clean, which makes the init directory a good home for schema bootstrap and a CREATE EXTENSION pgcrypto line, and a poor one for the test rows you tweak between runs. For the mechanics of the seed.sql those scripts contain, writing a Postgres seed script covers the file; the point here is only where it belongs in a compose stack.
By default I reach for this placement — bring the stack up and wait for health:
docker compose up -d --wait
--wait holds until every healthcheck reports healthy, then hands the shell back — no sleep 10 guesswork, no polling loop. Once it returns, Postgres is ready, and the seed runs against the published port:
SEEDFAST_DSN=postgresql://app:app@localhost:5432/app \
npx seedfast seed --scope "customers with a few orders and line items each" --output plain
That connection string is the same localhost:5432 you would hand to psql, reaching the container through the published port. npx pulls the CLI from npm; SEEDFAST_DSN tells the seed where to write and takes priority over DATABASE_URL, so it won't clobber another tool's setting; SEEDFAST_API_KEY, exported in your shell beforehand, authenticates the run; and --scope feeds Seedfast a plain-English description and skips the interactive prompt that would otherwise stall a script. Under the hood it reads the live schema and writes rows in dependency order, so every foreign key resolves to a parent that already exists. The flags match the CLI quick start; nothing changes because Postgres lives in a container.
If you would rather keep everything in one file, lift that command into a one-shot service that waits on Postgres:
seed:
image: node:22
depends_on:
postgres:
condition: service_healthy
environment:
SEEDFAST_API_KEY: ${SEEDFAST_API_KEY}
SEEDFAST_DSN: postgresql://app:app@postgres:5432/app
command: npx -y seedfast seed --scope "customers with a few orders each" --output plain
condition: service_healthy is the piece that matters, holding the seed container until the healthcheck passes so the seed never races a database that hasn't opened its socket. Inside the compose network the host becomes postgres, the service name, since localhost points the container at itself.
The loop stays short when the schema moves. After a migration adds a column, drop the volume and bring the stack back up:
docker compose down -v && docker compose up -d --wait
Then rerun the same seedfast seed command, and the new column simply appears in the next batch of generated rows, because the schema read happens fresh on every run rather than being cached from the first one.
None of this is only a local trick; the CI/CD database seeding guide runs the same gated-seed shape against a GitHub Actions service container, on the identical healthcheck.
Scripts in /docker-entrypoint-initdb.d run only on the first boot of an empty data directory, so a persisted named volume is almost always the reason a second run does nothing. The image skips the entire init directory whenever its data directory already holds a database, and no edit to a seed script there takes effect until the volume is gone — run docker compose down -v to drop it, bring the stack back up, and the scripts execute against a clean directory.
Seedfast treats a Compose-managed Postgres like any other database, reaching it over the port the compose file publishes. Point SEEDFAST_DSN at postgresql://user:pass@localhost:5432/db once docker compose up -d --wait returns, and run seedfast seed. Migrations don't break it, since the schema is read live on every run, and when you would sooner keep everything inside the file, the same command drops into a one-shot Compose service.
Docker Compose waits for Postgres when the service carries a pg_isready healthcheck and you gate the seed on it. Either bring the stack up with docker compose up -d --wait, which returns only after every healthcheck passes, or give the seed service a depends_on block with condition: service_healthy so Compose holds it until the database is accepting connections.
Test data you change with any regularity belongs in a seed step after startup, well away from the image build and the first-boot init script. A baked-in dataset goes stale the moment a migration lands and forces an image rebuild to refresh, whereas a post-startup seed reads the current schema and regenerates rows on demand, which is why the image and init directory should keep only the parts that rarely move, the schema and required extensions. A health-gated seed handles the data your tests assert against.
The compose file above hands every machine on your team the same clean Postgres, and Seedfast fills it with referentially valid rows generated from the live schema, no production data anywhere in the run. Start the 30-day free trial without a card, and the whole loop, up --wait included, fits inside a few minutes.