guest@6admin.io:~$

Nginx in front of Perforce: layer-4 TLS termination and the P4 protocol

Before putting nginx in front of Perforce, you should ask whether it is a good idea at all and I covered that question separately in a reverse proxy in front of Perforce, the pros and cons . Here I start from the decision made: we want centralised TLS termination, managed in the same place and by the same ACME tool as the Jenkins certificate. What remains is doing it correctly and correctly means otherwise than an HTTP tutorial would suggest.

Perforce is not HTTP

The Perforce server, p4d, speaks its own RPC protocol on port 1666. It is a binary protocol, stateful, long-lived, with its own compression. Nothing to do with HTTP. A proxy_pass http:// placed in an nginx http block therefore proxies nothing for a real p4 client: nginx expects HTTP requests and gets the Perforce protocol instead and at best the connection closes, at worst it hangs with nothing passing through. This is the mistake you copy without seeing it, because the config starts and nginx does not complain. Centralised TLS termination is therefore done at layer 4, with the stream module, which does TCP proxying: it accepts the client’s TLS connection, decrypts it and relays the Perforce stream in clear to p4d on the local machine.

# top-level context of nginx.conf
stream {
    upstream p4_backend {
        server p4.domain.internal:1666;
    }

    server {
        listen 1666 ssl;
        proxy_pass p4_backend;

        ssl_certificate     ssl/p4.domain.internal.crt;
        ssl_certificate_key ssl/p4.domain.internal.key;
        ssl_protocols       TLSv1.2 TLSv1.3;

        # layer-4 filtering, readable and closed by default
        allow 10.0.0.0/8;    # restrict to your real ranges
        allow 127.0.0.1;
        deny  all;

        # see below: sized for the long idle, not for the transfer
        proxy_timeout 12h;
        proxy_connect_timeout 10s;
        proxy_socket_keepalive on;
    }
}

The backend is an internal FQDN, resolved by the internal DNS, so that the day p4d moves nothing changes in nginx. Filtering is done with allow/deny, the idiomatic form at layer 4. On the client side, the connection becomes P4PORT=ssl:p4.example.com:1666 and on first contact a p4 trust records the fingerprint of the certificate nginx presents: the client never sees p4d, it sees nginx. A Windows detail that is not a detail: the mainline nginx binary for Windows includes the stream module, worth checking with nginx -V on an exotic build.

Why sessions get cut and the tuning that holds them

This is the real subject and it only shows up in production. A game depot sync is hundreds of gigabytes and operations that run for hours. Two moments cause trouble and neither is the transfer itself. The first is the time p4d spends computing server-side, a large changelist for instance, during which not a byte flows over the connection while it must stay open. The second is an interactive session left open between two commands. In both cases the connection is idle but alive and anything that sits on the path, TCP proxy, firewall, NAT, VPN, judges it dead and cuts it silently.

On the nginx side, the trap is proxy_timeout, which defaults to ten minutes in the stream module and fires as soon as no data flows in either direction. On an active transfer of hundreds of gigabytes it never fires, since the bytes keep rearming the counter. But on a silent server-side computation phase, ten minutes are soon exceeded and that is exactly where sessions were dropping. So it must be sized not on the duration of the transfer, but on the longest plausible idle period of a connection you want to keep, which lands in hours and not minutes. Hence the proxy_timeout 12h above, to adjust to your reality. The proxy_socket_keepalive on enables SO_KEEPALIVE on the connection to p4d, but the bulk of keepalive is set on p4d itself, because it is the one talking to clients through the middleboxes.

On p4d, the keepalive configurables are inert by default, all zero, Perforce documentation :

  • net.keepalive.idle: seconds of inactivity before the first keepalive
  • net.keepalive.interval: interval between keepalives
  • net.keepalive.count: number of unacknowledged keepalives before declaring the connection dead
  • net.keepalive.disable: left at 0 so keepalive is allowed

For connections to survive the middleboxes, you set idle and interval well below the shortest idle window on the path, often around thirty seconds if a firewall cuts after a few minutes. That is the setting that holds long sessions. Three other tunables matter for a game depot, throughput- and burst-oriented:

  • net.tcpsize, 512K default, the send and receive buffers set at connection time, raised for large files or high-latency links (server restart required)
  • net.backlog, 128 default, the queue of pending connections, raised when many build agents connect in bursts
  • net.autotune set to 1 on Linux, which can improve high-latency connections, network tuning doc , to test while watching for hangs

Keepalive is also set at the client’s OS level and Perforce has a dedicated note for Windows on that.

What you get

Perforce behind nginx is not an HTTP location, it is a stream block that terminates TLS at layer 4 and relays raw TCP to p4d. The rest of the work is not in the proxy but in tuning long connections, because a multi-hour sync spends most of its time either transferring or waiting on p4d and it is the waiting that kills the session if keepalive is not armed on both sides and proxy_timeout sized in hours. The values given here are those of a reference config, to be set against your own: the trio that really tells how the sessions hold is proxy_timeout, proxy_socket_keepalive and p4d’s net.keepalive.*.

[edit: 2026] The Ansible implementation of all this: the nginx role .