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
Search for a command to run...

DevOps & Cloud Engineer — building scalable, automated, and intelligent systems. Developer of sorts | Automator | Innovator
Artificial Intelligence has become one of the biggest talking points in software engineering. Every week, a new tool promises to automate deployments, fix production incidents, or replace developers a

You spin up a Kubernetes workload. You connect to PostgreSQL. You add sslmode=require because security matters. And then PostgreSQL replies with: server does not support SSL, but SSL was required Wai

When you first approach Tencent Cloud, it feels familiar if you come from AWS or Azure. There are VPCs, Kubernetes clusters, load balancers, object storage, IAM, everything you would expect. But once

When I first automated Docker builds and deployments, I thought the hard part would be writing the YAML. It was not. The real challenges were versioning, preventing accidental rollbacks, handling envi

For a long time, Docker Compose felt like the perfect solution. Simple YAML, fast local setup, predictable behavior. For a single service or even a small stack, it works beautifully. But at some point, reality catches up. As the application grew, tra...

CodeOps Studies
40 posts
Simple write-ups on day to day code or devops experiments, tests etc.
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.

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:5000 even though the VM is behind a tunnel.
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)
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"]
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
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
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
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.
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.
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!
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/registry to preserve your images even if the container is recreated.
Firewall: Make sure the VM allows traffic from the tunnel endpoint.
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!