DEV Community

Cover image for Fix ECONNREFUSED 127.0.0.1:5432 in Docker Compose (Node)
Mahdi BEN RHOUMA
Mahdi BEN RHOUMA

Posted on Originally published at iloveblogs.blog

Fix ECONNREFUSED 127.0.0.1:5432 in Docker Compose (Node)

TL;DR

connect ECONNREFUSED 127.0.0.1:5432 from a Node app running in Docker Compose means the app is dialling its own container. 127.0.0.1 and localhost inside a container are that container's loopback interface, and Postgres is not in it. Replace the host in DATABASE_URL with the Compose service name of the database (for example db), keep the container port 5432, and make the app wait for a healthy database with a pg_isready healthcheck and depends_on: condition: service_healthy.

The error

The case below is the one reported on Stack Overflow by a developer running a Sequelize app and Postgres with docker-compose up. The database log says it is ready, and the Node server still cannot connect. The report prints the address as 127.0.01:5432, a typo for the loopback address; in its usual form the pair of log lines reads:

database system is ready to accept connections
Error: connect ECONNREFUSED 127.0.0.1:5432
Enter fullscreen mode Exit fullscreen mode

The Compose file behind it passed this connection string to the web service:

DATABASE_URL: postgres://username:pgpassword@127.0.0.1:5432/mydatabase
Enter fullscreen mode Exit fullscreen mode

The same app worked when Node and Postgres both ran directly on the laptop. That detail is the whole diagnosis.

Why it happens

ECONNREFUSED is not a Postgres error. It is the operating system answering a TCP connection attempt with a reset, because no process is listening on that address and port. Postgres never saw the request, so the password, the database name and the driver are all irrelevant until the address is right.

When Node and Postgres run on the same machine, 127.0.0.1 reaches Postgres because both processes share one network stack. Docker gives each container its own network namespace, with its own loopback interface. In the web container, 127.0.0.1:5432 is port 5432 of the web container, where nothing listens, hence the refusal. The accepted answer on the question puts it plainly: the loopback address means "connect to myself".

Compose solves the addressing problem for you. According to the Compose networking documentation, "each service registers its name with an internal DNS server, so containers can reach each other using the service name directly". The same page settles the port question: "networked service-to-service communication uses the CONTAINER_PORT", while the host port of a mapping such as 8001:5432 only exists for clients outside the network. It also notes that links, which the original file relies on, "are not required for basic service-to-service communication".

A second, independent cause produces the same message: Compose starts the app before Postgres is ready. The startup-order guide is explicit that "Compose does not wait until a container is 'ready', only until it's running". A fresh Postgres container spends several seconds initialising its data directory, and a Node process that connects once at boot loses that race.

Read the address in the error first

The address printed after ECONNREFUSED tells you which of these cases you are in, before you change anything:

Address in the error Meaning Fix
127.0.0.1:5432 or localhost from inside a container The app dials its own container Use the service name (step 1)
::1:5432 localhost resolved to the IPv6 loopback Same as above in a container; on the host, see the edge cases
A private IP such as 172.18.0.2:5432 DNS worked; Postgres is not accepting yet, or listens elsewhere Healthcheck and retries (steps 1 and 3)
The right host, but a port such as 15432 The host side of a ports mapping was used between containers Use the container port

Check your own DATABASE_URL

Paste the connection string your app actually receives to see how it parses: the host, the port and the database it names, and whether that host only works on the machine running the code. It runs in your browser and nothing is sent anywhere; the password is masked in every result.

Fix

  1. Point the connection string at the database service. With the modern services: format, no links and no ports mapping are needed for the app to reach Postgres. Keep the ports line only if a tool on your host must connect too.
   services:
     web:
       image: node:22
       working_dir: /src
       command: npm start
       ports:
         - "8000:4242"
       environment:
         PORT: 4242
         DATABASE_URL: postgres://username:pgpassword@db:5432/mydatabase
       volumes:
         - ./:/src
       depends_on:
         db:
           condition: service_healthy
           restart: true
     db:
       image: postgres:17
       environment:
         POSTGRES_USER: username
         POSTGRES_PASSWORD: pgpassword
         POSTGRES_DB: mydatabase
       healthcheck:
         test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
         interval: 10s
         timeout: 10s
         retries: 5
         start_period: 30s
Enter fullscreen mode Exit fullscreen mode

The healthcheck is the one from the Compose startup-order guide. The doubled $$ stops Compose from interpolating the variables itself, so the shell inside the db container expands them. With restart: true, the guide adds, the web service is restarted when db is restarted by an explicit Compose operation such as docker compose restart.

  1. Make sure the code actually uses DATABASE_URL. Setting the variable in Compose changes nothing if the app ignores it. The node-postgres connection docs list the variables pg reads on its own: PGHOST, PGPORT, PGUSER, PGPASSWORD and PGDATABASE, with PGHOST falling back to localhost. DATABASE_URL is not among them, so a bare new Pool() inside a container dials localhost and fails exactly like the original question. Pass the string explicitly:
   // db.js — node-postgres
   const { Pool } = require('pg');

   const pool = new Pool({
     connectionString: process.env.DATABASE_URL,
   });

   module.exports = pool;
