Exploring the roots and future of Internet Relay Chat

IRC Behind Nginx: WebSocket Proxying and TLS Termination

The first time I put IRC behind a reverse proxy was in 2011 because our IRC server was getting hit by DDoS attacks and I needed to hide the real IP. The proxy absorbed the traffic, the IRC server stayed up, and I learned a lesson about infrastructure layering that I've applied to every deployment since. Today, the reasons are more nuanced — WebSocket support for browser-based clients, centralized TLS management, traffic inspection, and load distribution across multiple IRC backends — but the fundamental principle is the same: don't expose your application server directly to the internet if you can avoid it.

Why Put a Proxy in Front of IRC

IRC daemons handle the IRC protocol well. They handle everything else poorly. TLS certificate management in UnrealIRCd works but it's a manual process. Rate limiting in InspIRCd is functional but less configurable than a dedicated proxy. WebSocket support varies — Ergo has it built in, UnrealIRCd requires a separate module, and older IRCds don't support it at all.

A reverse proxy separates these concerns:

  • TLS termination — The proxy handles TLS, including certificate rotation. The IRC daemon receives plain TCP on localhost. One TLS configuration, one renewal process, regardless of how many backend services you run.
  • WebSocket proxying — Browser-based IRC clients (The Lounge, KiwiIRC, Gamja) connect via WebSocket. The proxy upgrades the HTTP connection to WebSocket and forwards traffic to the IRC daemon's WebSocket port.
  • IP shielding — The IRC daemon's real IP is not exposed. DNS points to the proxy. DDoS mitigation happens at the proxy layer.
  • Centralized logging — Connection logs, error logs, and access patterns are captured at the proxy level in a consistent format.
  • Port consolidation — Run IRC, a web client, and other services on ports 443 and 80, using SNI or path-based routing. No more explaining to users why they need to connect to port 6697.

For organizations managing complex infrastructure — whether in-house teams or service providers like PowerITi that handle multi-service deployments for their clients — this proxy-first architecture is standard practice. It applies equally to IRC, web applications, mail servers, and any other TCP service.

WebSocket Proxying With Nginx

Most modern IRC web clients use WebSocket to maintain a persistent connection to the IRC server. The WebSocket connection starts as an HTTP upgrade request, then transitions to a bidirectional TCP stream. Nginx handles this natively since version 1.3.13.

# /etc/nginx/sites-available/irc-websocket.conf

# Upstream: IRC daemon's WebSocket listener
# Ergo listens on 8097 by default for WebSocket
# UnrealIRCd with websocket module uses a configured port
upstream irc_websocket {
    server 127.0.0.1:8097;
    keepalive 64;
}

