The failure
HTTPS access to Gitea worked, but cloning over SSH from my Windows machine failed:
ssh: connect to host code.minhdb.io 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@code.minhdb.io: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 2221/tcp comment 'Gitea SSH'
sudo ufw reload
sudo ufw status numbered
For a private service, the source could instead be restricted to a trusted public IP. That improves exposure but requires updating the rule whenever the client address changes.
Layer 4: Does the cloud firewall allow it?
DigitalOcean’s Cloud Firewall is independent of UFW. Port 2221 also needed an inbound TCP rule attached to the correct Droplet.
At this point, the client test still failed:
Test-NetConnection code.minhdb.io -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 Droplet. The missing SYN-ACK moved the investigation back to host forwarding and firewall behavior.
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.
- 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.