From 68c33a37ad82f7c742d32b6a43a9498d1e2bc2d4 Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Tue, 30 Jun 2026 11:06:53 +0200 Subject: [PATCH] feat(server): configurable HTTP keep-alive/headers timeouts to prevent proxy 502s (#22327) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What & why Node's HTTP server defaults `keepAliveTimeout` to **5s**, which is shorter than the idle keep-alive timeout of common reverse proxies / load balancers (nginx `upstream-keepalive-timeout` and AWS ALB both default to **60s**). twenty-server currently calls `app.listen()` without overriding these, so it runs on the 5s default. When Node closes an idle keep-alive socket that the proxy still has pooled, the proxy's next request races the close and gets a TCP reset. nginx logs: ``` recv() failed (104: Connection reset by peer) while reading response header from upstream ``` and returns a **502** to the client. This is payload- and endpoint-independent: in prod it hit `/graphql`, `/metadata`, `/mcp` and the app-publish tarball upload alike, at a low continuous rate, on healthy pods (no restarts, ~64% memory, no CPU throttling). This is the well-documented "Node behind ALB/nginx 502" race. The fix is the standard one: make the **server** idle timeout **longer** than the proxy's, so the proxy is always the side that closes idle connections. ## Changes - Set `server.keepAliveTimeout` / `server.headersTimeout` in `main.ts` from config. - Add two env-overridable config vars (`SERVER_CONFIG` group), with safe defaults above the typical 60s proxy timeout: - `SERVER_KEEP_ALIVE_TIMEOUT_MS` (default **65000**) - `SERVER_HEADERS_TIMEOUT_MS` (default **66000**) - `headersTimeout` is clamped to `keepAliveTimeout + 1s` at startup, since Node requires `headersTimeout >= keepAliveTimeout` (otherwise it re-introduces the same race). - Document both in `.env.example`. Defaults fix the issue out of the box. The env vars exist because self-hosters sit behind many proxies (Cloudflare, Traefik, ALB, nginx) with different idle timeouts — mirroring how Next.js exposes `--keepAliveTimeout`, and how Fastify (72s) and Kestrel (130s) ship safe-by-default values. ## Test - `environment-config.driver.spec.ts` passes. - `nx typecheck twenty-server` clean for the changed files (only a pre-existing, unrelated `ical-generator` module-resolution error remains). Review in cubic --- packages/twenty-server/.env.example | 2 ++ .../core-modules/twenty-config/config-variables.ts | 14 ++++++++++++++ packages/twenty-server/src/main.ts | 8 ++++++++ 3 files changed, 24 insertions(+) diff --git a/packages/twenty-server/.env.example b/packages/twenty-server/.env.example index 305de015a2..90c0fb7ea2 100644 --- a/packages/twenty-server/.env.example +++ b/packages/twenty-server/.env.example @@ -50,6 +50,8 @@ FRONTEND_URL=http://localhost:3001 # APPLICATION_LOG_DRIVER=CONSOLE # LOG_LEVELS=error,warn # SERVER_URL=http://localhost:3000 +# Keep above your reverse proxy / load balancer idle timeout (nginx, ALB, ... 60s). +# SERVER_KEEP_ALIVE_TIMEOUT_MS=65000 # WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION=7 # WORKSPACE_INACTIVE_DAYS_BEFORE_SOFT_DELETION=14 # WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION=21 diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts index e6d002142d..d0274845c7 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts @@ -1255,6 +1255,20 @@ export class ConfigVariables { @IsOptional() NODE_PORT = 3000; + @ConfigVariablesMetadata({ + group: ConfigVariablesGroup.SERVER_CONFIG, + description: + 'Idle keep-alive timeout (ms) for the HTTP server. Should be higher ' + + 'than the idle timeout of any reverse proxy / load balancer in front ' + + 'of it (nginx, ALB, ... default 60s), so the proxy is the side that ' + + 'closes idle connections.', + type: ConfigVariableType.NUMBER, + isEnvOnly: true, + }) + @CastToPositiveNumber() + @IsOptional() + SERVER_KEEP_ALIVE_TIMEOUT_MS = 65000; + @ConfigVariablesMetadata({ group: ConfigVariablesGroup.SERVER_CONFIG, description: 'Base URL for the server', diff --git a/packages/twenty-server/src/main.ts b/packages/twenty-server/src/main.ts index 121505c376..75eba83ae9 100644 --- a/packages/twenty-server/src/main.ts +++ b/packages/twenty-server/src/main.ts @@ -105,6 +105,14 @@ const bootstrap = async () => { // Inject the server url in the frontend page generateFrontConfig(); + const keepAliveTimeout = twentyConfigService.get( + 'SERVER_KEEP_ALIVE_TIMEOUT_MS', + ); + const httpServer = app.getHttpServer(); + + httpServer.keepAliveTimeout = keepAliveTimeout; + httpServer.headersTimeout = keepAliveTimeout + 1000; + await app.listen(twentyConfigService.get('NODE_PORT')); };