Skip to main content

I published a blog post, pushed to main, and watched the working tree go clean. Everything looked shipped.

The post was a 404 for about thirty hours. I only found out because I happened to open the URL.

The deploy had failed. GitHub had told me, in the sense that a red X existed on a page I wasn’t looking at. Nobody emailed me, nothing buzzed, and the site kept serving the previous build behind a perfectly healthy 200. There is no version of “check the site” that catches this, because the site looks fine. It’s the new thing that’s missing, and you don’t go looking for something you believe you just shipped.

So I opened the run.

The Log I Misread

The workflow tarred dist/ and pushed it over SCP to my Hostinger plan, on the non-standard SSH port they use. Here’s the shape of the failing attempt:

17:16:44  tar all files into /tmp/zkiElzgTct.tar.gz
17:18:57  remote server os type is unix
17:18:57  scp file to server.
17:21:12  error copy file to dest: ***, error message:
          dial tcp ***:65002: connect: connection timed out

Seventeen minutes, three internal retries, same ending every time.

The diagnosis writes itself, and I wrote it: the archive step is taking two minutes and thirteen seconds. I’m shipping one big tarball over a residential-grade connection to a shared host. Obviously I should stop doing that. Stream it. Use rsync and send only changed files. Split the transfer. The fix practically volunteers.

That was wrong in a way I find genuinely embarrassing, because the log says so plainly once you stop reading the labels and start reading the clock.

drone-scp prints tar all files into ... before it does the work, the way most tools announce a step on entry. The 133-second gap after that line isn’t the tar. It’s whatever happens next — a connection to the remote host to sniff its OS. Which failed. Then it tried the actual transfer, and that failed too, 135 seconds later.

My build is 39 files. Ten megabytes, 7.7 tarred. It tars in well under a second. I had assigned two minutes of dead air to an operation that takes no measurable time, because the line above the gap had the word “tar” in it.

The Number That Gave It Away

133.5 and 135.2 seconds. Those are suspiciously alike, and suspiciously close to a number I half-remembered.

Linux defaults to tcp_syn_retries = 6. When you open a connection and nothing answers, the kernel retransmits the SYN on a doubling backoff — 1, 2, 4, 8, 16, 32 seconds — then waits 64 more before giving up. That’s 127 seconds of trying, plus process overhead. It lands right about where mine landed.

Then I found the corroboration, in an older failure of the same workflow before someone had raised a timeout. Same two gaps, but this time 120.4 and 120.1 seconds — against a configured timeout: 120s.

That’s the whole diagnosis in one comparison. Two runs, two different configured timeouts, two different gap lengths, each matching its own setting. A gap that changes size when you change a timeout value is not work being done. It’s a clock running out.

Raising that timeout from 120 to 300 hadn’t fixed anything. It had made failures take longer.

What a Timeout Doesn’t Tell You, and What It Does

connection timed out feels like a non-answer. It’s actually specific, mostly by what it rules out.

A closed port gives you connection refused — an RST comes back immediately. A host that’s down or unroutable usually gets you an ICMP unreachable. Both are fast, and both are the network telling you something.

A timeout means the packets left and nothing came back at all. No refusal, no error, no response. Something is silently discarding them — a firewall rule that drops rather than rejects.

Which invited an easy test. From my laptop:

nc -z -w 20 <host> 65002   →  succeeded in 0.067 seconds

Sixty-seven milliseconds from my house. Two minutes of silence from a GitHub-hosted runner, three times in a row, then a clean success on a re-run an hour later with zero changes.

That pattern points somewhere specific. GitHub’s hosted runners live in Azure address space, which is scanned for SSH brute-force attempts around the clock, and shared hosts commonly subscribe to reputation blocklists that catch those ranges.

I want to be careful about how hard I lean on that. I can’t see Hostinger’s firewall, and I’m not going to assert a cause I can’t verify — that’s the exact mistake this post is about. What I can say is what I measured: connections from GitHub-hosted runners were silently dropped, repeatedly and then not, while the same port answered my laptop instantly. A blocklist is the explanation that fits. It isn’t one I can prove from out here.

Why None of My Fixes Would Have Worked

This is the part worth sitting with.

Every fix I’d reached for addressed the transfer. The transfer never happened. The failure is at dial — before the SSH handshake, before authentication, before a single byte of payload moves. rsync, lftp, incremental sync, and streaming all need the same TCP connection that never opened. Every one of them fails identically, just with different error text.

Shrinking the payload does nothing, because 7.7 MB was never the problem. Splitting into per-file transfers makes it worse — 39 connection attempts instead of two, each another chance to hit the drop.

And the retries. Three attempts, a minute apart, all inside one job — which means all on the same runner, which means the same source IP, which means the same block. They didn’t fail three times for three reasons. They failed once, three times. That’s why a re-run an hour later succeeded on the first try: new runner, new address.

I’d written retry logic that was structurally incapable of retrying the thing that was broken.

What I’d Take From This

I’d already patched around this twice. There’s a commit in that repo titled “retry the SCP upload and verify the deploy landed” — earlier me, hardening a path against a failure earlier me hadn’t diagnosed. Each patch was reasonable. None of them touched the cause, because I never established one. I’d been treating the symptom as the specification.

The site is on Cloudflare Pages now. That decision took ten minutes once I understood the actual failure, because the honest question stopped being “how do I make this transfer more reliable” and became “why am I opening an SSH connection from a machine I don’t control to a host whose firewall I can’t see, to publish a folder of static files.”

None of this makes Hostinger a bad host. I’ve been a customer for six years. There’s no defect here I can point at — there’s a mismatch. Shared hosting has to be aggressive about SSH abuse, and a CI runner sitting in Azure address space looks exactly like abuse. I was asking one system to extend trust that another system is built to withhold, and both were behaving correctly. The fix wasn’t a better host. It was noticing that I’d been reaching for a shell account to do a job that never required one.

But the transferable part isn’t the hosting. It’s this: I built a two-minute story out of a log line that was only telling me a step had started. The timestamps were right there. The second data point that proved it was in a run I’d already looked at.

When something takes an oddly round amount of time — 120 seconds, 30 seconds, 5 minutes — that’s rarely work. Work takes irregular amounts of time. Round numbers are configuration. A gap that resizes itself when you change a timeout setting is a timeout, and it’s telling you nothing happened at all.

Raul C. Peña

Raul C. Peña

Senior Software Engineer at Dell Technologies. Air Force veteran, 20+ years as a Texas real estate broker, self-taught coder. Passionate about DevOps, IBM Vault (formerly HashiCorp Vault), and building things that matter.