Skip to content

Remote Deployment

HTTP Server Mode documents the transport flag by flag. This page is the other half: how to stand one up for other people, on a machine that stays running, reachable from somewhere other than the operator’s laptop.

Everything here assumes HTTP mode. There is no remote form of stdio: stdio is a pipe between one client process and one server process on the same machine, and the moment a second person needs to reach it the answer is HTTP.

SituationShape
One host, proxy and server togetherUnix socket: --http-addr=/run/gitlab-mcp/server.sock
Proxy on another host--tls-cert and --tls-key on the listener
No proxy at all--tls-cert and --tls-key plus --auth-mode=oauth; read Direct exposure
ContainersThe published image, read-only root filesystem, digest pin
More than one instanceA balancer with token affinity: Several instances

Two shapes, and what separates them is whether a same-host proxy has to reach a unix socket. Loopback TCP with a dynamic user is the least privileged form:

/etc/systemd/system/gitlab-mcp-server.service
[Unit]
Description=GitLab MCP Server
Documentation=https://docs.jmrp.io/gitlab-mcp-server/operations/http-server/
After=network-online.target
Wants=network-online.target
[Service]
Type=exec
DynamicUser=yes
EnvironmentFile=/etc/gitlab-mcp-server/env
ExecStart=/usr/local/bin/gitlab-mcp-server \
--http \
--http-addr=127.0.0.1:8080 \
--gitlab-url=https://gitlab.example.com \
--auth-mode=oauth \
--public-url=https://mcp.example.com \
--trusted-proxy-header=X-Real-IP \
--trusted-proxies=127.0.0.1
Restart=on-failure
RestartSec=2s
StandardInput=null
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
PrivateTmp=yes
PrivateDevices=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
CapabilityBoundingSet=
AmbientCapabilities=
MemoryMax=512M
[Install]
WantedBy=multi-user.target

ProtectSystem=strict mounts the whole filesystem read-only and the server is fine with that: it writes nothing to disk. Logs go to standard error, which journald collects, and the only file it ever creates is the unix socket of the second shape below. Reads are unaffected, so certificates and the environment file stay readable.

RestrictAddressFamilies= deliberately omits AF_NETLINK. The released binary is built with CGO_ENABLED=0 and uses Go’s own resolver, which reaches DNS over UDP and TCP and asks the kernel nothing through netlink. Verified against this unit: a credential check that had to resolve and reach gitlab.com was answered by GitLab with and without AF_NETLINK in the list.

A unix socket needs a fixed user. DynamicUser=yes allocates a transient user and group per start, so there is no group the proxy can be added to. Create a system user and share a real group:

Terminal window
sudo useradd --system --no-create-home --shell /usr/sbin/nologin gitlab-mcp
sudo usermod -aG gitlab-mcp www-data # or nginx, or caddy

Then replace the identity and address lines:

User=gitlab-mcp
Group=gitlab-mcp
RuntimeDirectory=gitlab-mcp
RuntimeDirectoryMode=0750
ExecStart=/usr/local/bin/gitlab-mcp-server \
--http \
--http-addr=/run/gitlab-mcp/server.sock \
--http-socket-mode=0660 \
--gitlab-url=https://gitlab.example.com

RuntimeDirectory= makes /run/gitlab-mcp exist, be owned by the service, and be removed on stop. It also has to be writable: the server does not bind the published path directly. It creates a 0700 staging directory beside it, binds and chmods the socket there, then links it into place, so a socket only ever appears with its final permissions. A read-only parent directory therefore fails, even though the process writes nothing else.

Keep /etc/gitlab-mcp-server/env at mode 0600, owned by root: systemd reads it before dropping privileges, so the service user never needs to. Then install and check:

Terminal window
sudo systemctl daemon-reload
sudo systemctl enable --now gitlab-mcp-server
curl -fsS http://127.0.0.1:8080/health
# The socket shape: curl -fsS --unix-socket /run/gitlab-mcp/server.sock http://localhost/health
journalctl -u gitlab-mcp-server -f

