on this page

You wrote a backend. It connects to Postgres at localhost:5432. It worked on your laptop for weeks. Then you moved it into Docker, ran docker compose up, and got ECONNREFUSED 127.0.0.1:5432. The database is right there. You can see it in the logs, happily accepting connections. Your backend swears nothing is listening.

Nothing changed in your code. What changed is the meaning of one word.

This guide is about that word: localhost. Once you understand what it points to from inside a container, the connection-refused errors stop being mysterious and start being obvious. If “container,” “image,” and “Compose” are still fuzzy, go read Docker without the buzzwords first, then come back. I’ll assume you know roughly what a container is.

What localhost actually means

localhost is not a place. It’s a word each machine resolves to itself, to the address 127.0.0.1 (the loopback address, network-speak for “the same machine I’m running on, don’t go out to the network”). When you type localhost, you mean “me.” The question is who “me” is.

On your laptop, “me” is your laptop. So localhost:5432 reaches whatever is listening on port 5432 on your laptop. Simple, and it’s why local dev felt easy.

Here’s the part that bites everyone: a container is its own little machine. It has its own network identity, its own localhost, its own 127.0.0.1. When code inside a container says localhost, it means that container. Not your laptop. Not the container next door. Just itself, alone, with whatever happens to be running inside it.

So when your backend container connects to localhost:5432, it’s looking for a database inside the backend container. There isn’t one. Postgres is in a different container. The backend is shouting into an empty room.

Three vantage points

The trick to never getting lost again is to always know whose point of view you’re reasoning from. There are three, and they each see the network differently.

Your host machine. That’s your actual laptop, the thing running Docker. From here, localhost is your laptop, and you reach containers only through ports you’ve deliberately published (more on that in a second).

A container. From inside a running container, localhost is that container. To reach a different container, it uses that container’s name on the shared network.

Another container. Same deal, just flipped. The database doesn’t know or care about your laptop’s localhost. It lives on the Docker network and answers to its service name.

The bug at the top of this guide is what happens when code written from the host’s point of view (“localhost is where my database is”) gets moved into a container, where that sentence is suddenly false.

How Compose connects services

Here’s the good news. When you run services with Docker Compose, it quietly does the annoying part for you: it puts every service in your docker-compose.yml on the same private network and lets them find each other by service name.

The service name is just the key you wrote in the YAML. Look at this file. The keys api, db, and cache are the names:

# docker-compose.yml
# Each top-level key under "services" is a name other services can dial.
services:
  api:
    build: .
    ports:
      - "3000:3000" # publish to the host (we'll get to this)
    environment:
      # Reach Postgres by its service name "db", not localhost.
      - DATABASE_URL=postgres://postgres:password@db:5432/myapp
      # Reach Redis by its service name "cache".
      - REDIS_URL=redis://cache:6379
    depends_on:
      - db
      - cache

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: password
      POSTGRES_DB: myapp

  cache:
    image: redis:7-alpine

Inside the api container, the hostname db resolves to the database container, and cache resolves to Redis. Compose runs a tiny internal DNS (Domain Name System, the same name-to-address lookup the whole internet uses) so db just works as a hostname. You did not configure that. It’s the main reason people put up with Compose.

So the fix for the opening bug is one word: change localhost:5432 to db:5432. The backend stops looking inside itself and starts looking at the database container.

backend:3000 versus localhost:3000

Let’s make this concrete with two containers talking to each other. Say you have a backend service (Node.js and Express) and a separate worker service that needs to call the backend’s API.

From inside the worker container, localhost:3000 means “port 3000 on the worker itself.” The worker isn’t running an API on 3000, so that fails. What it wants is backend:3000, which means “port 3000 on the container named backend.” That works, because Compose maps the name backend to the right container.

// Code running INSIDE the worker container.

// Wrong: looks for an API inside the worker. Nothing there.
await fetch("http://localhost:3000/jobs");

// Right: dials the backend container by its service name.
await fetch("http://backend:3000/jobs");

Notice the port stayed 3000 both times. The port number isn’t the bug. The hostname is. localhost pointed at the wrong machine.

One more thing that trips people up: container-to-container, you use the port the app actually listens on inside its container, not whatever you published to your laptop. If the backend listens on 3000 inside its container, other containers use backend:3000, even if you published it to your laptop as 8080:3000. The published port is only for your laptop. Inside the network, containers talk on the real internal ports.

The part beginners miss: the browser is not in your network

This is the one that costs people an entire afternoon, so read it twice.

Your frontend has two halves that live in completely different places. There’s the part that runs on the server (building pages, server-side rendering, an Astro endpoint, a Next.js API route) and the part that runs in the user’s browser (your React components fetching data after the page loads, an Angular service calling your API).

Those two halves are not on the same network, and that is the whole problem.

Server-side code runs inside its container, on the Docker network. It can reach other containers by service name. fetch("http://backend:3000/api") from server code is fine, because the server container can resolve backend.

