Docker Compose for Node.js + PostgreSQL
A Node.js app and its PostgreSQL database in the same Docker Compose, using depends_on and condition: service_healthy to avoid ECONNREFUSED on startup, migrations and development vs production.
When your app and its database live in the same compose.yaml, a problem appears that did not exist when Postgres was separate: start order. This guide sets up a Node.js app next to PostgreSQL and focuses on getting them to start in the right order, without the classic ECONNREFUSED.
compose.yaml for app + database
The app is built from your Dockerfile (build: .) and depends on Postgres using the long form of depends_on.
services:
app:
build: .
restart: unless-stopped
environment:
DATABASE_URL: ${DATABASE_URL}
ports:
- "127.0.0.1:3000:3000"
depends_on:
postgres:
condition: service_healthy
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
volumes:
postgres_data:The .env.example:
DATABASE_URL=postgresql://app:CHANGE_ME@postgres:5432/app POSTGRES_DB=app POSTGRES_USER=app POSTGRES_PASSWORD=CHANGE_ME
Why your app failed with ECONNREFUSED
It is the error almost everyone hits the first time. The app starts in milliseconds; Postgres takes a few seconds to be ready to accept connections. If your app tries to connect immediately, it gets ECONNREFUSED and often crashes.
The cause is a common misunderstanding: depends_on in its short form only orders startup, it does not wait for the service to be ready. The long form with condition: service_healthy does wait, but it needs Postgres to have a healthcheck (here, pg_isready). With that combination, Compose does not start app until the database responds.
Even so, the app should retry the connection a few times at startup: it is a cheap safety net against transient outages and against environments where you do not control the healthcheck.
The host name is the service name
Notice the URL: postgresql://app:CHANGE_ME@postgres:5432/app. The host is postgres, the service name, not localhost. Inside the app container, localhost is the app itself. Services in the same compose find each other by name on the internal network, so you do not need to publish the Postgres port for the app to use it.
Migrations
depends_on guarantees Postgres accepts connections, not that your schema exists. Treat migrations as an explicit step: run them at app startup (with retries) or as a separate command before serving traffic, for example docker compose run --rm app npm run migrate. That separates "the database is alive" from "the database has my schema".
Development vs production
For development with hot reload you can mount the code as a volume, but avoid mounting the host's node_modulesover the image's. In production you would separate the migration step, not publish the Postgres port, treat DATABASE_URL as a secret and set resource limits. This compose is the starting point to build on, not the final deployment.
Next steps
Generate this stack in the Docker Compose generator and, to fully understand waiting between services, read the healthchecks and depends_on guide.
Frequently asked questions
- My app fails with ECONNREFUSED on startup, why?
- Because your app tried to connect to Postgres before Postgres was accepting connections. The short form of depends_on (a list) only waits for the database container to start, not for it to be ready. The fix is the long form with condition: service_healthy plus a healthcheck on Postgres: Compose then does not start your app until pg_isready gives the all-clear.
- Why does DATABASE_URL point to 'postgres' and not 'localhost'?
- Inside Compose each service resolves the others by name on the internal network. Your app and Postgres do not share localhost: localhost inside the app container is the app itself. That is why the URL is postgresql://app:...@postgres:5432/app, where postgres is the service name. localhost would only work from your machine, through the published port.
- Even with a healthcheck, the first query sometimes fails. Is that normal?
- It can happen: service_healthy guarantees Postgres accepts connections, but your schema or migrations may not have run yet. depends_on solves start order, not the state of your schema. The robust approach is for the app to retry the connection a few times at startup and to run migrations as an explicit step before serving traffic.
- Do I run npm install inside or outside the container?
- For a reproducible image, npm install (or npm ci) goes in the Dockerfile, during the build, copying package.json and package-lock.json first to use layer caching. In development with hot reload many people mount the code as a volume, but then you should NOT mount the host's node_modules over the container's (their binaries may differ): node_modules is usually excluded from the mount.
- Is this good for production?
- It is a development skeleton. In production you would separate the migration step, not publish the Postgres port, manage DATABASE_URL as a secret outside git, set resource limits and almost always use a managed database or an orchestrator. The goal here is for app and database to start together and in the right order.