PostgreSQL

Fix ECONNREFUSED 127.0.0.1:5432 in GitHub Actions

Your Postgres service is healthy, yet the migration step cannot reach it. Pick the host that matches how the job runs, and make Sequelize read DATABASE_URL.

Mahdi Ben RhoumaMahdi Ben Rhouma
8 min read

TL;DR#

connect ECONNREFUSED 127.0.0.1:5432 in GitHub Actions, with a Postgres service that passes its health check and a ports: mapping already in place, points at the job's container: key. With it, every step runs inside the job container, where localhost is that container, and Postgres is not in it. Pick one of two fixes: keep container: and connect to the service label (postgres:5432), or delete container: so the job runs on the runner machine, where localhost:5432 plus ports: - 5432:5432 works. With Sequelize, also check that the config file actually reads DATABASE_URL: a URL hard-coded in config/config.js ignores the workflow entirely.

The error from the workflow#

The report on Stack Overflow comes from a Node backend that runs Sequelize migrations in CI. The job runs in node:latest, starts a postgres:latest service with a pg_isready health check and ports: - 5432:5432, and sets DATABASE_URL to a localhost address. Checkout and npm install pass, then the migration step stops:

text
Sequelize CLI [Node: 17.9.0, CLI: 6.4.1, ORM: 6.3.5]
 
Loaded configuration file "config/config.js".
Using environment "test".
 
ERROR: connect ECONNREFUSED 127.0.0.1:5432

The conflict sits in a handful of lines of that workflow:

yaml
jobs:
  postgres-test:
    runs-on: ubuntu-latest
    container: node:latest
    services:
      postgres:
        image: postgres:latest
        ports:
          - 5432:5432
    env:
      DATABASE_URL: postgresql://postgres:password@localhost:5432/carbonlink

container: node:latest and localhost cannot both be right.

Why it happens: GitHub has two networking modes#

GitHub's page on communicating with Docker service containers states that "Communication between a job and its service containers is different depending on whether a job runs directly on the runner machine or in a container." The two modes are not interchangeable.

Job in a container (the job has container:). GitHub "connects service containers to the job using Docker's user-defined bridge networks". On that network, "The hostname of the service container is automatically mapped to the label name." The label is the key under services:, here postgres. The same page adds: "You don't need to configure any ports for service containers. By default, all containers that are part of the same Docker network expose all ports to each other, and no ports are exposed outside of the Docker network."

Job on the runner machine (no container: key). Here, "you can access service containers using localhost:<port> or 127.0.0.1:<port>", but only after you map the port: "You need to map ports on the service container to the Docker host."

The workflow in the question mixes the two. It runs in a container, so the steps execute inside the node:latest container, and 127.0.0.1 inside that container is its own loopback interface, where nothing listens on 5432. The kernel refuses the connection straight away, which is what ECONNREFUSED reports. The ports: - 5432:5432 mapping does not help: it publishes the port on the Docker host (the runner VM), not inside the job container. The health check does not help either: Postgres really is ready, just on another container's address.

The same loopback mechanism, in Docker Compose rather than in Actions, is covered in ECONNREFUSED 127.0.0.1:5432 in Docker Compose. What is specific to Actions is the choice between the two modes and the fact that the service label, not a Compose service name, becomes the hostname.

Fix#

1. Decide where the job runs#

Look for a container: key under the job. If it is there, follow step 2. If you do not need a container image for the job (most Node projects do not, because actions/setup-node installs Node on the runner), step 3 is simpler and keeps localhost.

2. Container job: connect to the service label#

Keep container:, change the host in DATABASE_URL from localhost to the label postgres, and keep the container port 5432. The ports: block can go: in this mode it only publishes the port on the runner VM, which no step uses.

yaml
name: Backend CI
on: [push, pull_request]
 
jobs:
  postgres-test:
    runs-on: ubuntu-latest
    container: node:latest
 
    services:
      postgres:
        image: postgres:latest
        env:
          POSTGRES_DB: carbonlink
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: password
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
 
    env:
      NODE_ENV: test
      DATABASE_URL: postgresql://postgres:password@postgres:5432/carbonlink
 
    steps:
      - uses: actions/checkout@v6
      - run: npm ci
      - run: npx sequelize db:migrate
      - run: npm test

This is the shape of GitHub's own PostgreSQL service container example for container jobs: no ports:, and POSTGRES_HOST: postgres passed to the client. The action versions are the ones the GitHub docs repository currently references (checkout, setup-node). If you rename the service key to db, the hostname becomes db.

3. Runner job: drop container: and keep localhost#

The alternative keeps the original connection string. Delete container:, install Node on the runner, and keep the port mapping, because in this mode it is mandatory.

