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 Succeeded

and 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.2MB

Then it suddenly fails with:

unauthorized: authentication required

Why? 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.com

Troubleshooting: 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 stop

Then 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) 0s

Step 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 authentication

Step 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 numbered

Step 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:

  1. Port forwarding - Redirect WAN traffic to your GitLab VM
  2. Firewall rule - Allow the traffic through
  3. 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).”

  1. Navigate to FirewallNATPort Forward
  2. Click + Add to create a new rule

Configure the rule with these settings:

SettingValueExplanation
InterfaceWANTraffic coming from the internet
ProtocolTCPDocker Registry uses TCP protocol
DestinationWAN addressYour router’s public IP
Destination port rangeFrom: 5443, To: 5443External port clients connect to
Redirect target IP192.168.100.22Your GitLab VM’s internal IP
Redirect target port5443GitLab registry_nginx listening port
DescriptionGitLab Container RegistryHelps identify this rule later
NAT reflectionEnableAllows LAN clients to use public DNS name
Filter rule associationAdd associated filter ruleAuto-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:5443 instead 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.

OPNsense forward nat port

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 FirewallRulesWAN for a rule matching your port forward.

If the rule doesn’t exist, create it manually:

  1. Navigate to FirewallRulesWAN
  2. Click + Add (add rule at the top)

Configure with these settings:

SettingValueExplanation
ActionPassAllow this traffic through the firewall
InterfaceWANApply rule to traffic coming from the internet
DirectioninOnly incoming traffic (not outgoing)
ProtocolTCPDocker Registry uses TCP protocol
SourceAnyAccept connections from any IP (or restrict for security)
DestinationWAN addressTraffic destined for your router’s public IP
Destination port5443Only allow traffic to port 5443 (the registry port)
DescriptionAllow GitLab RegistryHelps 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
OPNsense WAN Firewall Rule

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:

  1. Navigate to FirewallSettingsAdvanced
  2. Scroll to the Network Address Translation section
  3. Find Reflection for port forwards
  4. Set to Enable (or Pure NAT for better performance)
  5. Click Save
OPNsense NAT Reflection Settings

What happens after this?

  • Internal clients can now access registry.example.com:5443 using 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:

  1. Navigate to ServicesUnbound DNSOverrides
  2. Click + Add to create a host override
  3. Configure the override:
SettingValueExplanation
HostregistryThe subdomain part (before the domain)
Domainexample.comYour actual domain name
TypeA (IPv4 address)IPv4 address record
IP address192.168.100.22Your GitLab VM’s internal IP
  1. Click Save and Apply
OPNsense Unbound DNS Host Override Configuration

What happens after this?

  • Internal clients: When devices on your LAN query registry.example.com, OPNsense’s DNS returns 192.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:

TypeNameValueTTL
AregistryYOUR_PUBLIC_IP300

If using Cloudflare DNS (but NOT proxied):

  1. Go to Cloudflare DNS settings
  2. Add A record for registry.example.com
  3. Important: Set proxy status to DNS only (grey cloud)
    • Orange cloud (proxied) = Goes through Cloudflare CDN
    • Grey cloud (DNS only) = Direct connection
Cloudflare DNS

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 +short

Part 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 correctly

Step 2: Test Docker Login

# Login to registry
docker login registry.example.com:5443

# Enter your GitLab username and Personal Access Token

Step 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-slim

Expected 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-slim

Expected 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: 1367

Success! 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:

  1. Go to DeployContainer Registry
  2. You should see your node image with the tag 24.12.0-trixie-slim

GitLab Container Registry after successful push

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.0

Part 5: Security Considerations

In OPNsense, modify the firewall rule to only allow specific IPs:

  1. Create an Alias for allowed IPs:

    • FirewallAliases
    • Create alias: registry_allowed_ips
    • Add your office IP, home IP, CI/CD runner IPs, etc.
  2. Update firewall rule:

    • Change Source from “Any” to the alias

Enable Rate Limiting

In OPNsense, you can add rate limiting to prevent abuse:

  1. Install os-nginx plugin (if not already installed)
  2. 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.hour

Use 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 fail2ban

Part 6: Complete Configuration Summary

Final Architecture

ComponentURLPath
GitLab UIhttps://gitlab.example.comCloudflare Tunnel → GitLab (192.168.100.22)
GitLab Registryhttps://registry.example.com:5443Internet → 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

SettingValue
InterfaceWAN
ProtocolTCP
DestinationWAN address
Destination port5443
Redirect target IP192.168.100.22
Redirect target port5443

DNS Configuration

TypeNameValueProxy
Agitlab(Cloudflare Tunnel IP)Proxied (orange)
AregistryYOUR_PUBLIC_IPDNS 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

  1. Cloudflare Tunnel isn’t suitable for Docker Registry due to upload limits and timeouts
  2. OPNsense port forwarding provides reliable direct access for registry traffic
  3. Use separate domains for GitLab UI and Registry
  4. SSL certificates are required for both endpoints
  5. Restrict registry access to known IPs when possible

References