DevOps

Next.js + Strapi in Docker: fetch failed ECONNREFUSED 1337

Read the address in the error, swap localhost for the Strapi service name in server-side fetches, and keep a separate public URL for anything the browser loads.

Mahdi Ben RhoumaMahdi Ben Rhouma
8 min read

TL;DR#

TypeError: fetch failed with cause: Error: connect ECONNREFUSED 127.0.0.1:1337 from a Next.js container means the server-side fetch dialled the frontend container's own loopback. Strapi runs in another container. Inside a Compose network, call Strapi by its service name and container port: http://backend:1337/api/posts. The ports: '1337:1337' line only opens Strapi to the host, which is why the same URL works in a browser. Anything the browser itself loads from Strapi still needs the host address, so keep two URLs.

The error from the question#

The Stack Overflow question runs a Next.js frontend service and a Strapi backend service from one Compose file. Both are built from a dev.dockerfile, and the page fetches http://localhost:1337/api/posts in getStaticProps. It works when both run on the laptop, and fails as soon as both run in Docker:

text
error - TypeError: fetch failed
frontend  |     at Object.fetch (node:internal/deps/undici/undici:11118:11)
frontend  |     at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
frontend  |     at async getStaticProps (webpack-internal:///./pages/index.tsx:38:17)
frontend  |     at async Object.renderToHTML (/usr/local/frontend/node_modules/next/dist/server/render.js:384:20)
frontend  |     at async doRender (/usr/local/frontend/node_modules/next/dist/server/base-server.js:708:34)
frontend  |     at async cacheEntry.responseCache.get.isManualRevalidate.isManualRevalidate (/usr/local/frontend/node_modules/next/dist/server/base-server.js:813:28)
frontend  |     at async /usr/local/frontend/node_modules/next/dist/server/response-cache/index.js:80:36 {
frontend  |   cause: Error: connect ECONNREFUSED 127.0.0.1:1337
frontend  |       at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1300:16) {
frontend  |     errno: -111,
frontend  |     code: 'ECONNREFUSED',
frontend  |     syscall: 'connect',
frontend  |     address: '127.0.0.1',
frontend  |     port: 1337
frontend  |   },
frontend  |   page: '/'
frontend  | }

Two details in that trace locate the problem. The frontend | prefix shows the error comes from the Next.js container's log. The address: '127.0.0.1' line shows where the connection went: the loopback of the process that made it. node:internal/deps/undici is Node's built-in fetch, so this is server-side code running in Node, not a browser request.

Why it happens#

localhost belongs to each container. Every container has its own network stack and its own loopback interface. When the Next.js process resolves localhost, it gets the loopback of the frontend container. Strapi is listening in the backend container, so the TCP connection reaches a port where nothing listens, and the kernel refuses it (errno: -111).

Services find each other by name. The Compose networking guide says that each container "joins the default network and is both reachable by other containers on that network, and discoverable by its service name" (Networking in Compose). In the question's file the Strapi service is named backend, so backend is the hostname the Next.js container must use.

A published port is a host-side door. The same guide explains that traffic between services uses the container port, and that "The host port is only used when accessing the service from outside the network." The '1337:1337' mapping is what lets the browser on the laptop open http://localhost:1337. It does nothing for traffic between two containers, which goes straight to the container port.

In development the fetch runs on every page load. Both services in the question are built from a development Dockerfile, and the Next.js docs say that "In development (next dev), getStaticProps will be called on every request" (getStaticProps). So the failing call happens inside the running frontend container each time / is requested. That is why the fix is a hostname, not a change to the build.

Reproduced with a stand-in backend#

The reproduction used two node:22-bookworm-slim containers on a default Compose network. A tiny Node HTTP server stands in for Strapi on 0.0.0.0:1337 and answers /api/posts with JSON. Strapi itself was not run, because the error is about Docker networking, not about Strapi. The frontend container runs the question's call through Node's built-in fetch (the same undici client as in the trace). Run on 19 October 2026 with Docker 29.7.2, Compose v5.5.0 and Node v22.23.3:

