Configuring a Postgres Service in a DevContainer Compose Stack
Your devcontainer app needs a real Postgres, not a mock — reachable by name, seeded once, and surviving rebuilds. This page adds a Postgres service to a Compose-backed devcontainer with a health check that gates startup, a named volume so data persists, and bridge networking so the app reaches it as db.
This matters because a mock or an in-memory substitute quietly diverges from the database your code will actually hit in staging and production. Column types, transaction isolation, LISTEN/NOTIFY, JSONB operators, and the exact SQL a migration emits all behave differently under a stub than under postgres:16-alpine. Running the genuine engine inside the same Compose stack that boots your workspace closes that gap: the connection your app opens against db:5432 during development is the same shape it opens everywhere else, so a query that works on your laptop is far more likely to work in CI and beyond. The four moving parts in this guide — the service definition, the pg_isready health check, the pgdata named volume, and the devnet bridge — each remove one class of "works on my machine" surprise.
Reach for this setup the moment a devcontainer stops being a single container and becomes a stack. The mental model is that Compose owns the lifecycle of every service and the network they share, while devcontainer.json merely points at one of those services as the workspace. Postgres is a sibling of the workspace, not a dependency baked into it, which is why it gets its own image, its own health signal, and its own volume rather than being installed into the app image. Keep that separation in mind and the rest of the configuration follows naturally: you are describing a peer service and then teaching the workspace to wait for it and to find it by name.
Prerequisites
You need a Compose-backed devcontainer and the credentials your app expects.
- A
devcontainer.jsonusingdockerComposeFileandservice. - A shared user-defined bridge network for the services.
- The database name, user, and password your app connects with.
The dockerComposeFile and service keys in devcontainer.json are the pivot point: the first tells the CLI which Compose file describes the stack, and the second names the service that becomes your workspace. Every other service in that file — Postgres included — comes up alongside the workspace automatically, so you do not add Postgres to devcontainer.json at all; you add it to the Compose file and let the stack carry it. Have your credentials settled before you start, because POSTGRES_USER, POSTGRES_PASSWORD, and POSTGRES_DB are only honored on first initialization of an empty data directory. If you change them later against an existing pgdata volume, Postgres ignores the new values and keeps the old ones, which produces confusing authentication failures.
The detail people get wrong is assuming Compose already puts services on a network that resolves by name. Compose does create a default network, but as soon as you start declaring your own networks: blocks — which you will, to keep the stack explicit and portable across docker compose invocations — every service must join the same user-defined bridge for DNS between them to work. Half-declaring it, where the app names devnet but the database does not, is the single most common reason db fails to resolve. viewBox="0 0 784 135" style="width:100%;height:auto;max-width:780px;margin:1.5rem 0;font-family:var(--font-family-sans,sans-serif)">
Step-by-Step Implementation
- Declare the Postgres service with credentials and a data volume.
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: devpass
POSTGRES_DB: appdb
volumes:
- pgdata:/var/lib/postgresql/data
networks:
- devnet
volumes:
pgdata:
networks:
devnet:
driver: bridge
This block declares three things at once, and the top-level volumes: and networks: stanzas are as important as the service itself. The image: postgres:16-alpine pin keeps the engine version identical for everyone; a floating postgres:latest would let a rebuild silently jump major versions and break migrations. The three POSTGRES_* environment variables seed the initial superuser, password, and database on first boot, matching whatever DSN your app uses. Mounting the named pgdata volume on /var/lib/postgresql/data — the exact path Postgres writes its data to — is what makes the data outlive the container, and attaching the service to devnet puts it on the same bridge the app will join. Because the volume and network are also declared at the top level, Compose owns their lifecycle rather than creating anonymous throwaways, which is what lets pgdata survive a docker compose down and come back with your schema intact.
- Add a health check so the workspace waits for a ready database.
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
interval: 5s
timeout: 3s
retries: 5
The pg_isready -U app -d appdb command is the right probe because it asks Postgres whether it is actually accepting connections for that user and database, not merely whether the process has started. During first boot the postgres container runs its initialization, briefly starts a private server to create the database, then restarts on the public port — a window in which a naive TCP check would report "up" while real connections are still refused. Wrapping the probe in CMD-SHELL runs it through a shell inside the container so the environment resolves correctly. The interval: 5s, timeout: 3s, and retries: 5 values give the database up to roughly 25 seconds of polled grace before Compose marks it unhealthy, which comfortably covers a cold initdb on a slow disk without hanging your workspace indefinitely if something is genuinely broken.
- Gate the workspace on database health in the app service.
depends_on:
db:
condition: service_healthy
This is the piece that turns the health check into ordering. A bare depends_on: [db] only waits for the db container to be created and started, which for Postgres means "the process exists," not "the database answers." The long form with condition: service_healthy tells Compose to hold the workspace until the db service reports healthy from the probe you defined in step 2, so migrations and app boot code that run on postCreateCommand or postStartCommand never fire against a database still running initdb. Note that this stanza belongs on the app service, not on db — you are declaring that the workspace depends on a healthy database, and the two halves only work together when the health check and the service_healthy condition are both present.
- Verify the app reaches Postgres by name.
devcontainer exec --workspace-folder . -- pg_isready -h db -U app
Running this from inside the workspace with devcontainer exec is the honest test, because it exercises exactly the path your app uses: the same container, the same devnet bridge, the same embedded DNS resolving db. The -h db flag is the important part — it forces name resolution rather than a loopback connection, so a passing result proves both that Postgres is up and that the network wiring is correct. If this command hangs or reports the host as unreachable while pg_isready succeeds from inside the db container itself, the fault is the network, not the database, and you should confirm both services list devnet. A clean "accepting connections" here means every prerequisite in the diagram above is satisfied.
Common Pitfalls
Database issues are usually a startup race, lost data, or a name that won't resolve.
The subtle one is volume ownership. The pgdata volume persists across rebuilds, which is exactly what you want for your schema and seed data — but it also persists across changes you might expect to reset it. Editing POSTGRES_USER or POSTGRES_PASSWORD after the volume already holds an initialized cluster has no effect, because those variables run only when /var/lib/postgresql/data is empty. The result is authentication failures that survive every rebuild until you explicitly remove the volume with docker compose down -v and let Postgres reinitialize. Treat a change to the credentials as a decision to discard the current data directory, and you will avoid chasing a password mismatch that the config file looks correct for.
The second recurring trap is confusing "container started" with "database ready," which the health check exists to solve but which reappears if any part of the chain is missing. If the app service omits depends_on with condition: service_healthy, or if the db service has no healthcheck for that condition to test, the gate silently degrades to a plain start-order dependency and the race returns. Both halves must be present, and the probe must target the real database and user so that "healthy" means "will accept your app's login," not just "the port is open." When a startup failure only happens on a cold machine or in CI, this incomplete gating is almost always the cause.
| Symptom | Root Cause | Remediation |
|---|---|---|
| App starts before DB is ready | No health-gated depends_on | Add healthcheck + service_healthy |
| Data lost on rebuild | No named volume for data dir | Mount pgdata on /var/lib/postgresql/data |
| App can't resolve db | Services on different networks | Put both on the devnet bridge |
| Password auth fails | Env vars mismatch app config | Align POSTGRES_* with the app's DSN |
Conclusion
A devcontainer Postgres needs three things beyond the image: a health check so the app waits for readiness, a named volume so data survives rebuilds, and a shared bridge so the app reaches it as db. Get those and you have a real database that behaves identically for every developer and in CI.
The strategic payoff is that the database stops being a variable. When the image is pinned to postgres:16-alpine, the credentials live in the Compose file, and the data sits on a declared volume, a teammate who clones the repo and reopens the folder gets the same engine, the same schema path, and the same startup ordering you do — no "install Postgres locally," no version drift, no undocumented seed step. That is the same pin-and-cache discipline that governs the rest of a good devcontainer: pin the versions that must not move, and let a named cache or volume carry the state that should persist. Here the pin is the image tag and the cache is pgdata, and together they make the database reproducible rather than incidental.
That reproducibility is what makes the setup worth the few lines of YAML. Because the health-gated depends_on guarantees ordering and the devnet bridge guarantees addressability, the stack behaves the same whether it boots on a fast laptop, a slow CI runner, or a colleague's machine three time zones away. Extending the pattern to a second service — a cache, a queue, a search node — is then a matter of repeating the same four moves rather than inventing new plumbing.
FAQ
Why does my app connect before the database is ready?
Because depends_on alone only waits for the container to start, not for Postgres to accept connections. Add a healthcheck using pg_isready and set the app's depends_on to condition: service_healthy, so the workspace starts only once the database is genuinely ready. The gap is widest on first boot, when Postgres runs initdb, briefly starts a private server to create your database, and then restarts on the public port — during that window the container is up but connections are refused. The pg_isready probe reports healthy only after that sequence finishes, which is precisely the moment your migrations and app code are safe to run.
How do I keep my seeded data across rebuilds?
Mount the Postgres data directory (/var/lib/postgresql/data) on a named volume. The volume outlives container rebuilds, so the schema and seed data persist. Without it, every rebuild reinitializes an empty database. Declare pgdata in the top-level volumes: block so Compose manages it as a first-class object rather than an anonymous mount, and remember the flip side: because the data survives, the POSTGRES_* variables are read only when the volume is empty, so credential changes require docker compose down -v to take effect.
Why can't the app resolve the hostname db?
The app and database must share a user-defined bridge network for Docker's embedded DNS to resolve db to the container. If they are on different networks or the default bridge, name resolution fails — put both services on the same devnet bridge. The service name in Compose is the hostname, so db: in the YAML is exactly what your DSN should point at. If you rename the service, update the connection string to match, and confirm both services list devnet under their own networks: key rather than relying on Compose's implicit default.
Should I publish the Postgres port to the host?
Only if you need to reach the database from a tool running outside the stack, such as a GUI client on your host machine. For app-to-database traffic inside the devcontainer you do not need ports: at all, because the devnet bridge already lets the app reach db:5432 directly. Publishing a port exposes Postgres on your host interface and can collide with a local Postgres already listening on 5432, so leave it off unless you have a concrete reason and, when you add it, map to a non-default host port like 5433:5432 to sidestep conflicts.
Can I run schema migrations automatically on startup?
Yes, and the health gate is what makes it reliable. Put your migration command in the workspace's postCreateCommand or postStartCommand; because the app service waits on condition: service_healthy, the migration runs only after Postgres accepts connections. Keep migrations idempotent so a rebuilt workspace against the persistent pgdata volume does not attempt to re-apply changes that already landed, and let the tool's version table decide what still needs to run.
Related
- Up to Docker Compose Integration for Multi-Service Apps — the parent guide on Compose stacks.
- Migrating from Docker Compose to DevContainers — moving an existing stack in.
- Resolving DNS Failures in Compose Networks — when the app can't reach db by name.