Three things every service manager gets wrong

Section titled “Three things every service manager gets wrong”
  • State the transport. --transport auto reads file descriptor 0 and picks HTTP only when stdin is /dev/null. systemd’s default StandardInput=null happens to give the same answer, but a unit that sets StandardInput=socket or tty gets stdio instead. --http says what you mean and cannot be moved by how the supervisor wires a file descriptor.
  • Socket activation is not supported. A .socket unit passes its socket through LISTEN_FDS, from file descriptor 3, and launchd’s Sockets key through launch_activate_socket(); the server reads neither, so a socket passed that way is ignored and the server binds --http-addr itself. Only StandardInput=socket, or launchd’s inetdCompatibility, puts a socket on file descriptor 0, and this server reads a socket there as “a supervisor handed me a stdio channel”, so under --transport auto it would speak JSON-RPC down it instead of accepting connections. Bind the socket yourself with --http-addr, and under systemd with RuntimeDirectory=.
  • Secrets belong in an environment file, never on the command line. A service’s command line is readable by any local user: a systemd unit’s ExecStart= through systemctl show and /proc, so a secret goes in EnvironmentFile=. HTTP mode has no GitLab token to hide, but two real secrets exist: GITLAB_MCP_TELEMETRY_IDENTITY_KEY, which has no flag for exactly this reason, and whatever OTEL_EXPORTER_OTLP_HEADERS carries.

A leftover socket is not always cleaned up for you. On start the server probes an existing socket and removes it only when the connection is refused, which proves nothing is listening. A live socket is refused rather than stolen, a path that is not a socket is never deleted, and a probe that fails for any other reason (a permission denial, for instance) refuses to start and tells you to remove the file by hand.

The image and its command line are described in Docker. What follows is the deployment shape.

Terminal window
image=$(curl -fsSL https://raw.githubusercontent.com/jmrplens/gitlab-mcp-server/main/server.json \
| jq -r '.packages[] | select(.registryType == "oci" and (.identifier | startswith("ghcr.io/"))) | .identifier')
docker run -d --name gitlab-mcp \
--restart unless-stopped \
-p 127.0.0.1:8080:8080 \
--read-only \
--tmpfs /tmp:rw,size=64m,mode=1777 \
--cap-drop ALL \
--security-opt no-new-privileges:true \
--memory 512m \
-e GITLAB_URL=https://gitlab.example.com \
"$image"

The instance arrives as an environment variable, not an argument. Any argument after the image name replaces CMD wholesale, and CMD is --transport auto --http-addr 0.0.0.0:8080. Writing docker run <image> --gitlab-url=… would silently drop both, and with no transport named the process serves stdio rather than HTTP. -e GITLAB_URL= fills the instance list without touching the command, because HTTP mode reads the variable when the flag is absent.

No -i. auto serves HTTP precisely when stdin is /dev/null, which is what docker run without -i gives it. Adding -i hands the container a pipe, which means a client, and the container speaks stdio to nobody.

-p 127.0.0.1:8080:8080 binds the published port to loopback. -p 8080:8080 publishes on every interface and, with Docker’s default iptables integration, that goes around the host firewall.

--read-only works because the image writes nothing at runtime. The one exception is a unix socket, whose bind needs a writable parent directory for the staging step described above, so give it a --tmpfs or a volume. The container already runs as appuser (uid 10001), so --user is redundant, and --cap-drop ALL is safe because nothing binds a privileged port.

Pin the digest, not only the tag. A tag can be re-pushed; a digest is what a client actually resolves. The first command reads the image the MCP Registry’s server.json pins for the current release: the 3.2.0 tag with the digest the release workflow pushed under it, recorded in the repository when the release was published rather than read back from the tag now. Docker Hub holds the same index under the same digest. Resolve it once and keep the value: a script that reads it again on every start moves to the next release as soon as one is published.

