Public SSH through a relay fails at two unrelated boundaries, and fixing one usually reveals the other. The edge either returns an HTTP response instead of carrying a stream, or the relay prepends a header the backend never agreed to read. Three conditions decide which of those you are looking at: what the public record actually points to, what the stream block sends upstream, and which name the SSH client verifies. Work them in that order.

Two boundaries, two symptoms

A failure at the edge looks like an HTTP status page arriving where an SSH banner should be. The connection completes at the TCP level and the first bytes are text that belongs to a web server. That is a protocol mismatch, not a dropped packet. It also tells you the edge is deliberately serving something else for that name.

A failure at the upstream looks different. The connection reaches sshd, sshd reads bytes that are not the identification exchange it expects, and the session dies or hangs without ever offering a key.

Those two are easy to confuse because both produce a client that connects and then fails. The first protocol response you receive is what tells them apart, which is why it belongs in the ticket.

The PROXY header is a message, not a toggle

In the stream module, proxy_protocol on instructs NGINX to write a PROXY protocol header toward the upstream before the payload. It does not enable TCP proxying, and it has nothing to do with SSH encryption. It is a note addressed to the receiver. If the receiver cannot read it, it becomes debris at the start of the stream.

The receiving service has to consume that header deliberately. A normal sshd expects the SSH identification exchange as the first bytes on the connection, so an unexpected preamble breaks the handshake in a way that looks like a corrupt server.

stream {
    server {
        listen 2222;
        proxy_pass 10.77.0.20:22;
    }
}

That fragment is the whole of a working relay for a backend reachable across the VPN. No PROXY directive appears because the receiver does not speak the protocol, and that absence is a decision rather than an omission.

Also separate the two directions. Setting proxy_protocol on the listen line means NGINX expects its own client to send the header first; enabling it for an ordinary SSH client produces exactly the reverse of the problem people are usually trying to solve.

What the public hostname actually reaches

An HTTP 400 page in an SSH session is evidence about the edge. Something in the path speaks HTTP, and no amount of key or account work at the backend will change that. Stop changing the backend until the edge is settled.

Cloudflare's ordinary proxied record carries its supported HTTP and HTTPS traffic on defined ports. Turning the proxy on for a hostname does not create a general TCP relay, and moving SSH to port 443 does not make it HTTPS.

Two workable directions exist. A DNS-only record can point at your own TCP relay, which puts the exposure decision in your hands. Alternatively, use a product that explicitly documents SSH carriage, with its own origin, client and access configuration.

Whichever you choose, the relay still needs a VPN route to the backend, a permitted connection and a return path. The route is not optional because the public side works.

Keep the SSH identity stable through an alias

Users usually want to keep typing their existing hostname. An SSH config alias can do that without changing anything about the relay, and it keeps the change on the client side where it belongs.

Host git.example.com
    HostName ssh-entry.example.net
    Port 2222
    User git
    HostKeyAlias git.example.com

Without HostKeyAlias, OpenSSH looks up the host key under the name it connects to, so the entry moves and every user sees a changed-key warning on first use. Setting the alias keeps known-hosts entries stable while the connection still goes to the relay.

This alias applies to SSH-based remotes only. An HTTPS Git URL does not consult it, and the typical remote URL must match the transport you actually provisioned.

What you must not do is disable host key verification to make the warning go away. That removes a protection the forwarding repair never asked you to touch, and it converts a cosmetic annoyance into a real exposure.

Verify each hop separately

Test the public listener on its own, then the relay-to-backend connection from the relay host, and only then the client end to end. Each of those three tests rules out a different layer. Skipping one means the remaining two results cannot be interpreted. Three observations, three places a failure can live.

Keep the destination address, the port and the first protocol bytes with each result. That trio distinguishes an edge error from a VPN routing problem from an SSH authentication failure, and it is the difference between a fix and a guess.

Finally, document the intended exposure. A TCP relay that carries SSH to a private backend is a deliberate opening in the public perimeter, and it deserves the same review as any other inbound service.

সম্পৰ্কিত প্ৰবন্ধ