Enter fullscreen mode Exit fullscreen mode

With Sequelize, as in the original question, the getting-started guide accepts the URI as the first constructor argument:

   // sequelize.js
   const { Sequelize } = require('sequelize');

   const sequelize = new Sequelize(process.env.DATABASE_URL, {
     dialect: 'postgres',
     logging: false,
   });

   module.exports = sequelize;
Enter fullscreen mode Exit fullscreen mode

One answer on the thread fixed the same error by changing a hard-coded host: 'localhost' in a Pool config to the container name postgresdb. The principle is identical: whatever form the config takes, the host must be the database service.

  1. Retry the first connection instead of crashing. The healthcheck only helps where Compose honours depends_on. A short retry loop at boot covers platforms that ignore it, and slow first starts. It does nothing for a database restart while the app is already running: that case is handled by restart: true from step 1, or by retrying individual queries. The pool also needs an error listener, because the node-postgres Pool docs warn that idle clients can emit errors when their backend goes away, and an unhandled pool error event can crash the Node process:
   // wait-for-db.js
   const { Pool } = require('pg');

   const pool = new Pool({ connectionString: process.env.DATABASE_URL });

   pool.on('error', (err) => {
     console.error('Idle pg client error:', err.message);
   });

   async function waitForDb(attempts = 10, delayMs = 2000) {
     for (let i = 1; i <= attempts; i += 1) {
       try {
         await pool.query('SELECT 1');
         console.log('Postgres is reachable');
         return pool;
       } catch (err) {
         console.warn(`Postgres not ready (attempt ${i}/${attempts}): ${err.code || err.message}`);
         if (i === attempts) throw err;
         await new Promise((resolve) => setTimeout(resolve, delayMs));
       }
     }
   }

   waitForDb().catch((err) => {
     console.error('Giving up on Postgres:', err);
     process.exit(1);
   });
Enter fullscreen mode Exit fullscreen mode
  1. Recreate the stack so the new environment is applied. A running container keeps the environment it was created with:
   docker compose down
   docker compose up --build
Enter fullscreen mode Exit fullscreen mode

Verify the fix

Check each hop separately, from the inside out. First, that Postgres accepts connections in its own container. pg_isready exits with 0 when the server accepts connections, 1 when it is rejecting them (for example during startup) and 2 when there is no response:

docker compose exec db pg_isready -U username -d mydatabase
docker compose ps
Enter fullscreen mode Exit fullscreen mode

docker compose ps should list db as healthy. Next, confirm that the service name resolves from the app container. This uses Node itself, so it works on slim and Alpine images that lack getent or ping:

docker compose exec web node -e "require('dns').lookup('db', (err, address) => console.log(err || address))"
Enter fullscreen mode Exit fullscreen mode

It should print a private address such as 172.18.0.2. Finally, run a real query through the driver with the same connection string the app uses:

docker compose exec web node -e "const { Pool } = require('pg'); new Pool({ connectionString: process.env.DATABASE_URL }).query('SELECT version()').then((r) => { console.log(r.rows[0].version); process.exit(0); }).catch((e) => { console.error(e.message); process.exit(1); })"
Enter fullscreen mode Exit fullscreen mode

If the error has changed to password authentication failed, the network is fixed and you are on to credentials: see the password authentication failed guide. Note that POSTGRES_USER and POSTGRES_PASSWORD only take effect when the data directory is empty; an old volume keeps the old role.

Edge cases the answers skip

Node on the host, Postgres in a container. Here 127.0.0.1:5432 is correct, provided the db service publishes the port with ports: - "5432:5432". If the error shows ::1:5432 instead, localhost resolved to the IPv6 loopback first. Since Node 17, dns.lookup returns addresses in the resolver's order (verbatim) rather than putting IPv4 first. Use 127.0.0.1 explicitly, or start Node with --dns-result-order=ipv4first.

Postgres on the host, Node in a container. The second answer on the thread, on Docker Desktop for Mac, reaches a Compose-run Postgres through its published host port via host.docker.internal. That works, but the service name is the direct route between two Compose services. The same name is the right tool when Postgres really runs on the host: Docker Desktop resolves it to the host automatically. On a Linux engine it does not exist unless you map it through the special host-gateway value, which in Compose is extra_hosts: - "host.docker.internal:host-gateway". The host's Postgres must also listen on an interface other than loopback and allow that client in pg_hba.conf.

A custom postgresql.conf. If you mount your own configuration file into the official image, its Docker Hub page warns that you must set listen_addresses = '*' so that other containers can reach the server.

Prisma and other ORMs. The mechanism is the same whatever the client. The Prisma form of this error on Apple Silicon, with a connection timeout on top, is covered in Prisma's "Can't reach database server at database:5432". If the URL is right but the driver rejects the port, a special character in the password is usually splitting the string: see Prisma's invalid port fix. For the wider Compose setup, bind mounts and watch mode, the Docker development environment tutorial walks through a full configuration.

Once the host in the connection string names the service and the app waits for a healthy database, this error stops depending on luck: the address is right on the first try, and a slow start produces a few logged retries instead of a crashed container.


Originally published at https://www.iloveblogs.blog

Top comments (0)