# Running a Private Docker Registry Behind a Tunnel with TCP Passthrough

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`](http://registry.nyzex.in) (with port `5000`) as our private registry.

## **1\. Environment Overview**

* ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1762805282942/647d76d9-3002-440c-adb4-c5b24b829ca6.png align="center")
    
    **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`](http://registry.nyzex.in:5000) even though the VM is behind a tunnel.
    

---

## **2\. Sample Flask App**

We will use a minimal Flask app for demonstration.

**File:** [`app.py`](http://app.py)

```yaml
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`

```yaml
# 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:

```yaml
docker run -d --restart=always --name registry \
  -p 5000:5000 \
  -v /data/registry:/var/lib/registry \
  registry:2
```

Check the container:

```yaml
docker ps
```

You should see something like:

```yaml
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`](http://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](https://blog.nyzex.in/self-hosting-pangolin-newt-on-your-own-server)

---

## **6\. Build and Tag the Flask App Image**

Build the image:

```yaml
docker build -t sample-app:latest .
```

Tag it for your private registry:

```yaml
docker tag sample-app:latest registry.nyzex.in:5000/sample-app:latest
```

---

## **7\. Push Image to Private Registry**

Push the tagged image:

```yaml
docker push registry.nyzex.in:5000/sample-app:latest
```

Expected output:

```yaml
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:

```yaml
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`

```yaml
{
  "insecure-registries": ["registry.nyzex.in:5000"]
}
```

On adding this and restarting docker shall fix our issue.

---

## **8\. Verify the Registry**

Check which repositories exist:

```yaml
curl http://registry.nyzex.in:5000/v2/_catalog
```

Output:

```yaml
{"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:

```yaml
docker pull registry.nyzex.in:5000/sample-app:latest
```

Run it:

```yaml
docker run -d -p 5001:5000 registry.nyzex.in:5000/sample-app:latest
```

Visit [`http://localhost:5001/`](http://localhost:5001/) in a browser:

```yaml
Hello from private Docker registry demo!
```

---

## **10\. Notes and Best Practices**

1. **TCP Passthrough:** Necessary when the registry is behind HTTP proxy/tunnel. HTTP/HTTPS termination can break Docker push/pull operations.
    
2. **Port 5000:** Required by default, but you can expose it via any external port using the tunnel.
    
3. **SSL Certificates:** Ideally, you should secure the registry using TLS. If using HTTP/HTTPS proxy fails, TCP passthrough is the simpler workaround.
    
4. **Persistent Storage:** Mount `/var/lib/registry` to preserve your images even if the container is recreated.
    
5. **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!
