PostgreSQL

Fix ECONNREFUSED 127.0.0.1:5432 in Docker Compose (Node)

Your Node app and Postgres both start, yet every query fails with connection refused. Read the address in the error, fix the host, and gate startup on pg_isready.

Mahdi Ben RhoumaMahdi Ben Rhouma
8 min read

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:

bash
database system is ready to accept connections
Error: connect ECONNREFUSED 127.0.0.1:5432

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

yaml
DATABASE_URL: postgres://username:pgpassword@127.0.0.1:5432/mydatabase

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 errorMeaningFix
127.0.0.1:5432 or localhost from inside a containerThe app dials its own containerUse the service name (step 1)
::1:5432localhost resolved to the IPv6 loopbackSame as above in a container; on the host, see the edge cases
A private IP such as 172.18.0.2:5432DNS worked; Postgres is not accepting yet, or listens elsewhereHealthcheck and retries (steps 1 and 3)
The right host, but a port such as 15432The host side of a ports mapping was used between containersUse 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.

Runs entirely in your browser — nothing you paste is sent anywhere, and the password is masked in every result. Still cautious? Replace the password with any string containing the same special characters: the diagnosis only needs its shape.

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.

    yaml
    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

    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.

  2. 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:

    js
    // db.js — node-postgres
    const { Pool } = require('pg');
     
    const pool = new Pool({
      connectionString: process.env.DATABASE_URL,
    });
     
    module.exports = pool;

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

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

    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.

  3. 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:

    js
    // 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);
    });
  4. Recreate the stack so the new environment is applied. A running container keeps the environment it was created with:

    bash
    docker compose down
    docker compose up --build

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:

bash
docker compose exec db pg_isready -U username -d mydatabase
docker compose ps

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:

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

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:

bash
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); })"

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.

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.

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.

Further reading: Fix ECONNREFUSED 127.0.0.1:5432 in GitHub Actions, Fix: Sequelize authenticate() Hangs With No Success or Error. Also see PostgreSQL 18 Docker: Fix Existing Volume Startup Errors.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Written by

Mahdi Ben Rhouma
Mahdi Ben Rhouma

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote