Transport #

The transport module in Caddy’s reverse proxy determines how data is physically sent between the proxy and your backend servers. If the main reverse_proxy directive handles the destination addresses (upstreams) and load distribution (load balancing), the transport block controls low-level parameters like protocol negotiation (HTTP/1.1, HTTP/2, gRPC), internal TLS encryption, socket wait limits (timeouts), and TCP connection lifecycles (connection pooling). Correct transport configuration is a key determining factor in optimizing network latency, securing internal communication paths, and increasing server resilience under high traffic.


Introduction to HTTP Transport #

By default, if you don’t explicitly define it, Caddy uses the http transport module to communicate with backends. This module provides very complete timeout tuning options to prevent your server sockets from hanging due to unresponsive backends.

Here’s the timeout parameter configuration for HTTP transport:

# Setting HTTP transport timeouts
example.com {
    reverse_proxy localhost:3000 {
        transport http {
            # Maximum time to establish the TCP + TLS connection to the backend
            dial_timeout 3s
            
            # Maximum time to receive response headers from the backend after the request is sent
            response_header_timeout 15s
            
            # Maximum time to read the response body from the backend
            # // DON'T enable this if the backend serves slow streaming data
            # read_timeout 60s
            
            # How long idle connections stay open in the pool
            keep_alive 90s
        }
    }
}
  • dial_timeout: Controls the limit for the initial TCP socket negotiation and TLS handshake with the backend. If the backend is completely dead, Caddy immediately drops the connection within 3 seconds (fail-fast) to switch to a backup backend.
  • response_header_timeout: Very important for detecting backends experiencing application deadlock (e.g., a locked database query). Caddy drops the connection if the backend doesn’t send the first HTTP header within the specified limit.
  • read_timeout: Limits the time for reading the response body data. This parameter should be disabled (default: unlimited) if the server serves giant file downloads or real-time communication like Server-Sent Events (SSE).

Understanding the Danger of Socket Exhaustion (Port Exhaustion) #

When the Caddy server processes thousands of requests per second without proper connection pool configuration, the OS is forced to open a new TCP socket for every request. Each TCP connection requires one ephemeral port (a temporary port in the 32768–60999 range on Linux).

After a connection closes, the TCP socket enters the TIME_WAIT state for 60 seconds (based on standard Linux kernel configuration) before the port can be reused. If the rate of new connection creation exceeds the TIME_WAIT cleanup rate, the server experiences Socket Exhaustion. The symptom is Caddy failing to reach the backend with the error message dial tcp: lookup localhost: trigger: assign requested address. Setting a stable keep_alive ensures these ports are reused efficiently.


Encryption to the Backend (Upstream TLS) #

In Zero Trust security architectures, data traffic even within a local private network must stay encrypted using HTTPS. Caddy supports making secure TLS connections toward backends.

# Secure upstream TLS configuration
example.com {
    # Note the protocol scheme using https://
    reverse_proxy https://internal-backend:8443 {
        transport http {
            # Enable TLS to the backend
            tls
            
            # Set the SNI (Server Name Indication) for validating the backend certificate
            tls_server_name internal-backend.company.local
            
            # Only trust certificates issued by our corporate internal CA
            tls_trusted_ca_certs /etc/caddy/certs/internal-ca.pem
        }
    }
}

ALPN Negotiation (Application-Layer Protocol Negotiation) #

During the TLS handshake with the backend, Caddy automatically negotiates the protocol using the ALPN extension. This lets Caddy and the backend agree on the best protocol (e.g., HTTP/2) directly without extra overhead. If the backend supports HTTP/2, Caddy automatically upgrades the connection from HTTP/1.1 to HTTP/2, enabling multiplexing of many requests over one physical connection.

Upstream TLS Security Best Practices #

// ANTI-PATTERN: Ignoring certificate verification in production
tls_insecure_skip_verify // DON'T USE IN PRODUCTION! This enables Man-in-the-Middle attacks

// CORRECT: Register your trusted internal Certificate Authority (CA) file
tls_trusted_ca_certs /path/to/internal-ca.pem

Many developers are tempted to write tls_insecure_skip_verify so Caddy doesn’t validate the backend’s self-signed certificate. This action is very dangerous in production because it disables the identity verification function, making the system vulnerable to traffic interception attacks (Man-in-the-Middle). The right way is to issue backend certificates using your own internal CA, then register that Root CA file in Caddy’s tls_trusted_ca_certs option.


Mutual TLS (mTLS) End-to-End #

For the highest security level, you can implement Mutual TLS (mTLS). In mTLS, not only does Caddy verify the backend’s identity, but the backend also verifies Caddy’s identity before serving sensitive data.

