Expose GitLab Registry via OPNsense Port Forwarding
Configure OPNsense to expose GitLab Container Registry directly while keeping GitLab UI behind Cloudflare Tunnel. Complete guide for self-hosted setups.
The Problem
You have GitLab self-hosted behind Cloudflare Tunnel, and everything works great—until you try to push Docker images to the Container Registry. Even if you log in to Docker successfully:
docker login registry.example.com:5443
Authenticating with existing credentials... [Username: your-username]
i Info → To login with a different account, run 'docker logout' followed by 'docker login'
Login Succeededand the push starts, layers begin uploading:
docker push registry.example.com:5443/myproject/myimage:latest
The push refers to repository [registry.example.com:5443/myproject/myimage]
5f70bf18a086: Preparing
a3b5c80a4eba: Preparing
7f18b442972b: Preparing
5f70bf18a086: Pushing [==============> ] 15.3MB/50.1MB
a3b5c80a4eba: Pushing [========================> ] 28.1MB/45.2MBThen it suddenly fails with:
unauthorized: authentication requiredWhy? Cloudflare Tunnel has limitations with large file uploads, chunked transfers, and long-running connections that Docker Registry requires.
The Solution: Hybrid Approach
Keep the best of both worlds:
- GitLab UI → Cloudflare Tunnel (secure, DDoS protection, no exposed ports)
- GitLab Registry → Direct port forwarding via OPNsense (reliable for large uploads)
Architecture Overview
┌─────────────────────────────────────────────────────────────────┐
│ INTERNET │
└─────────────────────────────────────────────────────────────────┘
│ │
│ gitlab.example.com │ registry.example.com
│ (Cloudflare Tunnel) │ (Direct - Port 5443)
▼ ▼
┌─────────────────────┐ ┌─────────────────────┐
│ Cloudflare Edge │ │ OPNsense WAN │
│ (Tunnel Endpoint) │ │ (Port Forward) │
└─────────────────────┘ └─────────────────────┘
│ │
│ │ NAT: 5443 → 192.168.100.22:5443
▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ LAN (192.168.100.0/24) │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ GitLab VM (192.168.100.22) │ │
│ │ │ │
│ │ ┌──────────────────┐ ┌──────────────────────┐ │ │
│ │ │ GitLab Web │ │ GitLab Registry │ │ │
│ │ │ (Port 80/443) │ │ (Port 5443) │ │ │
│ │ │ via Tunnel │ │ via Port Forward │ │ │
│ │ └──────────────────┘ └──────────────────────┘ │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘Prerequisites
Before starting, ensure you have:
- OPNsense firewall (any recent version)
- GitLab instance running on a VM (e.g., 192.168.100.22)
- Domain name with DNS management access
- SSL certificate (Let’s Encrypt or other)
- Basic understanding of NAT and port forwarding
Part 1: GitLab Configuration
Step 1: Configure GitLab for Separate Registry URL
Edit /etc/gitlab/gitlab.rb on your GitLab server:
# Main GitLab URL (via Cloudflare Tunnel)
external_url 'https://gitlab.example.com'
# Registry with separate URL (direct access)
registry_external_url 'https://registry.example.com:5443'
# Registry configuration
gitlab_rails['registry_enabled'] = true
gitlab_rails['registry_host'] = "registry.example.com"
gitlab_rails['registry_port'] = "5443"
gitlab_rails['registry_api_url'] = "http://127.0.0.1:5000"
# Registry nginx configuration
registry_nginx['enable'] = true
registry_nginx['listen_port'] = 5050
registry_nginx['listen_https'] = true
# SSL certificates for registry
registry_nginx['ssl_certificate'] = "/etc/gitlab/ssl/registry.example.com.crt"
registry_nginx['ssl_certificate_key'] = "/etc/gitlab/ssl/registry.example.com.key"
# Important: Allow registry to be accessed on different port
registry_nginx['proxy_set_headers'] = {
"Host" => "$http_host",
"X-Real-IP" => "$remote_addr",
"X-Forwarded-For" => "$proxy_add_x_forwarded_for",
"X-Forwarded-Proto" => "https",
"X-Forwarded-Ssl" => "on"
}Make sure to use the same domain in registry_external_url that you’ll configure in DNS and OPNsense. The SSL certificate must match this domain.
Step 2: Generate or Install SSL Certificate
You’ll need a valid SSL certificate for the registry domain. Here is the recommended approach with Let’s Encrypt:
sudo apt install certbot
sudo certbot certonly --standalone -d registry.example.comTroubleshooting: Port 80 Already in Use
You might encounter an error like this:
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Could not bind TCP port 80 because it is already in use by another process on
this system (such as a web server). Please stop the program in question and then
try again.This error occurs because the GitLab server is running on port 80 and is protected by Cloudflared. Stop it temporarily:
sudo gitlab-ctl stopThen wait for a few minutes and re-register the certificate:
sudo certbot certonly --standalone -d registry.example.com
Saving debug log to /var/log/letsencrypt/letsencrypt.log
Requesting a certificate for registry.example.com
Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/registry.example.com/fullchain.pem
Key is saved at: /etc/letsencrypt/live/registry.example.com/privkey.pem
This certificate expires on 2026-03-16.
These files will be updated when the certificate renews.
Certbot has set up a scheduled task to automatically renew this certificate in the background.
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
If you like Certbot, please consider supporting our work by:
* Donating to ISRG / Let's Encrypt: https://letsencrypt.org/donate
* Donating to EFF: https://eff.org/donate-le
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -# Copy to GitLab SSL directory
sudo mkdir -p /etc/gitlab/ssl
sudo cp /etc/letsencrypt/live/registry.example.com/fullchain.pem \
/etc/gitlab/ssl/registry.example.com.crt
sudo cp /etc/letsencrypt/live/registry.example.com/privkey.pem \
/etc/gitlab/ssl/registry.example.com.key
sudo chmod 600 /etc/gitlab/ssl/*Step 3: Reconfigure GitLab
sudo gitlab-ctl reconfigure
sudo gitlab-ctl restart
# Expected results
ok: run: alertmanager: (pid 69369) 1s
ok: run: gitaly: (pid 69385) 0s
ok: run: gitlab-exporter: (pid 69410) 0s
ok: run: gitlab-kas: (pid 69424) 1s
ok: run: gitlab-workhorse: (pid 69439) 0s
ok: run: logrotate: (pid 69457) 1s
ok: run: nginx: (pid 69466) 0s
ok: run: node-exporter: (pid 69479) 0s
ok: run: postgres-exporter: (pid 69485) 0s
ok: run: postgresql: (pid 69494) 0s
ok: run: prometheus: (pid 69496) 0s
ok: run: puma: (pid 69518) 1s
ok: run: redis: (pid 69523) 0s
ok: run: redis-exporter: (pid 69531) 1s
ok: run: registry: (pid 69540) 0s
ok: run: sidekiq: (pid 69567) 0sStep 4: Verify Registry is Running
# Check registry status
sudo gitlab-ctl status registry
# Check registry nginx status
sudo gitlab-ctl status nginx
# Verify registry is listening on port 5443
sudo ss -tlnp | grep 5443
# Should show: tcp LISTEN 0 511 0.0.0.0:5443 0.0.0.0:*
# Test registry locally
curl -k https://127.0.0.1:5443/v2/
# Should return: {"errors":[{"code":"UNAUTHORIZED","message":"authentication required","detail":null}]}
# This is GOOD - registry is working, just requires authenticationStep 5: Configure GitLab VM Firewall (CRITICAL)
IMPORTANT: Even though GitLab is listening on port 5443, the OS firewall may be blocking external access. You must allow port 5443 through the firewall.
This step is often overlooked but is required for the registry to be accessible from outside the VM. Without this, you’ll get connection timeouts even from within your LAN.
Using UFW (Ubuntu/Debian)
# Check if UFW is active
sudo ufw status
# Allow port 5443 from anywhere
sudo ufw allow 5443/tcp
# Or restrict to your LAN only (more secure)
sudo ufw allow from 192.168.100.0/24 to any port 5443 proto tcp
# Reload firewall
sudo ufw reload
# Verify the rule was added
sudo ufw status numberedStep 6: Test from LAN
After configuring the firewall, test from another machine on your network:
# Test port connectivity (from your local machine, NOT the GitLab VM)
nc -zv 192.168.100.22 5443
# Should return: Connection to 192.168.100.22 port 5443 [tcp/*] succeeded!
# Test HTTPS (from your local machine)
curl -k https://192.168.100.22:5443/v2/
# Should return: {"errors":[{"code":"UNAUTHORIZED",...}]}If nc -zv times out, the firewall is still blocking the port. Double-check your firewall configuration and ensure the rule is active.
Part 2: OPNsense Configuration
Now we’ll configure OPNsense to forward incoming traffic on port 5443 to your GitLab VM. This involves three steps:
- Port forwarding - Redirect WAN traffic to your GitLab VM
- Firewall rule - Allow the traffic through
- NAT reflection - Enable internal clients to use the same domain
Step 1: Create Port Forward Rule
What is Port Forwarding? Port forwarding (also called NAT port mapping) tells your OPNsense firewall to redirect incoming traffic from a specific external port to an internal IP address and port. This is essential for making services behind your firewall accessible from the internet.
For our setup: We’re telling OPNsense: “When traffic arrives on WAN port 5443, forward it to 192.168.100.22:5443 (our GitLab VM).”
- Navigate to Firewall → NAT → Port Forward
- Click + Add to create a new rule
Configure the rule with these settings:
| Setting | Value | Explanation |
|---|---|---|
| Interface | WAN | Traffic coming from the internet |
| Protocol | TCP | Docker Registry uses TCP protocol |
| Destination | WAN address | Your router’s public IP |
| Destination port range | From: 5443, To: 5443 | External port clients connect to |
| Redirect target IP | 192.168.100.22 | Your GitLab VM’s internal IP |
| Redirect target port | 5443 | GitLab registry_nginx listening port |
| Description | GitLab Container Registry | Helps identify this rule later |
| NAT reflection | Enable | Allows LAN clients to use public DNS name |
| Filter rule association | Add associated filter rule | Auto-creates firewall rule to allow traffic |
Click Save and then Apply Changes.
What happens after this?
- Traffic flow:
Internet → Your Public IP:5443 → OPNsense → 192.168.100.22:5443 → GitLab Registry - OPNsense automatically creates a corresponding firewall rule (if you selected “Add associated filter rule”)
- The NAT reflection setting allows your internal devices to use
registry.example.com:5443instead of the internal IP
Port Mapping Flow: External traffic arrives at WAN port 5443 → forwards to GitLab VM’s registry_nginx on port 5443 → which proxies internally to the registry backend on 127.0.0.1:5000.

Using port 5443 avoids conflicts with other services that might be using standard port 443 on your WAN. This is the recommended approach for dedicated registry access.
Step 2: Create/Verify Firewall Rule
Why is this needed? A port forward alone doesn’t allow traffic through—you also need a firewall rule that explicitly permits the traffic. Think of it as: port forwarding says “where to send traffic,” and the firewall rule says “this traffic is allowed.”
Check first: If you selected “Add associated filter rule” in Step 1, OPNsense automatically created this rule for you. Verify by checking Firewall → Rules → WAN for a rule matching your port forward.
If the rule doesn’t exist, create it manually:
- Navigate to Firewall → Rules → WAN
- Click + Add (add rule at the top)
Configure with these settings:
| Setting | Value | Explanation |
|---|---|---|
| Action | Pass | Allow this traffic through the firewall |
| Interface | WAN | Apply rule to traffic coming from the internet |
| Direction | in | Only incoming traffic (not outgoing) |
| Protocol | TCP | Docker Registry uses TCP protocol |
| Source | Any | Accept connections from any IP (or restrict for security) |
| Destination | WAN address | Traffic destined for your router’s public IP |
| Destination port | 5443 | Only allow traffic to port 5443 (the registry port) |
| Description | Allow GitLab Registry | Helps identify this rule later |
Click Save and Apply Changes.
What happens after this?
- When traffic arrives on WAN port 5443, the firewall rule checks if it’s allowed
- If the rule matches (TCP to port 5443), traffic passes through
- Then the port forward rule redirects it to your GitLab VM (192.168.100.22:5443)
- Without this rule, the port forward would exist but all traffic would be blocked

Step 3: Configure NAT Reflection (Optional but Recommended)
What is NAT Reflection? NAT reflection (also called “hairpin NAT” or “NAT loopback”) allows devices on your internal network to access services using the same public DNS name that external clients use. Without it, internal clients trying to connect to registry.example.com:5443 might fail because they’re trying to reach your public IP from inside your network.
Why enable this? When an internal device connects to registry.example.com:5443:
- Without reflection: Connection attempts to your public IP from inside the LAN may fail
- With reflection: OPNsense recognizes it’s an internal client and applies the port forward properly
When to skip this: If you’ve configured DNS override (Step 4), you don’t strictly need NAT reflection because internal clients will resolve to the private IP directly. However, enabling both provides redundancy—if DNS override fails, reflection still works.
Configuration:
- Navigate to Firewall → Settings → Advanced
- Scroll to the Network Address Translation section
- Find Reflection for port forwards
- Set to Enable (or Pure NAT for better performance)
- Click Save

What happens after this?
- Internal clients can now access
registry.example.com:5443using the public DNS name - OPNsense detects the connection is from an internal IP and applies the port forward
- Traffic flows: Internal device → OPNsense (recognizes reflection) → 192.168.100.22:5443
- This works even if your DNS doesn’t have a local override configured
Alternative: You can also enable reflection per-rule in the port forward settings (Step 1), rather than globally. This gives you more granular control if you only want reflection for specific services.
Step 4: (Optional) Configure Local DNS Override
Why do this? By default, internal clients using registry.example.com will resolve to your public IP and go out through the WAN, then back through the port forward. This is inefficient. A DNS override makes internal clients connect directly to the GitLab VM’s internal IP (192.168.100.22).
Benefits:
- Faster access from your LAN (no WAN round-trip)
- Works even if WAN is down
- Reduces load on your WAN connection
Configuration:
- Navigate to Services → Unbound DNS → Overrides
- Click + Add to create a host override
- Configure the override:
| Setting | Value | Explanation |
|---|---|---|
| Host | registry | The subdomain part (before the domain) |
| Domain | example.com | Your actual domain name |
| Type | A (IPv4 address) | IPv4 address record |
| IP address | 192.168.100.22 | Your GitLab VM’s internal IP |
- Click Save and Apply

What happens after this?
- Internal clients: When devices on your LAN query
registry.example.com, OPNsense’s DNS returns192.168.100.22(the private IP) - External clients: Queries from the internet still resolve to your public IP via your regular DNS provider
- Traffic flow for internal clients: Device → 192.168.100.22:5443 directly (no WAN round-trip, no port forwarding needed)
- Result: Faster access, works offline, and more efficient use of network resources
Verification: From an internal device, run nslookup registry.example.com or dig registry.example.com. You should see 192.168.100.22 as the answer, not your public IP.
Part 3: DNS Configuration
Step 1: Create DNS Record
Add an A record for your registry domain pointing to your public IP:
| Type | Name | Value | TTL |
|---|---|---|---|
| A | registry | YOUR_PUBLIC_IP | 300 |
If using Cloudflare DNS (but NOT proxied):
- Go to Cloudflare DNS settings
- Add A record for
registry.example.com - Important: Set proxy status to DNS only (grey cloud)
- Orange cloud (proxied) = Goes through Cloudflare CDN
- Grey cloud (DNS only) = Direct connection

The registry DNS record MUST be “DNS only” (grey cloud), not proxied through Cloudflare. Docker Registry traffic cannot go through Cloudflare’s proxy.
Step 3: Verify DNS Resolution
# Check DNS resolution
nslookup registry.example.com
# Should return your public IP (or internal IP if using DNS override)
dig registry.example.com +shortPart 4: Testing the Setup
Step 1: Test from External Network
From a machine outside your network (or using mobile hotspot):
# Test HTTPS connectivity
curl -k https://registry.example.com:5443/v2/
# Should return: {"errors":[{"code":"UNAUTHORIZED","message":"authentication required","detail":null}]}
# This is expected - it means the registry is responding correctlyStep 2: Test Docker Login
# Login to registry
docker login registry.example.com:5443
# Enter your GitLab username and Personal Access TokenStep 3: Test Push/Pull
Now let’s test pushing a real Docker image to your registry:
# Pull Node.js image from Docker Hub
docker pull node:24.12.0-trixie-slimExpected output:
24.12.0-trixie-slim: Pulling from library/node
f626fba1463b: Pull complete
e669be0904f2: Pull complete
5918935f1407: Pull complete
2ee9d466d7a4: Pull complete
cb778f43df8b: Pull complete
Digest: sha256:9ad7e7db423b2ca7ddcc01568da872701ef6171505bd823978736247885c7eb4
Status: Downloaded newer image for node:24.12.0-trixie-slim
docker.io/library/node:24.12.0-trixie-slim# Tag for your GitLab registry
docker tag node:24.12.0-trixie-slim \
registry.example.com:5443/base/docker-images/node:24.12.0-trixie-slim# Push to your GitLab registry
docker push registry.example.com:5443/base/docker-images/node:24.12.0-trixie-slimExpected output:
The push refers to repository [registry.example.com:5443/base/docker-images/node]
65de0fe7aaa8: Pushed
300ab2bb9bfa: Pushed
5f1c02153132: Pushed
813c6273ce81: Pushed
742b5304df6e: Pushed
24.12.0-trixie-slim: digest: sha256:507fa69d79feec3c18afedfc4b7ab67d3fdd6f750631066ef34d4d5ea4595c04 size: 1367Success! If you see the “Pushed” messages and a digest, your image was successfully uploaded to your GitLab Container Registry.
Step 4: Verify in GitLab UI
Navigate to your project in GitLab:
- Go to Deploy → Container Registry
- You should see your
nodeimage with the tag24.12.0-trixie-slim

Step 5: Test Pulling from Registry
# Remove local image to test pull
docker rmi registry.example.com:5443/base/docker-images/node:24.12.0-trixie-slim
# Pull from your registry
docker pull registry.example.com:5443/base/docker-images/node:24.12.0-trixie-slim
# Use the image
docker run --rm registry.example.com:5443/base/docker-images/node:24.12.0-trixie-slim node --version
# Should output: v24.12.0Part 5: Security Considerations
Restrict Access by IP (Recommended for Production)
In OPNsense, modify the firewall rule to only allow specific IPs:
Create an Alias for allowed IPs:
- Firewall → Aliases
- Create alias:
registry_allowed_ips - Add your office IP, home IP, CI/CD runner IPs, etc.
Update firewall rule:
- Change Source from “Any” to the alias
Enable Rate Limiting
In OPNsense, you can add rate limiting to prevent abuse:
- Install os-nginx plugin (if not already installed)
- Configure rate limiting in front of the registry
Or use GitLab’s built-in rate limiting in /etc/gitlab/gitlab.rb:
# Rate limiting for registry
registry['rate_limiting_enabled'] = true
registry['rate_limiting_threshold'] = 100
registry['rate_limiting_expiry'] = 1.hourUse Fail2Ban
On the GitLab server, configure Fail2Ban to block repeated failed authentication attempts:
# Install fail2ban
sudo apt install fail2ban
# Create GitLab registry jail
sudo cat > /etc/fail2ban/jail.d/gitlab-registry.conf << 'EOF'
[gitlab-registry]
enabled = true
port = 5050
filter = gitlab-registry
logpath = /var/log/gitlab/registry/current
maxretry = 5
bantime = 3600
findtime = 600
EOF
# Create filter
sudo cat > /etc/fail2ban/filter.d/gitlab-registry.conf << 'EOF'
[Definition]
failregex = ^.*unauthorized.*client_addr=<HOST>.*$
ignoreregex =
EOF
# Restart fail2ban
sudo systemctl restart fail2banPart 6: Complete Configuration Summary
Final Architecture
| Component | URL | Path |
|---|---|---|
| GitLab UI | https://gitlab.example.com | Cloudflare Tunnel → GitLab (192.168.100.22) |
| GitLab Registry | https://registry.example.com:5443 | Internet → OPNsense (Port 5443) → GitLab (192.168.100.22:5443) |
GitLab Configuration (/etc/gitlab/gitlab.rb)
# External URL (via Cloudflare Tunnel)
external_url 'https://gitlab.example.com'
# Registry configuration (direct access)
registry_external_url 'https://registry.example.com:5443'
gitlab_rails['registry_enabled'] = true
gitlab_rails['registry_host'] = "registry.example.com"
gitlab_rails['registry_port'] = "5443"
# Registry nginx
registry_nginx['enable'] = true
registry_nginx['listen_port'] = 5443
registry_nginx['listen_https'] = true
registry_nginx['ssl_certificate'] = "/etc/gitlab/ssl/registry.example.com.crt"
registry_nginx['ssl_certificate_key'] = "/etc/gitlab/ssl/registry.example.com.key"OPNsense Port Forward
| Setting | Value |
|---|---|
| Interface | WAN |
| Protocol | TCP |
| Destination | WAN address |
| Destination port | 5443 |
| Redirect target IP | 192.168.100.22 |
| Redirect target port | 5443 |
DNS Configuration
| Type | Name | Value | Proxy |
|---|---|---|---|
| A | gitlab | (Cloudflare Tunnel IP) | Proxied (orange) |
| A | registry | YOUR_PUBLIC_IP | DNS only (grey) |
Conclusion
By separating GitLab UI and Container Registry traffic, you get the best of both worlds:
- GitLab UI: Protected by Cloudflare’s security features (DDoS protection, WAF, etc.)
- Container Registry: Direct access for reliable large file transfers
This hybrid approach solves the “unauthorized: authentication required” error that occurs when pushing Docker images through Cloudflare Tunnel while maintaining security for your GitLab instance.
Key Takeaways
- Cloudflare Tunnel isn’t suitable for Docker Registry due to upload limits and timeouts
- OPNsense port forwarding provides reliable direct access for registry traffic
- Use separate domains for GitLab UI and Registry
- SSL certificates are required for both endpoints
- Restrict registry access to known IPs when possible
References
Share this post
Found this helpful? Share it with your network!
