The failure
HTTPS access to Gitea worked, but cloning over SSH from my Windows machine failed:
ssh: connect to host git.example.com port 2221: Network is unreachable
The repository was configured to expose the container's SSH server on host port 2221:
ports:
- "2221:22"
The correct clone form was therefore:
ssh://git@git.example.com:2221/owner/repository.git
The key to debugging this was to test each network layer independently.
Layer 1: Is Gitea's SSH server healthy?
On the server, I tested the published port through loopback:
ssh -T -p 2221 git@127.0.0.1
Gitea answered and identified my account. This proved:
- Gitea's internal SSH server was running.
- My key was recognized.
- Docker's local port forwarding worked.
It also separated authentication from connectivity. A Permission denied (publickey) response reaches an SSH server; a timeout or unreachable error does not.
Layer 2: Is Docker publishing the port?
I checked the Compose service, Docker mapping, and host listener:
docker compose ps server
docker port gitea
sudo ss -lntp | grep ':2221'
The expected evidence was:
0.0.0.0:2221->22/tcp
[::]:2221->22/tcp
and a docker-proxy listener on both IPv4 and IPv6.
Layer 3: Does the host firewall allow it?
UFW initially allowed SSH, HTTP, and HTTPS, but the custom Gitea SSH port needed its own inbound rule:
sudo ufw allow from 203.0.113.10 to any port 2221 proto tcp \
comment 'Gitea SSH from trusted client'
sudo ufw status numbered
203.0.113.10 is a documentation address and must be replaced with the trusted client's current public IP. Source restriction reduces exposure but requires updating the rule when the client address changes. If SSH cloning is intentionally public, an unrestricted rule may be appropriate, but it should be a conscious decision rather than the troubleshooting default.
Firewall changes are state-changing operations, so I list the numbered rules before adding or deleting anything and confirm that the host's administrative SSH port remains allowed.
Layer 4: Does the cloud firewall allow it?
The cloud provider's firewall is independent of UFW. Port 2221 also needed an inbound TCP rule attached to the correct server, preferably with the same source restriction.
At this point, the client test still failed:
Test-NetConnection git.example.com -Port 2221
Instead of guessing, I watched the server interface:
sudo tcpdump -ni any 'tcp port 2221'
The capture showed repeated incoming SYN packets from my client:
client.high-port > server.2221: Flags [S]
That was decisive evidence. DNS resolution and the external route were working, and the cloud firewall was delivering packets to the server. The missing SYN-ACK moved the investigation back to host forwarding and firewall behavior.
An unsuccessful ICMP ping would not change that conclusion. Many hosts and firewalls drop ping while permitting TCP, so TcpTestSucceeded and the packet capture were the relevant evidence.
A reusable diagnostic order
The final order is useful for any published container port:
- Test the application inside or through loopback.
- Confirm the container port mapping.
- Confirm a host process is listening.
- Check the host firewall.
- Check the cloud firewall.
- Capture packets on the server.
- Test authentication only after TCP connectivity succeeds.
Each check answers one question and shrinks the search space. Changing every firewall at once may eventually work, but it destroys the evidence needed to understand why.
Security considerations
Publishing Gitea SSH does not grant shell access by itself. Gitea accepts the key, maps it to an application user, and restricts the available Git commands. Still, the public port should be maintained like any other Internet-facing service:
- Keep Gitea current.
- Remove unknown user and deploy keys.
- Prefer key-based authentication and disable password authentication where applicable.
- Restrict source addresses if practical.
- Monitor authentication and firewall logs.
- Do not confuse Gitea's
gitidentity with a normal interactive host account.
The most valuable tool in this incident was not a firewall command; it was the ability to prove exactly how far a packet traveled. This article intentionally uses reserved example addresses and hostnames and does not publish the server IP, client IP, SSH fingerprints, or key material.