GitLab ships with its own bundled Nginx as part of the Omnibus package, which handles most people’s needs out of the box. But there are plenty of situations where you want an external Nginx instance sitting in front of GitLab instead — maybe you’re running GitLab behind a shared reverse proxy alongside other internal services, you want centralized TLS termination and logging across multiple apps, or you’re running GitLab in Docker/Kubernetes without the bundled Nginx. This guide covers exactly that: configuring a standalone, external Nginx as a reverse proxy in front of GitLab, whether GitLab itself is running via Omnibus with its internal Nginx disabled, or via Docker.
How This Fits Together
GitLab is not a simple single-process web app — it’s a collection of services (Puma/Unicorn for the Rails app, GitLab Workhorse for handling large file operations like Git pushes/pulls and file uploads, and optionally GitLab Pages, Container Registry, and Mattermost, each with their own ports). When you put an external Nginx in front of GitLab, you’re proxying to GitLab Workhorse, which itself sits in front of the Rails application — this is important, because proxying directly to the Rails app instead of Workhorse breaks large file uploads and Git LFS.
The setup looks like this:
Browser/Git client → External Nginx (your reverse proxy) → GitLab Workhorse (port 8181 or similar) → Puma/Rails app
Requirements
- A working GitLab instance (Omnibus, Docker, or Helm-based) already installed and reachable on an internal port.
- A separate server or the same server running your external Nginx instance (Nginx 1.18+ recommended).
- A domain name pointed at your Nginx server’s public IP.
- SSL certificates (Let’s Encrypt via Certbot works well here).
- If using Omnibus GitLab, you’ll disable its bundled Nginx so it doesn’t conflict with your external one.
Step 1: Disable GitLab’s Bundled Nginx (Omnibus Only)
If you’re running Omnibus GitLab and want your external Nginx to handle everything, edit /etc/gitlab/gitlab.rb:
nginx['enable'] = false
gitlab_rails['gitlab_shell_ssh_port'] = 2222
Then have GitLab listen on an internal port instead, still in gitlab.rb:
gitlab_workhorse['listen_network'] = "tcp"
gitlab_workhorse['listen_addr'] = "127.0.0.1:8181"
Reconfigure GitLab to apply changes:
sudo gitlab-ctl reconfigure
If you’re running GitLab via Docker, you’d instead publish Workhorse’s port to the host or an internal network your Nginx can reach, e.g. -p 127.0.0.1:8181:80 in your docker run or docker-compose.yml.
Step 2: Install Nginx on the Proxy Server
sudo apt update
sudo apt install nginx -y
Step 3: Configure the Reverse Proxy
Create /etc/nginx/sites-available/gitlab.conf:
upstream gitlab_workhorse {
server 127.0.0.1:8181;
}
server {
listen 80;
server_name gitlab.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name gitlab.example.com;
ssl_certificate /etc/letsencrypt/live/gitlab.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/gitlab.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 500m;
access_log /var/log/nginx/gitlab_access.log;
error_log /var/log/nginx/gitlab_error.log;
location / {
proxy_pass http://gitlab_workhorse;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 300s;
proxy_connect_timeout 300s;
proxy_redirect off;
}
}
A few details worth calling out:
client_max_body_size 500m;— GitLab pushes and file uploads can be large; the default Nginx limit (1MB) will break Git operations and MR attachments almost immediately if left unset.proxy_http_version 1.1;withUpgrade/Connectionheaders — needed for GitLab’s real-time features (Action Cable / WebSocket connections used for live updates in the UI).proxy_read_timeout 300s;— long-running operations like large pushes, CI artifact uploads, or slow clones need generous timeouts; the Nginx default (60s) is too short for GitLab’s typical workload.X-Forwarded-Proto https;— critical. Without this, GitLab doesn’t know the original request was HTTPS and can generate broken (http://) links throughout the UI.
Step 4: Tell GitLab Its External URL
Back on the GitLab server, set the external URL to match what users actually connect to, in /etc/gitlab/gitlab.rb:
external_url 'https://gitlab.example.com'
Then reconfigure:
sudo gitlab-ctl reconfigure
This matters because GitLab generates absolute URLs (in emails, webhooks, clone instructions) based on this setting — if it’s wrong, everything technically works through the proxy but the links GitLab generates for users will be broken.
Step 5: Handle Git Over SSH Separately
Reverse proxying HTTP/HTTPS traffic doesn’t touch SSH-based Git operations (git clone git@gitlab.example.com:...), since SSH isn’t HTTP and Nginx doesn’t proxy it in the typical sense. Make sure:
- Port 22 (or your configured
gitlab_shell_ssh_port) is open directly to the GitLab server, not routed through Nginx. - If your external Nginx server and GitLab server are different machines, users need to SSH directly to the GitLab server’s IP/hostname on the SSH port, or you’ll need a TCP-level proxy (Nginx’s
streammodule, nothttp) to forward SSH traffic:
stream {
upstream gitlab_ssh {
server gitlab-internal.example.com:22;
}
server {
listen 22;
proxy_pass gitlab_ssh;
proxy_timeout 300s;
}
}
This goes in a separate top-level stream block in nginx.conf, not inside http.
Step 6: Test and Reload
sudo nginx -t
sudo systemctl reload nginx
Enable the site if using the sites-available/sites-enabled pattern:
sudo ln -s /etc/nginx/sites-available/gitlab.conf /etc/nginx/sites-enabled/
sudo systemctl reload nginx
Step 7: Verify Everything Works
Web UI: Visit https://gitlab.example.com and confirm login works and pages load without mixed-content warnings (a sign X-Forwarded-Proto isn’t set correctly).
Git over HTTPS:
git clone https://gitlab.example.com/group/project.git
Large file push (tests client_max_body_size and timeouts):
git clone https://gitlab.example.com/group/project.git
cd project
dd if=/dev/urandom of=largefile.bin bs=1M count=100
git add largefile.bin
git commit -m "test large file"
git push
CI/CD pipelines: Trigger a pipeline and confirm the runner can reach the GitLab instance and artifacts upload successfully — this is often where client_max_body_size and timeout issues first surface in production.
Troubleshooting Common Issues
502 Bad Gateway. GitLab Workhorse isn’t running or isn’t listening where Nginx expects. Check sudo gitlab-ctl status on the GitLab server, and confirm the listen_addr in gitlab.rb matches the upstream block in your Nginx config.
Large pushes fail with “413 Request Entity Too Large.” client_max_body_size isn’t set high enough, or isn’t set on the specific server/location block actually handling the request.
Broken links, mixed content warnings, or infinite redirect loops. X-Forwarded-Proto https; is missing, or GitLab’s external_url doesn’t match what’s actually being served.
Real-time UI features (job logs streaming, notification badges) not updating. WebSocket upgrade headers (Upgrade, Connection: upgrade) are missing from the proxy config.
CI jobs time out uploading artifacts. proxy_read_timeout and proxy_connect_timeout are too short for your artifact sizes — increase them, and check GitLab’s own artifact size limits too.
SSH clone works but HTTPS doesn’t (or vice versa). These are two entirely separate paths — confirm you’re testing and troubleshooting the correct one; an HTTPS fix won’t affect SSH-based Git operations.
Security Considerations
- Terminate TLS at your external Nginx and keep the connection between Nginx and GitLab Workhorse on a private network or localhost — don’t expose Workhorse’s HTTP port publicly.
- Restrict access to GitLab’s internal ports (Workhorse, Puma, Registry) via firewall rules so only your Nginx proxy (and no one else) can reach them.
- Keep
client_max_body_sizereasonable for your actual needs rather than setting it unnecessarily huge, to reduce the blast radius of abuse via oversized upload attempts. - Apply rate limiting on login and API endpoints specifically, since GitLab instances are a common target for credential stuffing:
limit_req_zone $binary_remote_addr zone=gitlab_login:10m rate=5r/m;
location /users/sign_in {
limit_req zone=gitlab_login burst=3 nodelay;
proxy_pass http://gitlab_workhorse;
}
- Keep both GitLab and Nginx patched — GitLab in particular has had a number of high-severity CVEs over the years, and staying current matters more here than on most services.
Performance Tips
- Enable HTTP/2 (
listen 443 ssl http2;) — GitLab’s UI makes many concurrent requests for a single page load, and HTTP/2 multiplexing noticeably improves perceived load time. - Increase
worker_connectionson high-traffic instances, and tunekeepalivebetween Nginx and the Workhorse upstream:
upstream gitlab_workhorse {
server 127.0.0.1:8181;
keepalive 32;
}
- If GitLab Pages or Container Registry are also in use, they typically run on separate ports/subdomains and need their own
serverblocks — don’t try to cram them into the main GitLab location block. - Monitor Nginx access logs for slow endpoints (
$request_timein your log format) to catch performance regressions before they become user complaints.
Real-World Use Cases
- A company running several internal tools behind a single shared reverse proxy adds GitLab as one more
serverblock, centralizing TLS certificates and access logging across all internal services rather than managing certs per-service. - A GitLab instance deployed via Docker Compose (without the Omnibus bundled Nginx) uses an external Nginx on the host to handle TLS termination and expose the service cleanly on standard ports.
- A team running GitLab in a private VPC puts Nginx at the network edge specifically to apply IP allow-listing and rate limiting before traffic ever reaches the GitLab application layer.
Best Practices
- Always proxy to GitLab Workhorse, never directly to the Rails/Puma process — Workhorse handles large file streaming that the Rails app alone doesn’t.
- Set
client_max_body_sizedeliberately based on your actual largest expected upload (LFS objects, artifacts), not an arbitrary large number. - Match GitLab’s
external_urlexactly to what’s configured in Nginx — mismatches cause subtle, hard-to-debug link and redirect issues. - Handle SSH Git traffic explicitly and separately — it’s easy to forget during initial setup since it’s invisible until someone tries
git cloneover SSH. - Test large pushes and CI artifact uploads specifically after any proxy config change — these are the operations most sensitive to timeout and body-size settings.
Wrapping Up
Putting an external Nginx in front of GitLab is a well-trodden pattern, especially for teams consolidating multiple services behind a shared proxy layer or running GitLab in containerized environments without the bundled Omnibus Nginx. The core of it is straightforward — proxy to Workhorse, set the right headers, raise the body size and timeout limits — but the details around X-Forwarded-Proto, WebSocket upgrade headers, and SSH handling are exactly where naive configs quietly break specific GitLab features. Get those right up front, test with an actual large push and a CI pipeline (not just a login page load), and the setup holds up well in production.
