SSH Failing on Tencent Cloud CLB After GitLab Migration

I walked through debugging why SSH connections to a migrated GitLab instance were failing at the public CLB, and how adding a TCP listener on port 22 fixed it.

SSH Failing on Tencent Cloud CLB After GitLab Migration
Photo by Andrew Neel / Unsplash

We had just finished migrating a GitLab instance from AWS to Tencent Cloud. The server was up, the web UI loaded fine over HTTPS, and from the internal jumphost I could SSH into the CVM and clone repos without issues. Everything looked good.

Then I tried cloning a repo from my local machine using SSH. Nothing. Connection refused.

I ran a quick netcat check against the public CLB:

nc -v <public-clb-ip> 22
# connect to <public-clb-ip> port 22 (tcp) failed: Connection refused

Port 443 and 80 were fine. Port 22 was dead. The web UI worked, but git clone git@<gitlab.example.com>:org/repo.git was completely broken.


Ruling out the server

The first thing I did was rule out the server itself. I jumped onto the internal jumphost and tested SSH directly to the CVM's private IP. It worked fine, so the GitLab service and SSH daemon on the CVM were running correctly.

nc -zv <cvm-private-ip> 22
# Connection to <cvm-private-ip> 22 port [tcp/ssh] succeeded!

The problem was somewhere between the public internet and the CVM. That narrowed it down to the load balancers.

The architecture I was working with

Before I explain the fix, here is the setup. We run a multi-account Tencent Cloud environment with a dedicated network account that owns the public-facing resources. The devops account holds the internal workloads. The two accounts talk to each other through a CCN (Cloud Connect Network).

The flow for a user hitting the GitLab server looks like this:

Architecture diagram showing the traffic flow: User -> public CLB (network-core) -> CCN -> internal CLB (devops) -> gitlab-server CVM, with HTTPS on 443 and SSH on 22

So for HTTPS traffic, the path is: user hits the public CLB on port 443, TLS is terminated there with an SSL certificate, and the request is forwarded as plain HTTP to the internal CLB on port 80. The internal CLB then routes it to the CVM based on the host header.

For SSH, the path is the same structure but simpler: the public CLB receives TCP on port 22, forwards it to the internal CLB on port 22, which forwards it to the CVM on port 22. No TLS to worry about, just a raw TCP pass-through.

The first rehearsal was wrong

This was actually the second rehearsal of this migration. In the first one, I made a mistake that cost me some time.

I had created the CVM in a public subnet with a public IP, and I tested SSH by connecting directly to that public IP. It worked, so I thought the setup was correct. But the real architecture has the CVM behind a CLB in a private subnet, so my test was not testing the actual path users would take.

Looking back, this was a waste of time. I was testing a configuration that did not match what we would ship. The second rehearsal, I made sure the CVM was in a private subnet and all traffic went through the CLBs.

The missing listener

So here is what happened. The existing public CLB, which we used for other devops services, only had HTTP (80) and HTTPS (443) listeners configured. No TCP listener on port 22.

When I tried to SSH, the traffic hit the public CLB, found no listener on port 22, and dropped the connection. The internal CLB and the CVM were both fine. The problem was that the public CLB simply did not know what to do with TCP traffic on port 22.

The fix was to create a dedicated public CLB just for the GitLab server, with a TCP listener on port 22 added alongside the existing HTTP and HTTPS listeners. This way, SSH traffic would be forwarded to the internal CLB, which would then pass it to the CVM.

Here is the traffic flow after the fix:

HTTPS (web access and git clone over HTTPS):

HTTPS traffic flow: User to Public CLB on HTTPS 443, TLS terminated, forwarded as HTTP 80 via CCN to Internal CLB, then host-based routing to GitLab CVM on port 80

SSH (git clone and push):

SSH traffic flow: User to Public CLB on TCP 22, forwarded via CCN to Internal CLB on TCP 22, then to GitLab CVM on port 22

The key difference is that the new public CLB now has a TCP listener on port 22. Without it, the connection never reaches the internal CLB, let alone the CVM.


Validating the fix

After setting up the new CLB with the TCP listener, I ran connectivity tests from multiple points to make sure everything was wired correctly.

Port-by-port connectivity

First, I tested the old CLB to confirm the original problem:

for port in 22 80 443; do
  nc -zv <old-clb-hostname> $port
done

Port 22 timed out as expected. Ports 80 and 443 succeeded. This CLB was never meant to handle SSH, and it was not.

Then I tested the new dedicated CLB:

for port in 22 80 443; do
  nc -zv <new-clb-hostname> $port
done

All three ports succeeded. Port 22 was now being forwarded through the internal CLB to the CVM.

I also tested the CVM directly from the jumphost to confirm the baseline:

for port in 22 80 443; do
  nc -zv <cvm-private-ip> $port
done

Port 22 and 80 succeeded. Port 443 was refused, which is expected. The CVM serves GitLab over plain HTTP on port 80. TLS termination happens at the public CLB, so the CVM itself does not listen on 443.

End-to-end test from my machine

To test the full path without waiting for DNS propagation, I added an entry to /etc/hosts pointing the GitLab domain to the new public CLB's IP. I got the IP from a DNS lookup on the CLB hostname:

dig +short <new-clb-hostname>
# -> <public-clb-ip>

Then edited /etc/hosts:

<public-clb-ip>   <gitlab.example.com>

I ran a ping first. It showed 100% packet loss, but that is expected. ICMP is blocked by the security group and does not affect anything.

Then I tested HTTPS with curl:

curl -v https://<gitlab.example.com>

The TLS handshake succeeded (TLSv1.2, the certificate CN matched the domain and was valid). I got a 302 Found redirect to /users/sign_in. The server header was nginx, and GitLab-specific headers like X-Gitlab-Meta and X-Request-Id were present. The web path was working.

Finally, SSH:

ssh -T git@<gitlab.example.com>
# -> Welcome to GitLab, @<gitlab-username>!

The host key had changed from the old server, so I had to run ssh-keygen -R <gitlab.example.com> first. After that, the connection went through.

To confirm the data was actually coming from the migrated server and not the old production instance, I cloned a repo and checked git log:

git clone git@<gitlab.example.com>:org/repo.git
# -> Cloning into 'repo'... done (1781 objects, 563 KiB)

The latest commit was from July 16, while the production EC2 instance had commits up to July 19. That three-day gap confirmed the /etc/hosts override was routing to the migrated Tencent CVM with slightly stale data, not the production EC2 instance. The clone was hitting the right target.


Closing

The root cause was simple: the CLB lacked a TCP listener on port 22. But the real mistake was rushing into the first rehearsal without thinking through the architecture.

I tested the CVM directly with its public IP instead of testing through the CLB. That test passed, but it was testing the wrong thing. It gave me false confidence and wasted a whole rehearsal cycle. If I had mapped out the actual traffic path first, I would have caught the missing listener before the first test.