The Service Is Running, Listening, and Completely Unreachable
curl works on the box and hangs from outside. Four places the packet can die, and how to tell which one killed it without guessing.
I deployed a service, SSHed in, ran curl localhost:8080 and got a clean 200. From my laptop the same request hung until it timed out. No error, no refusal, just silence.
Silence is the useful clue and I nearly missed it. A hang and a refusal are different diagnoses, and the difference tells you roughly where the packet died.
Read the failure mode first
Before touching a firewall rule, note exactly how the connection fails:
| Symptom | Meaning | Look at |
|---|---|---|
Connection refused, immediate |
The packet arrived, nothing was listening on that port | The process and its bind address |
| Hangs then times out | The packet was dropped silently, nobody sent a reset | A firewall or security group |
No route to host |
Routing or ICMP rejection | Network configuration |
| DNS error | Never got as far as connecting | Name resolution, not the service |
Mine hung. A refusal comes from the kernel of a machine that received the SYN and had nothing on that port. A hang means something dropped the packet without replying, and dropping without replying is what firewalls do deliberately, because a reset would confirm the host exists.
So the process was almost certainly fine and something in front of it was eating the traffic.
Tested on Ubuntu 24.04, Linux 6.8, curl 8.5.
Hypothesis one: it is bound to loopback
The most common cause of exactly this, and worth eliminating in one command.
ss -tlnp
State Recv-Q Send-Q Local Address:Port
LISTEN 0 511 127.0.0.1:8080
127.0.0.1:8080 is reachable only from the machine itself. 0.0.0.0:8080 accepts from anywhere. Every framework has a different way of getting this wrong: Flask defaults to loopback, next dev binds locally unless told otherwise, and plenty of Docker examples publish a port while the app inside still listens on 127.0.0.1, which produces this precise symptom.
Mine showed 0.0.0.0:8080. So it was listening correctly and the packet was dying earlier.
Hypothesis two: the host firewall
sudo ufw status verbose
sudo iptables -L INPUT -n -v --line-numbers
sudo nft list ruleset
Empty. No ufw, no rules, default policy ACCEPT. Not the guest.
This is where the investigation stops being about the machine, because I had now proved the service was listening on all interfaces and the guest was not filtering. If a packet had reached the kernel, it would have been answered. It was not being answered, so it was not reaching the kernel.
Hypothesis three: the packet never gets to the machine
It did not, and this is the design rather than a fault.
The machine had no public IPv4 address. Not a blocked one, not a firewalled one. There was no address on the public internet that routed to it. My laptop was sending SYNs into a void.
This model is increasingly common and it catches people who are used to a VPS where you get an address and a wide open port 22 by default. Inbound is default deny at the platform edge, and the only paths in are ones you deliberately create:
- an SSH session, which is brokered rather than a direct TCP connection to a public address
- a hostname mapped at the edge and forwarded inward
- an explicitly published port
I had done none of the three. The service was perfect and nobody outside could address it.
What I changed
Mapping a hostname at the edge, which is the intended route for anything HTTP. On Krova Cloud the machine stays unaddressable and traffic is matched by hostname at the proxy and forwarded to the internal port:
krova domains add app.example.com --cube my-api --port 8080
# then a CNAME from app.example.com to dns.krova.cloud
Certificates are issued automatically once DNS resolves, which introduces its own set of caches to be patient with. I wrote that up in the domain that resolved everywhere except my laptop.
For a non HTTP service, publish the port explicitly and accept that you have just created the only door in the building, so it should have a lock on it.
Why default deny is worth the friction
The first time this happens it feels like the platform is in your way. Having now run infrastructure where the opposite was true, I think the friction is correctly placed.
On a conventional VPS, the instant your machine boots it has a public address and something is scanning it. Not eventually. Within minutes. Bring up a box with password SSH enabled and watch /var/log/auth.log fill with attempts from half a dozen countries before you have finished configuring it. The default posture is exposed, and safety is a thing you remember to add.
Default deny inverts that. Nothing is reachable until you say so, and the cost is that a deployment can be running for ten minutes before you notice nobody can reach it. That is a self correcting mistake with an obvious symptom. The other kind is not.
There is a second effect that matters more at scale. Machines cannot reach each other either. Traffic between them is dropped at the bridge, so a compromised workload cannot scan the local subnet for neighbours, which is the standard next move after an initial foothold. Combined with a per machine kernel it means lateral movement has no obvious path, which is the isolation argument rather than the convenience one.
What I would do differently
Classify the failure before forming a hypothesis. Hang versus refused versus no route narrows the search to roughly one layer, and I spent five minutes on the application before reading my own timeout properly.
Run ss -tlnp first, always. It is one command and it eliminates the single most common cause.
Verify from outside during deployment, not after. A curl from your laptop belongs in the deployment checklist, because curl localhost on the box proves the process works and proves nothing about whether anyone can use it.
Read the platform's network model once, properly. Twenty minutes of documentation would have told me there was no public address. I assumed a VPS shaped world because that is what I had used before, and the assumption cost me more than the reading would have. Most infrastructure surprises are assumptions that were true somewhere else.