services:
gitlab-mcp-server:
image: ghcr.io/jmrplens/gitlab-mcp-server:3.2.0
restart: unless-stopped
networks: [mcp]
ports:
- "127.0.0.1:8080:8080"
command:
- "--http"
- "--http-addr=0.0.0.0:8080"
- "--gitlab-url=https://gitlab.example.com"
- "--auth-mode=oauth"
- "--public-url=https://mcp.example.com"
- "--trusted-proxy-header=X-Real-IP"
- "--trusted-proxies=172.28.0.1"
read_only: true
tmpfs:
- /tmp:rw,size=64m,mode=1777
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
deploy:
resources:
limits: { memory: 512M, cpus: "2.0" }
healthcheck:
test: ["CMD", "gitlab-mcp-server", "--probe"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
logging:
driver: json-file
options: { max-size: "10m", max-file: "3" }
networks:
mcp:
ipam:
config:
- subnet: 172.28.0.0/24

A command: list replaces CMD, which is why --http and --http-addr are spelled out again. Naming an instance is not optional: HTTP mode exits with --gitlab-url is required in HTTP mode when none is given, because a deployment that names no instance would send whatever token a caller supplied to whatever host that caller put in GITLAB-URL.

--trusted-proxies names where the proxy connects from, and that has to be an address only the proxy can hold. The example declares its own network with a fixed subnet and trusts 172.28.0.1 alone: the gateway, which is where a proxy running on the host arrives from through the published port. A proxy that is itself a container on that network gets a fixed ipv4_address, and that is the value to trust instead. Docker’s default pools (172.16.0.0/12) are the wrong answer: every container on the host lives in them, and any of them could then name the address a caller’s failures are charged to.

Several flags have no environment-variable equivalent and must be arguments: --http-addr, --http-socket-mode, --tls-cert, --tls-key, --trusted-proxy-header, --trusted-proxies, --stateless, --json-response, --http-idle-timeout, --max-request-body-bytes, --allow-any-gitlab-url, --resource-documentation, --resource-policy-uri and --resource-tos-uri, and so do the transport selectors --http and --transport themselves. Most of the rest can come from the environment, --rate-limit-rps and --rate-limit-burst included, and an explicitly passed flag still wins.

The image’s own HEALTHCHECK, from 3.0.0, is gitlab-mcp-server --probe: the binary finds the server process in the container, reads --http-addr, --tls-cert and the transport off that process’s command line, and asks /health where it is actually served. Move the listener to another port, to a unix socket, or behind --tls-cert, and the check follows without being told. A container a client runs over stdio (docker run -i) has no listener; the probe reports it healthy while the process runs. --probe <url>, --probe unix:<path> or --probe host:port skips the discovery, for a check run from outside the container.

The Compose block above restates the image’s check only so that a change to the image’s interval does not silently change the deployment’s; drop the healthcheck: block to use the image’s own. An image up to 2.7.5 has no --probe: its own check runs wget against http://localhost:8080/health, so any other listener is reported unhealthy while it serves, and the test: line above fails there as an unknown flag. Pinned to one of those, restate the check with wget against wherever that deployment listens.

HTTP mode has no GitLab token to inject. What is left is telemetry credentials, and those do not belong in environment:, which docker inspect prints in full. Mount the file and name it:

secrets:
- source: mcp_env
target: /run/secrets/mcp.env
environment:
GITLAB_MCP_ENV_FILE: /run/secrets/mcp.env
secrets:
mcp_env:
file: ./secrets/mcp.env

GITLAB_MCP_ENV_FILE is resolved once from the process environment before any dotenv file is loaded, so a loaded file cannot nominate another one. Give an absolute path: a relative one is resolved against the working directory and warned about at startup. It composes with read_only: true, because the secret arrives as a read-only mount.

--gitlab-url may be given more than once, or once comma-separated. One instance pins the deployment and a GITLAB-URL header is ignored. Several publish an allow-list: the header becomes required, and one naming anything outside the list is refused rather than honored. --allow-any-gitlab-url publishes none and lets the header name any host, and a request that then omits it is refused with 400; the server refuses to start with it unless --http-addr binds a loopback address or a unix socket, so it belongs on a single-user local deployment only. The full table is in Publishing more than one instance.

RequirementWhy
Declare the host with --public-urlThe proxy forwards the client’s Host, and a host nobody declared is refused with 403 as a DNS-rebinding attempt. --public-url is what declares it. Listing the proxy’s own address in --trusted-proxies, together with the --trusted-proxy-header it requires, is the alternative: a hop the operator vouched for may forward any host, which is what a deployment fronting several names does. OAuth mode still requires --public-url
No response bufferingStreamable HTTP answers with text/event-stream. A buffering proxy turns a live stream into one delivery at the end
Long read timeoutA subscriptions/listen stream is silent between notifications. The server’s SSE keep-alive fires every 25 seconds, under nginx’s 60-second default, but a quiet stream still wants headroom
HTTP/1.1 upstreamHTTP/1.0 to the upstream has no chunked transfer, which is what an SSE body uses
Forward AuthorizationOAuth mode reads the bearer token from it; stripping it turns every request into a 401
Forward PRIVATE-TOKENLegacy mode’s header
Forward GITLAB-URLSelects the instance when several are published
Real client address--trusted-proxy-header names the header the proxy sets and --trusted-proxies the addresses it connects from, so the authentication-failure limiter counts callers rather than the proxy
Route /.well-known/ unchangedIn OAuth mode the RFC 9728 metadata lives at the host root, not under the path prefix
Add no CORS headersThe server answers preflight itself. Two Access-Control-Allow-Origin headers is a CORS failure, not a merge

--public-url is what makes the forwarded host acceptable. Every proxy below preserves the client’s Host and connects to the server over loopback, which is also the shape of a DNS-rebinding attack: a page resolves a name the attacker controls to the loopback address, and the browser reaches the local server with that name in Host. So a deployment reached as mcp.example.com starts with --public-url=https://mcp.example.com, and any other host is still refused with 403, on /health as well as on /mcp. A request that carries no Host at all, which is what a balancer health check sends, is served. The full rule, the trusted-proxy alternative and what a wildcard listener does are in Behind a reverse proxy.

Two more that bite. Do not let the proxy speak CORS: the server answers its own preflight from --trusted-origins, to which the --public-url origin is added automatically, and a proxy that also emits Access-Control-Allow-Origin produces two of them, which browsers reject outright while curl reports a cheerful 200, so it fails only in the one client that matters. test/e2e/http pins that collision with a real nginx, and the server’s side of it is in Cross-origin protection. The one exception is a document the proxy serves itself rather than forwards: the AI Catalog in the nginx example never reaches the server, so the proxy is the only thing that can send its CORS header. And anything the proxy does not route is a 404, not a 401, because the MCP endpoint is mounted on specific patterns rather than as a catch-all: a path the server does not serve answers {"error":"not found"} without asking for a credential. A 404 where you expected a 401 usually means the path never reached the handler.

upstream gitlab_mcp {
server 127.0.0.1:8080;
keepalive 16;
}
server {
listen 443 ssl;
http2 on;
server_name mcp.example.com;
ssl_certificate /etc/letsencrypt/live/mcp.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mcp.example.com/privkey.pem;
# RFC 9728 metadata: the exact path --public-url derives, at the host
# root, never a prefix and never rewritten. This deployment owns its
# host name, so it derives the bare path.
location = /.well-known/oauth-protected-resource {
proxy_pass http://gitlab_mcp;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Connection "";
}
# AI Catalog: the host's own document, served here and never forwarded.
# `types { }` clears the extension map, without which mime.types sends
# the file as application/json and default_type is never reached.
location = /.well-known/ai-catalog.json {
alias /etc/nginx/ai-catalog.json;
types { }
default_type application/ai-catalog+json;
add_header Access-Control-Allow-Origin "*" always;
add_header Cache-Control "public, max-age=3600" always;
}
location / {
proxy_pass http://gitlab_mcp;
proxy_http_version 1.1;
proxy_set_header Host $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 $scheme;
# With keepalive above: clear Connection so upstream connections are reused.
proxy_set_header Connection "";
# Server-sent events must not be held.
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 1h;
proxy_send_timeout 1h;
}
}

Pair it with --trusted-proxy-header=X-Real-IP --trusted-proxies=127.0.0.1. The list is what makes the header worth reading: it is believed only on a connection from one of those addresses, and a request from anywhere else is charged to its own peer address whatever header it carries. Without the list a caller who reaches the listener directly would write the header themselves and choose the address their failures are charged to, which is why the server refuses to start with one flag and not the other.

Either header works. With X-Forwarded-For the server walks the value from the right, skipping every hop that is itself in the list, and charges the first hop that is not: with one nginx in front that is the client, and with a second proxy in front of nginx it is still the client, provided both proxies are listed. A value the client invented on the left is never reached, and a hop that is not an address charges the peer instead. A header that arrives as several field lines is read as one list, the lines joined in order: nginx extends the client’s line, while HAProxy’s option forwardfor appends a line of its own after it, and both end with the address the proxy heard from on the right. X-Real-IP set from $remote_addr at the hop nearest the server carries one address and needs no walk, and it is the header the project’s own end-to-end proxy test uses.

nginx passes client request headers upstream by default, so Authorization, PRIVATE-TOKEN and GITLAB-URL arrive with no proxy_set_header line. The one nginx habit to know is that it drops headers containing underscores unless underscores_in_headers on is set; none of this server’s headers contains one. Host is the exception to passing through: nginx sends its upstream’s name unless told otherwise, which is why both locations that forward set it. They clear Connection for a similar reason: nginx sends Connection: close upstream unless told otherwise, which would close every connection keepalive is there to reuse.

When the proxy shares the machine, remove the hop rather than encrypting it: --http-addr=/run/gitlab-mcp/server.sock. When the proxy is elsewhere, the binary terminates TLS itself and no sidecar is needed:

Terminal window
gitlab-mcp-server --http --http-addr=:8443 \
--tls-cert=/etc/ssl/mcp.crt --tls-key=/etc/ssl/mcp.key \
--gitlab-url=https://gitlab.example.com

Both flags or neither: --tls-cert requires --tls-key is a startup error, not a warning. TLS 1.2 is the floor with no ceiling, so a current proxy negotiates 1.3 and anything below 1.2 is refused. The mechanics of both listeners are in Listening on a unix socket, or on TLS.

The binary answers its own CORS preflights, its own 404s, its own security headers, and can terminate TLS or listen on a unix socket. A deployment without a proxy should still be deliberate about five things:

  • --auth-mode=oauth. Bearer verification against the instance the request selected, with RFC 9728 metadata so clients discover it. --public-url is required and must be the externally reachable https origin.
  • Certificates and their renewal. Re-read on the handshake after the files change, so a renewal is a file write rather than a restart; see TLS between the proxy and the server.
  • Rate limiting. --rate-limit-rps defaults to 10 in HTTP mode with a burst of 40, counted per pooled token rather than per address, and a separate per-address budget covers authentication failures, which is the one that answers 429 with Retry-After. A throttled tools/call is not a 429: it is a normal MCP result carrying an error, so it will not show up in HTTP-level monitoring.
  • Bounds. --max-http-clients caps pooled entries, not sessions or the requests they hold; the process bounds the calls held at once across every credential by its descriptor limit (192 under a hard limit of 1024), not configurable, and refuses the next one as busy (see Requests held open at once). --pool-idle-timeout reclaims unused entries, except one with a live subscription, which is not idle however long ago it made a request. --session-timeout applies to stateful mode only, where the process also keeps at most half as many sessions as it may hold calls (96 under a hard limit of 1024) and refuses the next initialize as busy (see Stateful sessions kept at once). Neither ceiling has a per-credential one beside it, since a credential is a key a caller can mint, so one credential can fill either; and the server keeps no memory cap of its own, so keep MemoryMax on the unit (the unit above sets 512M), or set a memory limit on the container, which is what bounds memory where the descriptor limit is large.
  • The token passes through the box. Every caller’s GitLab token reaches this process, authenticates one request, and is never persisted. Whether the people whose tokens they are consider the machine trustworthy is a question the software cannot answer, so ask them rather than answering for them.

Affinity is a preference, with two exceptions

Section titled “Affinity is a preference, with two exceptions”

Each instance keeps two caches. Both are per process, both are keyed on the caller, and neither can be shared:

  • The server pool, keyed on SHA-256(token + "\x00" + gitlabURL). On a miss the entry is built: the credential is verified, its scopes are read, the instance’s licensing tier is detected, and the entry is bound to the MCP server of its configuration shape. The tool catalog is not built per credential: it is registered once per shape and shared by every credential of that shape (ADR-0020), so its build (the first client of a shape waited 0.32 seconds for it on the dynamic surface and 1.28 on individual in the resource benchmark, on the host it states) is paid once per shape on each instance rather than once per caller. The handshake is answered before the catalog exists, so even that first build lands on the first request that needs the catalog rather than on initialize.
  • The OAuth identity cache, keyed on instance and token, with --oauth-cache-ttl at 15 minutes by default. A miss is a round trip to GitLab to verify the credential.

Both hold live objects rather than serializable state, so there is no version of this where a second instance reads the first one’s cache. The question for a balancer is therefore not whether requests will work anywhere: under the default --stateless=true every POST is self-contained and every instance answers correctly. The question is how many times you are willing to pay for a cache designed to be paid once, and since its expensive half is shared per configuration rather than held per credential, the answer is small: a credential probe, a licensing lookup and a client.

Two things do not merely cost extra when a caller moves, they break. A stateful session (--stateless=false) lives in the process that minted its Mcp-Session-Id, and another instance answers that id with 404, which a client reads as its session having been terminated. Resource subscriptions are watchers held by one process and disappear with it. If you run either, affinity is not an optimization.

DistributionKeyWhat it costs
Round robinnoneEvery instance eventually holds an entry for every caller, so each caller’s credential probe, licensing lookup and token verification are paid once per instance rather than once. The catalog behind those entries is per configuration shape, so it is not multiplied. Correct, and pays N times for the cheap half
IP hashthe client addressFree, one directive, no secret to keep. The key is the wrong grain in both directions: an office, a VPN concentrator or a CI runner fleet is one address, so all of it collapses onto one instance, while one caller roaming between networks changes key and lands cold each time. Behind a CDN it needs the real address recovered first
Token hasha salted digest of the credentialMatches the grain of what is cached, because the pool key is derived from the token. Costs one secret to manage, a fallback for credential-less requests, and one cold entry whenever a caller rotates its token. This is what the hosted endpoint runs

Token hash is the right answer here, and the reason is narrow enough to state exactly: the thing being cached is keyed on the token, so the routing key should be too. Neither of the others is wrong, and both are fine wherever one instance serves the whole caller population, which is most deployments. Reach for a second instance for availability, not for throughput: the ceiling that usually binds is GitLab’s own rate limit against one token, which no number of instances raises.

The credential must not become the routing key. An affinity key ends up in the balancer’s memory, in its access log if the log format names the variable, and in whatever upstream selection it drives. A raw bearer token in any of those is a credential leak with extra steps.

The fix is a salted digest: hash a per-deployment secret together with the credential, and route on that. The digest is a distribution function rather than a security primitive; the salt is what does the security work. Without it the key is a token fingerprint that anyone holding a candidate token could confirm. With it the key is meaningless outside this deployment and cannot be replayed as a credential.

Three details decide whether the affinity actually holds:

  • Normalize before hashing. Strip the Bearer scheme prefix and surrounding whitespace. The same token spelled two ways hashes two ways and lands on two instances.
  • Fall back to the address, not to nothing. A request with no credential has no key, and an empty key makes every anonymous request hash identically onto one instance. $remote_addr spreads them.
  • Use consistent hashing. Removing one of three instances then relocates roughly a third of the callers instead of reshuffling all of them, which is the difference between a rolling update that warms one cold pool and one that warms every pool.

hash accepts a string with variables and hashes it itself, so the salt goes into the key expression without ever becoming a loggable variable of its own. This needs no third-party module:

# Keep the salt out of the repository: include it from a 0600 file.
map $host $mcp_salt {
default "a-long-random-per-deployment-salt";
}
# The bearer credential, without its scheme prefix.
map $http_authorization $mcp_bearer {
default "";
"~*^Bearer[ ]+(?<tok>\S+)$" $tok;
}
# Legacy-mode callers send PRIVATE-TOKEN instead.
map $mcp_bearer $mcp_credential {
default $mcp_bearer;
"" $http_private_token;
}
# No credential: fall back to the client address.
map $mcp_credential $mcp_affinity {
default $mcp_credential;
"" $remote_addr;
}
upstream gitlab_mcp {
hash "$mcp_salt$mcp_affinity" consistent;
server 10.0.0.11:8080 max_fails=2 fail_timeout=10s;
server 10.0.0.12:8080 max_fails=2 fail_timeout=10s;
server 10.0.0.13:8080 max_fails=2 fail_timeout=10s;
keepalive 32;
}
server {
listen 443 ssl;
server_name mcp.example.com;
ssl_certificate /etc/letsencrypt/live/mcp.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mcp.example.com/privkey.pem;
location / {
proxy_pass http://gitlab_mcp;
proxy_http_version 1.1;
proxy_set_header Host $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 $scheme;
proxy_set_header Connection "";
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 1h;
# Retry only what was never delivered.
proxy_next_upstream error timeout;
proxy_next_upstream_tries 2;
}
# The exact derived metadata path, never a prefix.
location = /.well-known/oauth-protected-resource {
proxy_pass http://gitlab_mcp;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Connection "";
}
}

Do not log $mcp_affinity or $mcp_credential. Where an explicit digest is preferred, set_md5 $key "$mcp_salt$mcp_affinity" from ngx_http_set_misc_module (OpenResty, or Debian and Ubuntu’s nginx-extras) produces one, and njs does the same in a few lines. The hosted endpoint at mcp.jmrp.io computes md5(salt + bearer) that way.

Prove the affinity rather than assuming it. Two instances, eight tokens, six requests each, reading the backend out of the access log:

Terminal window
for t in alpha bravo charlie delta echo foxtrot golf hotel; do
for _ in $(seq 1 6); do
curl -s -o /dev/null -H "Authorization: Bearer token-$t" \
https://mcp.example.com/health
done
done

Every token must show exactly one backend across its six requests, and the eight tokens must not all show the same one. Run the same loop with no Authorization header and with PRIVATE-TOKEN instead, to confirm both fallbacks pin as well.

Announce the drain before the listener closes. On SIGTERM the process marks itself draining first: from that moment /health answers 503 with "status": "draining" and Cache-Control: no-store. By default the listener closes right after, so a balancer polling /health usually notices the closed listener rather than the 503, one probe later, and every request it sent in that window failed. Start each instance with --drain-delay set to at least one probe interval (--drain-delay=10s for a 5-second probe that tolerates two failures): the listener then stays open that long answering 503, the balancer removes the backend, and only then does the listener close and the in-flight requests get their 15 seconds to finish before the remaining connections are closed. Under consistent hashing the callers pinned to it move to a neighbour, warm an entry there, and come back when it returns. A balancer that cannot poll still works the old way: remove the backend by hand, let streams drain, then signal.

/health is the probe, and it is honest about what it knows. No credential, no GitLab round trip, 200 with status, version, commit, build, config_digest, started_at and uptime_seconds. status is ok while serving and draining once shutdown was requested; the HTTP status carries the same verdict. build is the label to put on a dashboard, the closest release plus the short commit, so a release binary and a build from main read alike. config_digest is the fleet check: every instance behind one balancer must report the same one, or one of them serves a different catalog to whichever clients reach it and nothing else notices. Compare it in the same loop that proves the affinity. The endpoint deliberately does not test GitLab reachability, so a balancer must not read a 200 as “GitLab is up”, and there is no separate readiness endpoint. Every field is described in Health check.

Give the fleet a fixed egress address. GitLab applies its own rate limits and any IP allow-lists per source address. Instances behind a NAT gateway with a stable address are one caller to GitLab; instances with ephemeral public addresses are several unpredictable ones, and an allow-list cannot be written for them.

Remember the limiter multiplies. --rate-limit-rps is per pooled token entry inside one process, so three instances mean up to three buckets for one caller unless affinity pins it to one. With token affinity the configured number is the number. Under round robin, either divide it by the instance count or put the real limit on the balancer.

Revalidation keeps running per instance. --revalidate-interval re-checks pooled credentials every 15 minutes by default and evicts the ones GitLab now rejects; setting it to 0 does not make a revoked token last forever, because an entry older than an hour is rebuilt on next use anyway. That is per instance, so the same token is re-checked once per instance holding it, which is one more thing affinity keeps to one.

Frequently asked questions

Can I load-balance several instances round-robin?

For ordinary calls it works, and it pays for the cheap half of the cache several times. Each instance keeps a server pool keyed on a hash of the token and GitLab URL, and an OAuth identity cache keyed on the instance and token. Both are per process and hold live objects, so nothing is shared between instances. A caller that lands on a different instance each request has a pool entry built there (its scopes read and the licensing tier detected) and its token verified against GitLab again. The tool catalog is not rebuilt for it: an instance registers the catalog once per configuration shape and shares it with every credential of that shape. Round robin is correct; it just pays N times. Two things do need affinity, because they live in one process: legacy stateful sessions and resource subscriptions. Token affinity pins a caller to the instance that already holds its entry.

Why not just use IP hash for affinity?

Because the address is the wrong grain. An office, a VPN concentrator or a CI runner fleet is one address, so all of it collapses onto one instance; and one caller roaming between networks changes key and lands on a cold instance each time. Behind a CDN the balancer also has to recover the real address first. IP hash is cheap and needs no secret, which is a real advantage, but the thing being cached is keyed on the token, so the routing key should be too.

Do I need a reverse proxy at all?

No. The binary answers its own CORS preflights, its own 404s, its own security headers, terminates TLS with --tls-cert and --tls-key, and presents a renewed certificate on the next handshake without a restart. Where a proxy shares the machine, --http-addr=/run/gitlab-mcp/server.sock removes the hop instead of encrypting it. A proxy earns its place for obtaining certificates automatically, for hosting other services on the same name, and for balancing several instances.

My OAuth clients get a 404 during discovery. What is wrong?

The proxy is almost certainly routing only the path prefix. The RFC 9728 metadata document lives at the host root, not under --public-url's path: started with --public-url=https://mcp.example.com/gitlab, the server serves it at /.well-known/oauth-protected-resource/gitlab and answers 404 for /gitlab/.well-known/oauth-protected-resource/gitlab. /health and the server card *are* mounted under the prefix as well; the OAuth metadata is not. Route that exact derived path, /.well-known/oauth-protected-resource/gitlab, to the same upstream without rewriting it, and never a prefix of it.