text
$ docker compose exec frontend node /app/fetch.js http://localhost:1337/api/posts
TypeError: fetch failed
    at node:internal/deps/undici/undici:15270:13
    at process.processTicksAndRejections (node:internal/process/task_queues:103:5) {
  [cause]: Error: connect ECONNREFUSED 127.0.0.1:1337
      at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1638:16) {
    errno: -111,
    code: 'ECONNREFUSED',
    syscall: 'connect',
    address: '127.0.0.1',
    port: 1337
  }
}

The backend had already logged backend listening on 0.0.0.0:1337 before that call, so start-up order was not the cause. From the Docker host, in the same run and with the stack unchanged, the published port answered:

text
$ curl -s http://localhost:55470/api/posts   (on the Docker host)
{"data":[{"id":1,"title":"Hello from backend"}]}

The host port is 55470 because the reproduction let Docker pick a free one (ports: - "1337"). The point is the same as in the question: "works from the host" and "refused from the next container" are both true of one running stack.

Fix#

  1. Point server-side fetches at the service name. Keep the URL out of the code so the same page runs on the laptop and in Compose. The variable name is your choice; this example calls it STRAPI_INTERNAL_URL:
tsx
export async function getStaticProps() {
  const base = process.env.STRAPI_INTERNAL_URL ?? 'http://localhost:1337'
  const res = await fetch(`${base}/api/posts`)
  const data = await res.json()
 
  return {
    props: { data },
  }
}
  1. Set it in the frontend service. The hostname is the Compose service name (backend) and the port is the container port (1337), whatever the host mapping is. No links: entry is needed. This is the question's Compose file with one environment: block added; everything else is unchanged, including the volumes that hold Strapi's data:
yaml
version: '3.8'
services:
  backend:
    container_name: backend
    build:
      context: backend
      dockerfile: .docker/dev.dockerfile
    image: backend:latest
    env_file: ./backend/.env
    volumes:
      - strapi_data:/usr/local/backend/strapi/.tmp
      - strapi_upload:/usr/local/backend/strapi/public/uploads
    ports:
      - '1337:1337'
  frontend:
    container_name: frontend
    build:
      context: frontend
      dockerfile: .docker/dev.dockerfile
    image: frontend:latest
    env_file: ./frontend/.env
    environment:
      STRAPI_INTERNAL_URL: http://backend:1337
    volumes:
      - node_modules_next:/usr/local/frontend/node_modules
      - next_folder:/usr/local/frontend
    ports:
      - '3000:3000'
    depends_on:
      - 'backend'
volumes:
  node_modules_next:
  next_folder:
  strapi_data:
  strapi_upload:
  1. Keep a second URL for the browser. backend only resolves inside the Compose network; the Compose guide's own example reaches a service from the host through localhost and the host port instead. Any Strapi URL the browser loads (a client-side fetch, or a media link) has to use the published address. The environment variables guide states that "By default, environment variables are only available on the server", and that browser values need the NEXT_PUBLIC_ prefix and are "inlined into the JavaScript bundle during next build". So the browser URL belongs in a NEXT_PUBLIC_ variable that is set before the build, and the internal URL stays a plain server variable:
bash
# frontend/.env
NEXT_PUBLIC_STRAPI_URL=http://localhost:1337
  1. Remove the next_folder volume so the edited page reaches the container. In the question's file, this named volume is mounted over the whole app directory, /usr/local/frontend, and the code is baked into the image. Docker's volume guide says that when an empty volume is mounted over existing files, those files "are propagated (copied) into the volume by default", while a non-empty volume mounted over a directory means "the pre-existing files are obscured by the mount" (Volumes). After the first up the volume already holds the old code, so a rebuilt image alone never brings the step 1 change into the container. Stop the frontend and remove that one volume. Compose prefixes the volume name with the project name, so list it first and remove the name it prints (myproject_next_folder below stands for that name):
bash
docker compose rm -sf frontend
docker volume ls --filter name=next_folder
docker volume rm myproject_next_folder

Do not use docker compose down -v for this: it also deletes strapi_data and strapi_upload, which hold Strapi's content and uploads.

  1. Rebuild and start the frontend. The new container picks up the STRAPI_INTERNAL_URL value from step 2, and the now-empty volume is filled again from the rebuilt image:
bash
docker compose up -d --build frontend

Verify the fix#

Call the internal URL from inside the frontend container before you reload the page. In the reproduction, changing only the hostname from localhost to backend turned the refusal into data:

text
$ docker compose exec frontend node /app/fetch.js http://backend:1337/api/posts
OK http://backend:1337/api/posts {"data":[{"id":1,"title":"Hello from backend"}]}

The name resolves through Compose's DNS to the backend container's address on the project network, even though the Compose file has no links: key:

text
$ docker compose exec frontend getent hosts backend
172.19.0.2      backend

In your own stack, the equivalent check is one line run from the host. It asks the frontend container, not your laptop, to reach Strapi:

bash
docker compose exec frontend node -e "fetch('http://backend:1337/api/posts').then(r => console.log(r.status))"

Any HTTP status code, even an error status, means the TCP connection was accepted and the network path is right; what Strapi answers is then an application question. Then reload http://localhost:3000 and watch docker compose logs -f frontend for the request.

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.

When backend resolves and Strapi still refuses#

If the error keeps ECONNREFUSED but the address changes from 127.0.0.1 to a private address such as 172.19.0.2, the name is right and nothing is accepting connections on that container's network interface. Two causes fit this case.

Strapi listens on its loopback only. The generated Strapi config reads host: env('HOST', '0.0.0.0'), and the generated .env.example sets HOST=0.0.0.0 (Strapi server.js template). A project whose backend/.env sets HOST to 127.0.0.1 accepts connections only from inside its own container. The reproduction showed this with a second listener bound to 127.0.0.1 on port 1338 in the backend container:

text
$ docker compose exec frontend node /app/fetch.js http://backend:1338/api/posts
TypeError: fetch failed
    at node:internal/deps/undici/undici:15270:13
    at process.processTicksAndRejections (node:internal/process/task_queues:103:5) {
  [cause]: Error: connect ECONNREFUSED 172.19.0.2:1338

The same listener answered inside backend: 200 when called from the backend container itself. Set HOST back to 0.0.0.0 in backend/.env and recreate the backend.

Strapi has not finished starting. depends_on: - backend only orders container start-up. Docker's startup-order guide says Compose "does not wait until a container is "ready", only until it's running" (Control startup order). With next dev, a request made while Strapi is still booting fails, and the next one succeeds. The fix is a healthcheck plus condition: service_healthy. The RabbitMQ ECONNREFUSED walkthrough shows that pattern for a slow-starting broker.

What the other answers on the question change#

  • links: - backend (the answer that adds a links entry to the frontend) is harmless but not the fix. The Compose guide says links "are not required for basic service-to-service communication. By default, any service can reach any other service at that service's name." The URL change to http://backend:1337 is what makes the request work.
  • build.network: host (the answer that switches the build to host networking) only affects docker build. It matters only if next build runs inside the image, which a dev.dockerfile running next dev does not do. If you later build a production image with getStaticProps, the build step cannot use backend. That is a different error, getaddrinfo ENOTFOUND, explained in Docker hostname ENOTFOUND in Next.js prod but not in dev. That page moves the fetch to request time.

The same "localhost is the wrong container" mistake shows up with other backends. For a Node app that cannot reach Postgres, see ECONNREFUSED 127.0.0.1:5432 in Docker Compose. For a proxy whose upstream points at its own loopback, see Nginx 502 with an upstream on localhost. There is also a case with no containers at all: Next.js and Strapi both run on the laptop, and the error reads ECONNREFUSED ::1:1337. That one is an IPv6 resolution order problem, covered in FetchError request to localhost:1337 ECONNREFUSED ::1.

The rule that settles this error: an address in a server-side fetch is resolved by the container that runs the code. In Compose, that container reaches Strapi at backend:1337. Your browser, outside the network, still reaches it at localhost:1337. Once those two URLs are separate, the refusal in getStaticProps goes away.

Further reading: Next.js builds locally, fails in Docker: fix the image error.

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