server {
    listen 443 ssl http2;
    server_name irc.example.com;

    ssl_certificate /etc/letsencrypt/live/irc.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/irc.example.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384;
    ssl_prefer_server_ciphers off;

    # WebSocket endpoint for IRC
    location /webirc {
        proxy_pass http://irc_websocket;
        proxy_http_version 1.1;

        # WebSocket upgrade headers
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";

        # Pass client IP to IRC daemon
        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 Host $host;

        # Timeouts — IRC connections are long-lived
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;

        # Disable buffering for real-time data
        proxy_buffering off;
        proxy_cache off;
    }

    # Optional: serve a web IRC client on the same domain
    location / {
        proxy_pass http://127.0.0.1:9000;  # The Lounge web client
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

# Redirect HTTP to HTTPS
server {
    listen 80;
    server_name irc.example.com;
    return 301 https://$server_name$request_uri;
}

The key configuration points:

  • proxy_http_version 1.1 — Required for WebSocket. HTTP/1.0 doesn't support the Upgrade mechanism.
  • proxy_set_header Upgrade and Connection "upgrade" — These headers tell the backend to upgrade from HTTP to WebSocket.
  • proxy_read_timeout 3600s — IRC connections are persistent. The default 60-second timeout would kill idle connections. Set this to at least an hour. Some deployments use 86400s (24 hours).
  • proxy_buffering off — IRC messages must be forwarded immediately. Buffering adds latency and can cause messages to arrive in batches.
Tip: The X-Real-IP header passes the client's actual IP to the IRC daemon. For this to work, the IRC daemon must be configured to trust the proxy's IP and use the X-Real-IP header for the client's displayed hostname. In Ergo, this is the ip-limits.proxy-allowed-from setting. In UnrealIRCd, configure a webirc block. Without this, all web clients will appear to connect from 127.0.0.1.

TLS Termination at the Proxy

TLS termination at the proxy means the proxy handles encryption/decryption and the backend IRC daemon receives unencrypted traffic over localhost. This simplifies certificate management — one place to configure, one renewal process — and removes the TLS overhead from the IRC daemon.

# Nginx stream module for raw IRC TLS termination
# This handles native IRC clients (not WebSocket)
# Requires: nginx compiled with --with-stream and --with-stream_ssl_module

# In /etc/nginx/nginx.conf or a separate stream config file
stream {
    upstream irc_backend {
        server 127.0.0.1:6667;  # IRC daemon plain port on localhost
    }

    # TLS-terminated IRC on port 6697
    server {
        listen 6697 ssl;
        proxy_pass irc_backend;

        ssl_certificate /etc/letsencrypt/live/irc.example.com/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/irc.example.com/privkey.pem;
        ssl_protocols TLSv1.2 TLSv1.3;

        # Pass the real client IP via PROXY protocol
        proxy_protocol on;

        # Timeout for idle IRC connections
        proxy_timeout 3600s;
        proxy_connect_timeout 10s;
    }

    # Plain IRC (optional, some networks still offer it)
    server {
        listen 6667;
        proxy_pass irc_backend;
        proxy_protocol on;
        proxy_timeout 3600s;
    }
}

The stream module operates at the TCP level, not HTTP. This is necessary for native IRC clients that speak raw IRC protocol, not WebSocket. The proxy_protocol directive adds a PROXY protocol header to each connection, allowing the IRC daemon to see the client's real IP address.

Warning: The Nginx stream module must be compiled into Nginx. It's included in the official Nginx packages from nginx.org but may not be present in distribution packages (Ubuntu's nginx-light, for example, doesn't include it). Check with nginx -V 2>&1 | grep stream. If missing, install nginx-full or compile from source with --with-stream.

The IRC daemon must be configured to accept PROXY protocol connections. In UnrealIRCd:

# unrealircd.conf — accept PROXY protocol from Nginx
listen {
    ip 127.0.0.1;
    port 6667;
    options {
        proxy-protocol;
    };
};

# Allow the proxy's IP to use PROXY protocol
allow {
    ip 127.0.0.1;
    class clients;
    options {
        useip;
    };
};

In Ergo, the equivalent is setting proxy-allowed-from in the listener configuration.

HAProxy Alternative: TCP Mode for Raw IRC

HAProxy is often a better choice than Nginx for raw TCP proxying. Its TCP mode is purpose-built for this — better connection tracking, more flexible health checks, and finer control over connection behavior.

# /etc/haproxy/haproxy.cfg

global
    log /dev/log local0
    maxconn 10000
    ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
    ssl-default-bind-options ssl-min-ver TLSv1.2

defaults
    log global
    mode tcp
    timeout connect 10s
    timeout client 3600s
    timeout server 3600s
    option tcplog

# Frontend: TLS IRC on port 6697
frontend irc_tls
    bind *:6697 ssl crt /etc/letsencrypt/live/irc.example.com/combined.pem
    default_backend irc_servers

    # Rate limiting: max 3 connections per IP per 60 seconds
    stick-table type ip size 100k expire 60s store conn_cur,conn_rate(60s)
    tcp-request connection track-sc0 src
    tcp-request connection reject if { sc0_conn_rate gt 3 }

# Frontend: Plain IRC on port 6667
frontend irc_plain
    bind *:6667
    default_backend irc_servers

    stick-table type ip size 100k expire 60s store conn_cur,conn_rate(60s)
    tcp-request connection track-sc0 src
    tcp-request connection reject if { sc0_conn_rate gt 5 }

# Backend: IRC daemon(s)
backend irc_servers
    balance roundrobin
    option tcp-check

    # Send PROXY protocol v2 to pass real client IP
    server irc1 127.0.0.1:6667 send-proxy-v2 check

    # Health check: connect and expect a response
    # IRC servers send a banner on connect
    tcp-check connect
    tcp-check expect rstring ^:.+\s001\s|^NOTICE

HAProxy's built-in rate limiting (stick-table with conn_rate) is more flexible than anything available in Nginx's stream module without third-party modules. You can rate limit by source IP, by SNI hostname, by connection count, or by data rate — all at the TCP level before the IRC daemon sees the connection.

For deployments needing both WebSocket (for web clients) and raw TCP (for native IRC clients), you can run both HAProxy (TCP) and Nginx (HTTP/WebSocket) or use HAProxy alone — it supports WebSocket in HTTP mode as well.

Load Balancing Multiple IRC Backends

Load balancing IRC is different from load balancing HTTP. IRC connections are long-lived and stateful. A user connected to server A is in channels on server A. You can't transparently move them to server B without dropping and re-establishing the connection. This means IRC load balancing operates at the connection level, not the request level.

# HAProxy config for multiple IRC servers
backend irc_cluster
    balance leastconn    # Send new connections to the least-loaded server
    option tcp-check

    # Three IRC servers, linked together via IRC S2S protocol
    server irc1 10.0.1.10:6667 send-proxy-v2 check weight 100
    server irc2 10.0.1.11:6667 send-proxy-v2 check weight 100
    server irc3 10.0.1.12:6667 send-proxy-v2 check weight 50  # smaller box

    # If a server goes down, new connections go to remaining servers
    # Existing connections on the failed server drop — users reconnect
    # and the proxy routes them to a surviving server
    #
    # The IRC servers must be linked (S2S) so users on different
    # backends can see each other and share channels

The leastconn balance method is ideal for IRC because connections vary wildly in duration. Some users connect for minutes, others idle for months. Round-robin would eventually create imbalance; least-connection naturally distributes the load.

The IRC servers behind the load balancer must be linked via the IRC server-to-server (S2S) protocol. The proxy distributes client connections across backends, but the IRC mesh ensures that all users — regardless of which backend they connected to — see the same channels, messages, and user lists. Without S2S linking, you'd have three separate IRC servers that happen to share an IP address, which is not useful.

Production Configuration and Hardening

For production deployments, several additional considerations:

Certificate renewal: Use Certbot with a deploy hook that reloads Nginx or HAProxy after certificate renewal.

# Certbot renewal hook for Nginx
# /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
#!/bin/bash
nginx -t && systemctl reload nginx

# For HAProxy (needs combined cert file)
# /etc/letsencrypt/renewal-hooks/deploy/reload-haproxy.sh
#!/bin/bash
DOMAIN="irc.example.com"
cat /etc/letsencrypt/live/$DOMAIN/fullchain.pem \
    /etc/letsencrypt/live/$DOMAIN/privkey.pem \
    > /etc/letsencrypt/live/$DOMAIN/combined.pem
chmod 600 /etc/letsencrypt/live/$DOMAIN/combined.pem
systemctl reload haproxy

Connection limits: Set appropriate limits to prevent resource exhaustion. A single server can handle thousands of IRC connections, but each connection consumes a file descriptor and a small amount of memory.

# Nginx: per-IP connection limits for the stream module
stream {
    # Limit zone: 10MB shared memory, keyed by client IP
    limit_conn_zone $binary_remote_addr zone=irc_conn:10m;

    server {
        listen 6697 ssl;
        proxy_pass irc_backend;

        # Max 5 simultaneous connections per IP
        limit_conn irc_conn 5;
    }
}

Logging: Log connection metadata without logging message content. IRC messages may contain sensitive information. Log source IP, connection time, bytes transferred, and disconnection reason.

# Nginx stream log format
stream {
    log_format irc_log '$remote_addr [$time_local] '
                       '$protocol $status $bytes_sent $bytes_received '
                       '$session_time '
                       '$upstream_addr';

    server {
        listen 6697 ssl;
        proxy_pass irc_backend;
        access_log /var/log/nginx/irc-access.log irc_log;
    }
}

Fail2ban integration: Monitor proxy logs for repeated failed connections and ban offending IPs.

# /etc/fail2ban/jail.d/irc-proxy.conf
[irc-proxy]
enabled = true
port = 6667,6697
filter = irc-proxy
logpath = /var/log/nginx/irc-access.log
maxretry = 10
findtime = 60
bantime = 3600
action = iptables-multiport[name=irc, port="6667,6697"]
Warning: When using PROXY protocol, ensure that ONLY the proxy can connect to the IRC daemon's backend port. If the IRC daemon is accessible directly (bypassing the proxy), an attacker can send forged PROXY protocol headers and impersonate any IP address. Bind the IRC daemon to 127.0.0.1 or use firewall rules to restrict access to the backend port.

The proxy layer adds a small amount of complexity but the payoff is substantial: centralized TLS, connection-level protection, WebSocket support for web clients, and the ability to move or scale the IRC backend without changing the public-facing configuration. It's the same architecture pattern used for every other internet-facing service. IRC is no different, and there's no reason to treat it differently.

FAQ

Does proxying add latency to IRC messages?

Negligible. Proxy forwarding adds microseconds per packet. IRC messages are small (under 512 bytes typically) and the proxy processes them at wire speed. The latency added by the proxy is far less than the network latency between the client and server. You will not notice it.

Can I use Cloudflare as a proxy for IRC?

Not for raw TCP IRC traffic on the free plan. Cloudflare's free and Pro plans only proxy HTTP/HTTPS traffic. Cloudflare Spectrum (Enterprise plan) can proxy arbitrary TCP traffic, but it's expensive and overkill for most IRC deployments. For WebSocket-based IRC, Cloudflare's regular proxy works since WebSocket runs over HTTP, but be aware of Cloudflare's WebSocket connection limits and timeout behaviors.

How do I proxy IRC and a website on the same IP with one port 443?

For HTTPS/WebSocket, use Nginx with SNI-based virtual hosting — different server_name blocks for the IRC WebSocket and the website. For raw IRC over TLS on port 443 (useful when port 6697 is blocked by firewalls), you need SNI routing at the TCP level. HAProxy can do this with tcp-request inspect-delay and req.ssl_sni matching. Nginx's stream module also supports SNI-based routing with the ssl_preread module.

Should I use HTTP/2 for the WebSocket proxy?

No. WebSocket upgrade requires HTTP/1.1. The proxy_http_version 1.1 directive in the Nginx config is intentional. HTTP/2 has its own multiplexing mechanism that is incompatible with the WebSocket upgrade handshake. Some newer implementations support WebSocket over HTTP/2 (RFC 8441), but IRC client and server support for this is minimal. Stick with HTTP/1.1 for the WebSocket backend connection.