yaml
jobs:
  postgres-test:
    runs-on: ubuntu-latest
 
    services:
      postgres:
        image: postgres:latest
        env:
          POSTGRES_DB: carbonlink
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: password
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
 
    env:
      NODE_ENV: test
      DATABASE_URL: postgresql://postgres:password@localhost:5432/carbonlink
 
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v7
        with:
          node-version: lts/*
      - run: npm ci
      - run: npx sequelize db:migrate

This mode is documented by GitHub, not reproduced here: the local test below covers the container-job mode only.

4. Make Sequelize read DATABASE_URL#

The question's config/config.js hard-codes the test URL:

js
module.exports = {
  test: {
    url: 'postgresql://postgres:password@localhost:5432/carbonlink',
    dialect: 'postgres'
  }
};

With that file, editing DATABASE_URL in the workflow changes nothing: sequelize-cli never reads the variable, and the migration still dials localhost. Tell the CLI to take the URL from the environment with use_env_variable, which the Sequelize CLI documentation describes and the CLI's config helper parses into host, port, user and database:

js
require('dotenv').config();
 
module.exports = {
  test: {
    use_env_variable: 'DATABASE_URL',
    dialect: 'postgres'
  }
};

The workflow now owns the host. The same file works on a laptop, where .env can point DATABASE_URL at localhost.

Check your own DATABASE_URL#

Paste the connection string from your workflow's env: block below: the checker flags a localhost or 127.0.0.1 host that will only work where the code runs, along with port and encoding mistakes. It runs in your browser; nothing is sent.

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.

Verify the fix#

The container-job mode was reproduced locally on 7 October 2026 with Docker Desktop: a postgres:latest container (PostgreSQL 18.6) with the network alias postgres and a published port, and a node:latest container (Node 26.10.0) on the same user-defined bridge network, the network type GitHub documents for container jobs. Sequelize CLI 6.6.5 with ORM 6.37.8 ran the migration. Nothing ran on a GitHub-hosted runner.

With the question's localhost URL, the error appears verbatim:

text
Loaded configuration file "config/config.js".
Using environment "test".
 
ERROR: connect ECONNREFUSED 127.0.0.1:5432

With one change, the host set to postgres, the migration runs:

text
Loaded configuration file "config/config.js".
Using environment "test".
== 20260101000000-create-ping: migrating =======
== 20260101000000-create-ping: migrated (0.040s)

The hard-coded config trap reproduced too. With the URL pinned to localhost in config/config.js, setting the environment variable to the postgres host still failed with ERROR: connect ECONNREFUSED 127.0.0.1:5432. Replacing url with use_env_variable: 'DATABASE_URL' was the only change before the next run, which connected and reported the schema already up to date; after db:migrate:undo:all, the migration ran again.

In your own pipeline, the migration step's log is the check: the == ...: migrated lines replace the ERROR: line. To confirm the hostname before the migration runs, add a step such as getent hosts postgres; in the local container job it printed the service container's bridge address.

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.

Things that look like fixes but do not change a container job#

Swapping localhost for 127.0.0.1. One answer on the question suggests it and reports success with the question's YAML; nothing in the thread shows how that run differed. In a container job the swap cannot work: the error already names 127.0.0.1, and the local run with the URL set to 127.0.0.1 failed with the identical ERROR: connect ECONNREFUSED 127.0.0.1:5432.

Adding ports:. Another answer reports that forgetting ports: caused the error. That is true for a runner job, where the mapping is required, but the question's workflow already had ports: - 5432:5432. In a container job the mapping publishes the port on the runner VM, and the job container's loopback still has nothing on 5432.

A longer health check. GitHub's example adds the pg_isready options precisely to "wait until postgres has started", and the question already had them. In the local reproduction the Postgres container reported healthy before the first migration attempt, and the attempt was still refused, because the address was wrong, not early.

The errors that come next#

getaddrinfo ENOTFOUND postgres. This one points the other way: the URL uses the label but the job has no container:, so it runs on the runner and is not on the services' bridge network. GitHub only documents localhost for that mode. A process outside the service network, simulated locally with a container not attached to it, failed the lookup with getaddrinfo ENOTFOUND postgres. Use step 2 or step 3, not a mix. The same message appears outside CI when next build runs inside docker build, which is not attached to the Compose network: see ENOTFOUND for a Compose service during a Next.js Docker build.

Unknown command: "set-script". The question's workflow disables Husky with npm set-script prepare ''. On today's node:latest that step fails before the migration runs: the bundled npm 11.19.1 printed Unknown command: "set-script" and exited with code 1. The npm 9 changelog lists the removal (npm/cli PR #5456). The npm pkg command replaces it; npm pkg delete scripts.prepare removes the hook and exited with code 0 in the same container.

password authentication failed for user "postgres". Once the host is right, the server checks credentials. The user and password in DATABASE_URL must match POSTGRES_USER and POSTGRES_PASSWORD on the service. If they do not, continue with password authentication failed for user "postgres", which covers the mismatch cases, including a data directory initialised with an older password.

bind: address already in use on a self-hosted runner. A runner-job ports: - 5432:5432 publishes 5432 on the runner host. If that host already runs PostgreSQL, Docker cannot publish the port; Postgres 5432 bind: address already in use shows how to find the owner, and mapping 5433:5432 with the URL on port 5433 avoids the clash.

The rule to keep in mind is short: the host in DATABASE_URL follows the place the steps run. When the steps run in a container, the database is the service label on port 5432 and ports: is irrelevant; when they run on the runner, the database is localhost on the mapped port. Fix that, then make sure the ORM's config file reads the variable you just fixed, or the workflow change never reaches the migration.

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