FTP traffic doesn’t inherently play well with load balancing, and that’s because of its stateful nature.

Let’s see how we can make it work.

Imagine you have three FTP servers: ftp-1 (192.168.1.10), ftp-2 (192.168.1.11), and ftp-3 (192.168.1.12). We want to distribute incoming FTP connections across these servers using a single public IP address, say 10.0.0.1. We’ll use HAProxy for this, as it’s a robust and widely used load balancer.

Here’s a basic HAProxy configuration:

global
    log /dev/log    local0
    log /dev/log    local1 notice
    daemon

defaults
    mode    tcp
    timeout connect 5000ms
    timeout client  50000ms
    timeout server  50000ms

listen ftp_cluster 10.0.0.1:21
    mode tcp
    balance roundrobin
    server ftp_1 192.168.1.10:21 check
    server ftp_2 192.168.1.11:21 check
    server ftp_3 192.168.1.12:21 check

When a client connects to 10.0.0.1 on port 21 (the FTP control port), HAProxy will forward that connection to one of the backend FTP servers based on the roundrobin algorithm. The check directive ensures HAProxy periodically pings each backend server to verify its health. If a server fails the check, HAProxy will temporarily stop sending traffic to it.

This handles the control connection (port 21). But FTP has a second, trickier part: the data connection. By default, FTP uses active mode, where the server initiates the data connection back to the client. This is problematic for load balancing because the server would need to know the client’s IP and the specific port the client is listening on, which is not something a load balancer can easily manage.

To make this work, we need to use passive mode FTP. In passive mode, after the control connection is established, the client initiates the data connection to the server. This is much easier to handle with a load balancer. The client receives a 227 Entering Passive Mode (h1,h2,h3,h4,p1,p2) message from the FTP server, where p1 and p2 represent the port number (calculated as p1 * 256 + p2). The client then connects to h1.h2.h3.h4 on that calculated port.

The challenge here is that the HAProxy load balancer is acting as the h1.h2.h3.h4 for the client. If the client connects to 10.0.0.1 and the server responds with 227 Entering Passive Mode (10,0,0,1,p1,p2), the client will try to connect back to 10.0.0.1 on the specified data port. However, HAProxy isn’t listening on those data ports.

To solve this, we need to configure HAProxy to handle FTP’s passive mode data connections. This requires a bit more configuration. We need to tell HAProxy to:

  1. Listen on the FTP control port (21).
  2. Understand FTP commands to determine when a data connection is about to be established.
  3. Allocate a range of ports on the load balancer itself for these data connections.
  4. When a passive mode data connection is requested, forward it to the same backend server that handled the control connection.

Here’s an enhanced HAProxy configuration that addresses passive mode:

global
    log /dev/log    local0
    log /dev/log    local1 notice
    daemon
    # Needed for FTP passive mode
    nbproc 1
    # Set to a value higher than the number of concurrent data connections
    maxconn 2000

defaults
    mode tcp
    timeout connect 5000ms
    timeout client  50000ms
    timeout server  50000ms

# FTP control connection listener
listen ftp_control
    bind 10.0.0.1:21
    mode tcp
    balance roundrobin
    # Add specific FTP settings for passive mode
    option tcplog
    # Use cookie to stick client to the same server for control and data
    # This is crucial for stateful protocols like FTP
    cookie FTP_SERVER insert indirect

    # Define your backend FTP servers
    server ftp_1 192.168.1.10:21 check cookie ftp1
    server ftp_2 192.168.1.11:21 check cookie ftp2
    server ftp_3 192.168.1.12:21 check cookie ftp3

# FTP data connection listener (needs to be on the same IP as control)
# This listener will handle the actual data transfer
listen ftp_data
    bind 10.0.0.1:50000-50100  # Range of ports for passive data connections
    mode tcp
    balance source
    # Use the same cookie to ensure data goes to the same backend server
    cookie FTP_SERVER

    # Backend servers for data connections - these are the same as control
    # The 'redir' directive is key here: it rewrites the passive mode response
    # to point to the load balancer's IP and the allocated data port.
    # HAProxy's FTP module inspects the 227 response and rewrites it.
    # The 'server' entries here are placeholders for the backend servers.
    # HAProxy's internal FTP handler will map the client's connection
    # to the correct backend server based on the control connection.
    # The 'redir' is handled implicitly by the FTP parsing.
    server ftp_1_data 192.168.1.10:21 # This is a placeholder, HAProxy knows the real IP
    server ftp_2_data 192.168.1.11:21
    server ftp_3_data 192.168.1.12:21

The crucial part for passive mode is the ftp_data listener. By specifying a range of ports (50000-50100 in this example), HAProxy can intercept the 227 Entering Passive Mode response from the backend server. HAProxy’s built-in FTP protocol parser will modify this response. Instead of telling the client to connect back to the backend server’s IP on a specific port, it will tell the client to connect to the load balancer’s IP (10.0.0.1) on one of the ports from the ftp_data listener’s range. HAProxy then forwards this data connection to the original backend server that handled the control connection for that client.

The cookie FTP_SERVER insert indirect in the ftp_control section is vital. It tells HAProxy to insert a cookie into the client’s connection. When the client makes a data connection, HAProxy reads this cookie to ensure the data connection is sent to the same backend server that handled the control connection. This is the "stickiness" required for FTP.

The balance source in ftp_data is less about actual source IP balancing and more about ensuring connections within this listener are handled consistently. The primary mechanism for directing data to the correct backend is the cookie and the FTP protocol parsing.

The specific range of ports for ftp_data needs to be open in your firewall.

The most surprising thing about making FTP work with load balancers is that the load balancer itself needs to be FTP-aware, not just a dumb pipe. It has to parse FTP commands and rewrite responses to manage the stateful data connections.

Now, let’s consider the backend FTP servers. Each of them needs to be configured to allow passive mode connections. On many FTP servers like vsftpd, this involves settings in their configuration files (e.g., /etc/vsftpd.conf). You’d typically see directives like:

pasv_enable=YES
pasv_min_port=50000
pasv_max_port=50100
pasv_address=192.168.1.10  # The *internal* IP of this specific server

The pasv_address is important. It tells the FTP server what IP address to put into the 227 Entering Passive Mode response. When HAProxy is in front, you might think this should be the public IP, but it’s actually better to have it be the server’s internal IP. HAProxy intercepts this response, sees the internal IP, and knows how to rewrite it to point to itself (the load balancer) on one of the allocated data ports. The load balancer then handles the translation to the correct backend server.

The range specified in pasv_min_port and pasv_max_port on the backend servers must match the range specified in the ftp_data listener on HAProxy.

If you run into issues, check your firewall rules to ensure the ftp_data port range is open on the HAProxy server and that traffic can reach your backend FTP servers on ports 21 and the passive port range.

The next challenge you might face is FTP over TLS (FTPS), which adds another layer of complexity due to encryption.

Want structured learning?

Take the full Ftp course →