Running a Private Docker Registry Behind a Tunnel with TCP Passthrough

DevOps & Cloud Engineer — building scalable, automated, and intelligent systems. Developer of sorts | Automator | Innovator
Managing Docker images privately is crucial for projects that need security, control, and faster deployments. In this tutorial, we’ll go through setting up a private Docker registry behind a remote tunnel , using a TCP passthrough for secure communication, and pushing/pulling Docker images with a sample Flask app.
We will use the domain registry.nyzex.in (with port 5000) as our private registry.
1. Environment Overview

VM Hosting Docker Registry: Behind a Pangolin tunnel, which exposes internal IPs externally.
Problem: Docker registry standard HTTP/HTTPS cannot easily work through HTTP proxy because Docker client uses HTTPS by default, and certificate management is complicated when going through a tunnel.
Solution: Use TCP passthrough on the tunnel. This allows Docker to talk directly to the registry over TCP without interference from HTTP-level proxies.
Result: You can tag/push images using
registry.nyzex.in:5000even though the VM is behind a tunnel.
2. Sample Flask App
We will use a minimal Flask app for demonstration.
File: app.py
from flask import Flask
app = Flask(__name__)
@app.route("/")
def hello():
return "Hello from private Docker registry demo!"
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
3. Dockerfile
Containerize the Flask app:
File: Dockerfile
# Use lightweight Python image
FROM python:3.11-slim
# Set working directory
WORKDIR /app
# Install dependencies
RUN pip install --no-cache-dir flask gunicorn
# Copy app into container
COPY app.py .
# Expose the port
EXPOSE 5000
# Run the app with Gunicorn
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app"]
4. Run the Private Docker Registry
On the VM behind the Pangolin tunnel:
docker run -d --restart=always --name registry \
-p 5000:5000 \
-v /data/registry:/var/lib/registry \
registry:2
Check the container:
docker ps
You should see something like:
CONTAINER ID IMAGE PORTS
d5fb2585a79e registry:2 0.0.0.0:5000->5000/tcp
5. Configure Pangolin Tunnel
Set Protocol: TCP for the domain
registry.nyzex.in.Forward external port 5000 to the VM internal IP/port (e.g.,
192.168.29.73:5000).Do not use HTTP/HTTPS proxy, as Docker cannot handle HTTPS termination through HTTP proxies easily.
This allows Docker to connect directly through TCP passthrough. I am still working on finding a way to enable HTTPs as I didnt want to registry to have :5000, it didn’t work even when I edited the daemon.json, so we shall get to it on a later post!
To learn how to setup a pangolin tunnel and a VM behind it, you can read my blog:
https://blog.nyzex.in/self-hosting-pangolin-newt-on-your-own-server
6. Build and Tag the Flask App Image
Build the image:
docker build -t sample-app:latest .
Tag it for your private registry:
docker tag sample-app:latest registry.nyzex.in:5000/sample-app:latest
7. Push Image to Private Registry
Push the tagged image:
docker push registry.nyzex.in:5000/sample-app:latest
Expected output:
The push refers to repository [registry.nyzex.in:5000/sample-app]
a93f0efed1fd: Pushed
ed6ad9f11d83: Pushed
...
latest: digest: sha256:2d7a49862a5ebe625225642f809f7f6fbb8593587e7f0861d5256e6640f46b1b size: 856
A possible output could be:
failed to do request: Head "https://registry.nyzex.in:5000/v2/sample-app/blobs/sha256:6e38218f441707f4e338d3de283e3dee507e47bb8818603c5f28d54f221306": http: server gave HTTP response to HTTPS client
In this case we have perform the edit as I mentioned on 5 , Under /etc/docker/daemon.json
{
"insecure-registries": ["registry.nyzex.in:5000"]
}
On adding this and restarting docker shall fix our issue.
8. Verify the Registry
Check which repositories exist:
curl http://registry.nyzex.in:5000/v2/_catalog
Output:
{"repositories":["sample-app"]}
This confirms that your image is stored in the private registry.
9. Pull and Run the Image
Pull the image on any host that can access the tunnel:
docker pull registry.nyzex.in:5000/sample-app:latest
Run it:
docker run -d -p 5001:5000 registry.nyzex.in:5000/sample-app:latest
Visit http://localhost:5001/ in a browser:
Hello from private Docker registry demo!
10. Notes and Best Practices
TCP Passthrough: Necessary when the registry is behind HTTP proxy/tunnel. HTTP/HTTPS termination can break Docker push/pull operations.
Port 5000: Required by default, but you can expose it via any external port using the tunnel.
SSL Certificates: Ideally, you should secure the registry using TLS. If using HTTP/HTTPS proxy fails, TCP passthrough is the simpler workaround.
Persistent Storage: Mount
/var/lib/registryto preserve your images even if the container is recreated.Firewall: Make sure the VM allows traffic from the tunnel endpoint.
11. Summary
You now have a private Docker registry running behind a Pangolin tunnel, able to push and pull images securely over TCP passthrough, using a simple Flask app as a demo.
This setup is ideal for internal image hosting, CI/CD pipelines, or secure Docker environments where Docker Hub might not be suitable. We are yet to enable authentication for this, but this was a fun practice!