Browser code runs on the user’s machine. Someone in another country opens your site in Chrome. That browser has never heard of your Docker network. It cannot resolve backend. The name backend only exists inside Compose’s private DNS, on your server, and the user’s laptop is nowhere near it. So fetch("http://backend:3000/api") from browser code fails instantly with a DNS error, because to the browser, backend is not a real address.

Browser code has to call a URL the browser can actually reach: http://localhost:3000 during local development (through a port you published), or the public https://api.yoursite.com URL in production. Never the internal service name.

Publishing a port: the only way in from your laptop

I keep saying “published port,” so let’s nail it down, because forgetting this is its own classic bug.

By default, a container’s ports are sealed off from your laptop. The service is running, it’s listening, and you still can’t reach it from your browser, because nothing connects your laptop’s network to the container’s network. You have to open a door on purpose. That’s what ports: does.

services:
  backend:
    build: .
    ports:
      - "8080:3000"
      #  ^host ^container
      # Traffic to YOUR laptop's port 8080 forwards to the
      # container's port 3000. The format is host:container.

With that line, http://localhost:8080 on your laptop reaches the backend’s port 3000 inside the container. No ports: entry, no door, and your browser gets connection refused no matter how healthy the container is.

Two details worth burning in. The left number is your laptop, the right number is the container, every time. And published ports are for your laptop reaching in; containers talking to each other never need a published port, because they’re already on the same internal network. A database usually should not publish a port at all, since only your other containers need it and exposing 5432 to your laptop (or worse) is just attack surface you didn’t need.

Two examples end to end

A backend talking to a database. Backend and Postgres both run as Compose services. The backend connects with postgres://postgres:password@db:5432/myapp, using the service name db. No published port on Postgres, because only the backend talks to it. This is the normal, boring, correct setup.

A frontend talking to a backend. Your React app’s components call the API after the page loads, so that code runs in the user’s browser. In local dev you publish the backend as 8080:3000 and point the browser at http://localhost:8080. In production you point it at the real public URL, something like https://api.yoursite.com, served by Railway

Heads up. You're leaving raindev.fyi

This link heads to railway.com, an external site we don't control. Cool to keep going?

Continue
or Render

Heads up. You're leaving raindev.fyi

This link heads to render.com, an external site we don't control. Cool to keep going?

Continue
, with your static frontend on Cloudflare Pages

Heads up. You're leaving raindev.fyi

This link heads to pages.cloudflare.com, an external site we don't control. Cool to keep going?

Continue
. The browser never sees backend:3000 in either case, because the browser is never on the Docker network.

The way teams handle the two-environments problem is an environment variable the browser bundle reads at build time, so the same code points at localhost locally and the public domain in production.

Who calls what: the address cheat sheet

When you hit a connection error, find the caller in the left column and check you used the matching address.

Who is callingWhat they want to reachAddress to use
Your laptop (browser, curl, Postman)A containerlocalhost:PUBLISHED_PORT (needs a ports: entry)
Container (server-side code)Another containerservice-name:INTERNAL_PORT, e.g. db:5432, backend:3000
ContainerA service inside itselflocalhost:PORT (rare, usually means you have a bug)
User’s browser (React, Angular)Your backend in devlocalhost:PUBLISHED_PORT
User’s browserYour backend in prodThe public https:// URL
ContainerSomething on your host OShost.docker.internal:PORT (the host’s special hostname)

That last row is the escape hatch: when a container genuinely needs to reach a service running on your laptop and not in Docker, Docker gives you the hostname host.docker.internal that points back at the host. You’ll need it rarely. When you do, you’ll be glad it exists.

Classic traps

These four account for the overwhelming majority of “why won’t my containers talk” tickets.

SymptomWhat you didThe fix
ECONNREFUSED 127.0.0.1:5432 from the backendBackend connects to the DB at localhostUse the service name: db:5432
Browser console: DNS / name not resolved for backendFrontend browser code calls the internal service nameCall localhost:PORT (dev) or the public URL (prod)
Connection refused from your laptop, container looks fineNo ports: entry, so nothing is reachable from the hostAdd ports: - "8080:3000"
Backend crashes on boot, works on restartApp connected before Postgres was ready to accept connectionsSee the note below

That last one is sneaky because it looks like a networking bug and isn’t. depends_on only waits for the database container to start, not for Postgres to finish booting and actually accept connections. So your backend connects a half-second too early, gets refused, and dies. The container restarts, by which time Postgres is ready, and now it works, which makes it look random.

Why this matters when you ship

Every one of these confusions has the same root: a container thinks it’s the only machine in the world, and localhost means “me.” Hold that one idea and the rest follows. Server code uses service names because containers share a network. Browser code uses public URLs because the user’s browser was never invited to that network. Your laptop uses published ports because that’s the only door in.

You’ll feel it the day you deploy for real. You push the same docker-compose.yml to a server, your frontend ships to Cloudflare Pages, your API runs on Railway behind a public domain, and it all just works, because you already sorted out who was calling what. The people who skip this part spend launch day staring at connection-refused logs wondering why production hates them. You won’t be one of them. You’ll be the older dev friend explaining it to someone else.