sequenceDiagram
    autonumber
    participant Caddy as Caddy Proxy
    participant Backend as App Server (mTLS Required)

    Caddy->>Backend: 1. ClientHello (Send cipher suite list)
    Backend-->>Caddy: 2. ServerHello & Server Certificate (Send backend certificate)
    Backend-->>Caddy: 3. Certificate Request (Backend requests Caddy's certificate)
    Caddy->>Backend: 4. Client Certificate (Caddy sends tls_client_auth)
    Note over Caddy,Backend: Both parties verify each other's cryptographic keys
    Caddy->>Backend: 5. Finished (Secure mTLS Channel Established)

Here’s an example mTLS configuration at Caddy’s transport level:

# mTLS configuration
secure.example.com {
    reverse_proxy https://secure-backend:8443 {
        transport http {
            tls
            
            # 1. Verify the backend certificate using this CA
            tls_trusted_ca_certs /etc/caddy/certs/backend-root-ca.pem
            
            # 2. Send Caddy's own client certificate to the backend for verification
            tls_client_auth /etc/caddy/certs/caddy-client.crt /etc/caddy/certs/caddy-client.key
            
            tls_server_name secure-backend.internal
        }
    }
}
  • tls_client_auth: This parameter requires two arguments: the location of the client’s public certificate file (.crt) and the private key file (.key) belonging to Caddy. The backend must be configured to require a client TLS handshake and validate it using the same Root CA.

Short mTLS Certificate Creation Tutorial via OpenSSL #

To facilitate local testing, you can generate mTLS certificates manually with the following OpenSSL commands:

# 1. Create the internal Root CA Private Key and Certificate (valid 10 years)
openssl req -x509 -nodes -newkey rsa:4096 -days 3650 \
  -keyout internal-ca.key -out internal-ca.pem \
  -subj "/CN=Internal Infrastructure Root CA/O=Company"

# 2. Create the CSR and Key for the Caddy Client
openssl req -newkey rsa:2048 -nodes \
  -keyout caddy-client.key -out caddy-client.csr \
  -subj "/CN=caddy-edge-proxy/O=Infrastructure"

# 3. Sign the Caddy Client Certificate using our Root CA (valid 1 year)
openssl x509 -req -in caddy-client.csr -CA internal-ca.pem -CAkey internal-ca.key \
  -CAcreateserial -out caddy-client.crt -days 365 -sha256

# 4. Create the CSR and Key for the Backend Server
openssl req -newkey rsa:2048 -nodes \
  -keyout backend.key -out backend.csr \
  -subj "/CN=secure-backend.internal/O=Applications"

# 5. Sign the Backend Certificate using the same Root CA (valid 1 year)
openssl x509 -req -in backend.csr -CA internal-ca.pem -CAkey internal-ca.key \
  -CAcreateserial -out backend.crt -days 365 -sha256

After these certificate files are created, place internal-ca.pem, caddy-client.crt, and caddy-client.key on the Caddy server, and place internal-ca.pem, backend.crt, and backend.key on the backend server.


Connection Pool Optimization for High Traffic #

On production servers with very high traffic volumes, the default OS and Caddy configurations can limit data throughput. You must tune the connection count limits and memory buffers:

# Transport optimization for high traffic
traffic.example.com {
    reverse_proxy backend-1:3000 backend-2:3000 {
        transport http {
            # Raise the idle connection limit per host
            max_idle_conns_per_host 512
            
            # Limit the total connection count so the backend doesn't run out of RAM
            max_conns_per_host 1024
            
            # How long idle sockets are kept
            keep_alive 120s
            
            # Increase read and write buffer sizes to minimize IO system calls
            read_buffer_size  16kb
            write_buffer_size 16kb
        }
    }
}
  • max_idle_conns_per_host: Raising this value (default: 32) ensures Caddy keeps more already-open TCP connections to the backend in an idle state. This is very effective for absorbing sudden request spikes without wasting time on new TCP handshakes.
  • read_buffer_size and write_buffer_size: Increasing the buffer size from 4KB to 16KB reduces the frequency of read/write system calls, significantly lowering server CPU utilization on large data traffic.

FastCGI Transport for PHP-FPM #

The FastCGI protocol is a special protocol used to communicate with the PHP programming language processing engine (PHP-FPM). Unlike a regular HTTP proxy, Caddy must translate HTTP request objects into the binary FastCGI record format before sending them to the PHP-FPM socket.

flowchart LR
    Client["Client (HTTP Request)"] ===>|Port 443| Caddy{"Caddy Proxy"}
    
    subgraph FastCGI_Translation["Protocol Translation"]
        Caddy -->|Extract Path & Env| FC["FastCGI Transport Module"]
    end

    FC ===>|Unix Socket / TCP| PHP["PHP-FPM Socket (:9000)"]
    PHP --> Exec["Execute .php script"]

    style Caddy stroke:#0288d1,stroke-width:2px
    style PHP stroke:#43a047,stroke-width:2px

Dynamic CGI Parameter Translation #

The FastCGI binary format requires sending environment variables like SCRIPT_FILENAME so PHP-FPM knows which physical file to execute, REQUEST_METHOD (GET/POST), and QUERY_STRING. Caddy dynamically reads the incoming HTTP request and maps these values using Go text templates to the FastCGI binary in real time.

