Expose GitLab Registry via OPNsense Port Forwarding

Expose GitLab Registry via OPNsense Port Forwarding

· 14 min read
gitlab-for-your-team

The Problem

Self-hosted GitLab behind Cloudflare Tunnel breaks Docker image pushes. Login works fine:

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

Layers start 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 dies:

unauthorized: authentication required

Cloudflare Tunnel chokes on Docker Registry’s chunked uploads and long-lived connections.

The Solution: Hybrid Approach

Split the traffic:

  • GitLab UI → Cloudflare Tunnel (DDoS protection, no exposed ports)
  • GitLab Registry → Direct port forwarding via OPNsense (handles 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

You need:

  • OPNsense firewall (any recent version)
  • GitLab instance on a VM (e.g., 192.168.100.22)
  • Domain with DNS management access
  • SSL certificate (Let’s Encrypt or other)
  • Basic NAT/port forwarding knowledge

Part 1: GitLab Configuration

Step 1: Configure GitLab for Separate Registry URL

Edit /etc/gitlab/gitlab.rb:

# 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

Get a cert for the registry domain:

sudo apt install certbot

sudo certbot certonly --standalone -d registry.example.com

Troubleshooting: Port 80 Already in Use

You might see:

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

GitLab holds port 80. Stop it first:

sudo gitlab-ctl stop

Wait a minute, retry:

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)

Your VM’s OS firewall may block port 5443 even though GitLab is listening. Open it.

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

Run these from another machine on your network, not the GitLab VM:

# 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

Three things: port forwarding, firewall rule, and NAT reflection.

Step 1: Create Port Forward Rule

This redirects WAN traffic on port 5443 to your GitLab VM.

  1. Go to FirewallNATPort Forward
  2. Click + Add
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 then Apply Changes.

Flow: Internet → Public IP:5443 → OPNsense → 192.168.100.22:5443 → GitLab Registry

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

A port forward alone won’t pass traffic. You need a firewall rule too.

If you selected “Add associated filter rule” in Step 1, it already exists. Check FirewallRulesWAN.

Otherwise, create it:

  1. Go to FirewallRulesWAN
  2. Click + Add
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.

Without this rule, traffic gets dropped silently.

OPNsense WAN Firewall Rule

NAT reflection (hairpin NAT) lets LAN devices reach the registry using the public DNS name instead of the internal IP.

Without it, LAN clients hitting registry.example.com:5443 try to reach your public IP from inside the network, which fails.

DNS override (Step 4) solves the same problem. Enabling both gives redundancy.

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

You can also enable reflection per-rule instead of globally.

Step 4: (Optional) Configure Local DNS Override

A DNS override points LAN clients directly at 192.168.100.22 instead of routing through the WAN.

Faster, works when WAN is down, less load on the router.

  1. Go to ServicesUnbound DNSOverrides
  2. Click + Add
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

LAN devices now resolve registry.example.com to 192.168.100.22 directly. External clients still hit your public IP.

Verify with nslookup registry.example.com from an internal device. You should see 192.168.100.22.

Part 3: DNS Configuration

Step 1: Create DNS Record

Add an A record for registry pointing to your public IP:

TypeNameValueTTL
AregistryYOUR_PUBLIC_IP300

On Cloudflare, set proxy status to DNS only (grey cloud). Docker Registry traffic cannot go through Cloudflare’s proxy.

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 outside your network (mobile hotspot works):

# 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

Push a real image:

# Pull Node.js image from Docker Hub
docker pull node:24.12.0-trixie-slim
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
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

  1. Go to DeployContainer Registry
  2. Your node image with tag 24.12.0-trixie-slim should appear
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

  1. Create an Alias under FirewallAliases named registry_allowed_ips. Add your office, home, and CI/CD runner IPs.
  2. Update the WAN firewall rule: change Source from “Any” to the alias.

Enable Rate Limiting

Use OPNsense’s os-nginx plugin or 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

Block repeated failed auth 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

This hybrid setup keeps Cloudflare’s DDoS/WAF protection on the GitLab UI while giving Docker Registry the direct connection it needs for large uploads.

It fixes the “unauthorized: authentication required” error caused by pushing through Cloudflare Tunnel.

Key Takeaways

  1. Cloudflare Tunnel can’t handle Docker Registry — upload limits and timeouts break pushes
  2. OPNsense port forwarding gives 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