NestJS Microservices on Railway — TCP Transport Connection Refused

NestJS microservices connection refused on Railway shows up as the most confusing kind of bug: both services are green in the Railway dashboard, both boot cleanly, both respond fine if you curl them directly — and the moment one tries to reach the other over the TCP transport, it's ECONNREFUSED. The instinct is to start rewriting your ClientsModule configuration. Don't. The transport config is almost never the problem.
Short answer: you're almost certainly pointing one service at the other's public URL instead of Railway's private network, or you've hit the same IPv4/IPv6 mismatch that trips up Redis and BullMQ on Railway — the private network defaults to IPv6, and Node's networking stack defaults to IPv4 unless told otherwise. Fix the hostname to use Railway's internal private-network address, add the IPv6 family option, and the refused connection turns into a handshake.

Why NestJS Microservices Get Connection Refused on Railway
ECONNREFUSED means one specific thing: the target machine actively rejected the connection attempt — not a timeout, not a DNS failure, an active refusal. That narrows the cause considerably. Between two Railway services, this happens for one of two reasons almost every time:
- You're connecting over the public URL. Railway's public-facing domains aren't meant for service-to-service traffic, and depending on how the target service is configured, a connection attempt from outside the expected path gets rejected rather than routed through.
- You're on Railway's private network correctly, but hitting a protocol mismatch. Railway's private networking defaults to IPv6. Node's
netmodule — which NestJS's TCP transport sits directly on top of — defaults to IPv4 unless explicitly configured to negotiate both.
(If this sounds familiar, it's because it's the exact same mechanism behind Railway's IPv6-default breaking BullMQ workers — Railway didn't do anything wrong here, it's just consistent about defaulting to IPv6 internally, and a lot of Node tooling quietly assumes IPv4.)
Fixing the Hostname: Public URL vs Private Network
The first thing to verify — and the one people check last, for some reason — is which hostname the client is actually configured to hit:
1// app.module.ts
2import { Module } from '@nestjs/common';
3import { ClientsModule, Transport } from '@nestjs/microservices';
4
5@Module({
6 imports: [
7 // WRONG: pointing at the public-facing URL for internal service traffic
8 // ClientsModule.register([
9 // {
10 // name: 'BILLING_SERVICE',
11 // transport: Transport.TCP,
12 // options: { host: 'billing-service-production.up.railway.app', port: 3001 },
13 // },
14 // ]),
15
16 // RIGHT: Railway's internal private-network hostname
17 ClientsModule.register([
18 {
19 name: 'BILLING_SERVICE',
20 transport: Transport.TCP,
21 options: { host: 'billing-service.railway.internal', port: 3001 },
22 },
23 ]),
24 ],
25})
26export class AppModule {}Railway assigns every service a private .railway.internal hostname specifically for this — service-to-service traffic that never needs to touch the public internet, isn't subject to public bandwidth billing, and doesn't take the round trip out and back in that a public URL would.

The IPv6 Gotcha in NestJS's TCP Transport
If the hostname is already correct and you're still seeing ECONNREFUSED, the IPv4/IPv6 mismatch is next. NestJS's TCP transport is a thin layer over Node's own net module, and neither one automatically negotiates IPv6 by default — you have to tell it to.
1// main.ts of the microservice being connected TO
2import { NestFactory } from '@nestjs/core';
3import { MicroserviceOptions, Transport } from '@nestjs/microservices';
4import { AppModule } from './app.module';
5
6async function bootstrap() {
7 const app = await NestFactory.createMicroservice<MicroserviceOptions>(AppModule, {
8 transport: Transport.TCP,
9 options: {
10 host: '::', // listen on all interfaces, IPv4 and IPv6
11 port: 3001,
12 },
13 });
14 await app.listen();
15}
16bootstrap();On the client side, if you're constructing a raw socket connection anywhere in a custom health check or diagnostic script, pass family: 0 to let Node negotiate whichever protocol actually resolves — the identical fix used for ioredis on Railway, because it's solving the identical underlying mismatch.

Testing Connectivity Directly From Railway's Shell
Before redeploying and hoping, confirm the fix from inside Railway's own shell for the calling service:
1# From within the Railway shell of the service making the call
2nc -zv billing-service.railway.internal 3001A successful connection here confirms the networking is fixed before you go looking for anything else. If it still fails at this raw TCP level, the problem is entirely in the hostname/protocol configuration — not in NestJS, not in your business logic, and not worth debugging inside your microservice's code at all until this line succeeds.
The Opinion Part
Here's the position worth stating plainly, and it's not really about Railway: you almost certainly don't need microservices yet, and this exact bug is a preview of the tax you're now paying for having them. Microservices solve an organizational problem — multiple teams needing to deploy independently — not a traffic problem. Splitting into services buys you network calls where you used to have function calls, and a connection-refused debugging session is what that trade looks like on a Tuesday. Standish's data on large, complex builds backs this up directionally: more moving parts fail more often, and a distributed system is strictly more moving parts than a monolith. If it's a small team debugging TCP transport connectivity between two services you both maintain, it's worth asking honestly whether the split earned its complexity yet — our monolith vs. microservices breakdown goes into exactly when the answer becomes yes.
If you've made that call deliberately and you're staying distributed, an event-driven approach sidesteps this specific class of direct-connection bug entirely — our event-driven architecture guide is worth reading if TCP transport keeps being the thing that breaks between deploys.
Conclusion
NestJS microservices refusing to connect on Railway is rarely a NestJS problem — it's a networking configuration problem wearing a NestJS stack trace. Check the hostname first (private network, not public URL), check the IPv4/IPv6 negotiation second, and verify with a raw nc connection before you touch your transport config at all. Once both services are actually reachable at the TCP level, the rest of your microservices code was already fine — it just never got the chance to prove it.
If the fix here felt like patching around a decision you're not sure you needed to make, that's a normal reaction, and it's a genuinely useful data point about your own architecture. We have that conversation with clients often enough that it's basically a service line at this point.
Frequently Asked Questions
Almost always one of two causes: the services are configured to reach each other over Railway's public URL instead of its private network, or they're using Railway's private network correctly but hitting an IPv4/IPv6 mismatch — Railway's private networking defaults to IPv6, while Node's net module and NestJS's TCP transport default to IPv4 unless told otherwise.
Private networking, for service-to-service traffic. Railway's private network is faster, doesn't count against public bandwidth, and doesn't route internal traffic out to the public internet and back in. Public URLs should be reserved for traffic actually coming from outside Railway — your frontend or external webhooks, not one of your own services talking to another.
Yes, structurally identical. Railway's private network defaults to IPv6, and several Node libraries — ioredis for BullMQ, and Node's own net/dgram modules underneath NestJS's TCP transport — default to IPv4 unless explicitly told to negotiate both. The fix in both cases is the same family: tell the client to use IPv6 or let it negotiate automatically instead of assuming IPv4.
Open Railway's shell for one service and use nc -zv <internal-hostname> <port> or a small Node script that attempts a raw net.connect() to the other service's private hostname and port. If that fails while both services show as running, you've confirmed it's a networking configuration issue, not an application crash.
For most early-stage SaaS teams, a modular monolith avoids this problem completely, because there's no network hop between modules to misconfigure. Microservices solve a genuine organizational problem — multiple teams needing to deploy independently — not a traffic problem. If you're debugging TCP transport connectivity between two services run by the same small team, it's worth asking whether the split earned its complexity yet.