Caddy provides the high-level php_fastcgi directive, which automatically configures all FastCGI transport parameters behind the scenes. However, you can also write the configuration manually if you need special adjustments:

# Manual FastCGI transport configuration for PHP-FPM
php.example.com {
    root * /var/www/my-php-app
    
    # Send requests to the PHP-FPM unix socket
    reverse_proxy unix//run/php/php8.3-fpm.sock {
        transport fastcgi {
            # Set the application root path on the server
            root /var/www/my-php-app
            
            # Set the file split extension
            split .php
            
            # Inject custom environment variables for PHP to read ($_SERVER)
            env APP_ENV "production"
            env DB_CONNECTION "mysql"
            
            # Set the read timeout for long-running PHP scripts
            read_timeout 60s
        }
    }
    file_server
}

TCP Socket vs Unix Socket #

When connecting Caddy with PHP-FPM, you face two socket connection type options:

  1. Unix Socket (unix//run/php/php8.3-fpm.sock): The best option if Caddy and PHP-FPM run on the same physical server/VM. The Unix Socket communication path doesn’t go through the OS network protocol stack (TCP/IP), providing much lower latency and higher throughput performance.
  2. TCP Socket (localhost:9000 or 10.0.0.12:9000): The required option if PHP-FPM runs in a separate Docker container or on a different physical server.
# Socket comparison example
example.com {
    # ✓ CORRECT & FAST for single-server: Unix Socket
    php_fastcgi unix//run/php/php8.3-fpm.sock
    
    # ✓ CORRECT for containerized/Docker: TCP Socket
    # php_fastcgi php-container:9000
}

Unix Socket Permission Troubleshooting (Permission Denied) #

The most common problem when first using a Unix Socket is a 502 Bad Gateway error accompanied by a permission denied error log in Caddy. This happens because the .sock socket file is created by the OS with ownership of the www-data user (PHP-FPM), while the Caddy process runs as the caddy user (without read/write access to that file).

To fix it, edit the PHP-FPM pool configuration (usually at /etc/php/8.3/fpm/pool.d/www.conf):

; ANTI-PATTERN: Emptying the socket owner or restricting too tightly
; listen.owner = www-data
; listen.group = www-data

; CORRECT: Allow the 'caddy' group to access the socket (or give 0660 access)
listen.owner = www-data
listen.group = caddy
listen.mode = 0660

After changing these lines, restart the PHP-FPM service (sudo systemctl restart php8.3-fpm) to apply the new permissions.


Proxying gRPC Services #

gRPC is a modern high-performance RPC communication framework developed by Google. gRPC works exclusively using the HTTP/2 protocol as its transport layer and relies on long-lived two-way streaming connections.

Caddy fully supports acting as a proxy for gRPC services. You must use the h2c:// protocol scheme (HTTP/2 Cleartext) if the gRPC backend runs without TLS encryption, or use versions 2 on the HTTP transport if the backend requires encrypted HTTP/2:

# gRPC reverse proxy configuration
grpc.example.com {
    # h2c:// tells Caddy to use HTTP/2 Cleartext without TLS encryption to the backend
    reverse_proxy h2c://grpc-backend:50051 {
        transport http {
            # Force Caddy to only use HTTP/2 for this connection
            versions 2
            
            # Make sure the dial timeout is adjusted for long streaming
            dial_timeout 5s
        }
    }
}

Why Is HTTP/1.1 Not Enough for gRPC? #

gRPC relies heavily on HTTP/2-specific features like bidirectional streaming (client and server can send data concurrently without dropping the connection) and HTTP/2 Trailers.

Trailers are additional HTTP headers only sent after the response body finishes. gRPC uses trailers to send program execution status (grpc-status) and error messages (grpc-message). HTTP/1.1 doesn’t natively support these trailers. Therefore, traditional HTTP/1.1 proxies drop these headers, causing gRPC clients to detect unknown failure statuses. Caddy handles this HTTP/2 trailer cycle transparently and safely.


Summary #

  • Module Definition: The transport block determines the low-level physical connection parameters to the backend, such as socket type, timeout limits, protocol versions, and encryption.
  • Internal Security: Always use tls_trusted_ca_certs with your trusted CA file to secure the private network without opening Man-in-the-Middle attack holes.
  • mTLS: Mutually trusted TLS (tls_client_auth) is used to authenticate Caddy’s identity at the backend level, ensuring the data path is tightly closed from the outside.
  • Speed Optimization: Raise the max_idle_conns_per_host parameter to keep a large number of idle connections in the pool, cutting new TCP handshake overhead.
  • FastCGI & gRPC: Use Unix sockets for faster local PHP-FPM communication, and use the h2c:// scheme with versions 2 to route HTTP/2-based gRPC traffic natively.

← Previous: Proxy Headers   Next: Proxy Cache →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact