The Slow Gulls

Ideas, texts and images between sky, earth and sea

social: [ mastodon logo @ololduck@vit.am | github logo github | sourcehut logo sourcehut | radicle logo radicle ]

lang: [ en | fr ]

Installing Soju, a modern IRC bouncer

2026-06-24

I swapped my old ZNC for Soju. Here is how it went.

I have used ZNC for years. It is reliable, solid, and does the job of keeping me connected to my friends when I am away from my computer, while having only one user actually connected to the IRC network even if I am connected from both my phone and my computer. But the IRC protocol has evolved with IRCv3 features that ZNC does not support.

I had already mentioned Soju in my notes from May 3, and I finally decided to deploy it on chat.vit.am.

Why Soju?πŸ”—

Soju is a newer IRC bouncer than ZNC, with native support for IRCv3 extensions like chathistory, sasl, and message-tags. But what really pushed me was file upload support. ZNC does not offer it natively, while Soju integrates it directly: you can send a file with clients supporting the soju.im/file-uploads IRC extension. The link is public, anyone can download it.

DeploymentπŸ”—

I run a small Docker Compose stack on chat.vit.am with TheLounge (webchat) and ZNC (the old bouncer). I added a Soju service to it.

Docker Compose configurationπŸ”—

# docker-compose.yml
services:
  soju:
    image: codeberg.org/emersion/soju:latest
    container_name: soju
    restart: unless-stopped
    ports:
      - "127.0.0.1:6668:6667"
      - "127.0.0.1:4002:80"
    configs:
      - source: soju
        target: /soju-config
    volumes:
      - soju-db:/db
      - /var/www/chat.vit.am/uploads/:/uploads/

configs:
  soju:
    file: soju/config

volumes:
  soju-db:

I expose two ports, both on loopback:

The /var/www/chat.vit.am/uploads/ directory is mounted into the container for storing uploaded files.

Soju configurationπŸ”—

# soju/config
db sqlite3 /db/main.db
message-store db
file-upload fs uploads/

listen irc+insecure://
listen http+insecure://:80
listen unix+admin://

http-ingress https://chat.vit.am/soju

accept-proxy-ip localhost

Some explanations:

The nginx reverse proxyπŸ”—

I already had an HTTPS configuration for chat.vit.am (Let’s Encrypt certificate managed by Certbot, used for TheLounge and ZNC). I simply added a location /soju/ block to the existing server, and a stream section in nginx.conf for TCP TLS.

This is where things get interesting. nginx has to fill two roles: serving as an HTTP reverse proxy for the Soju interface (WebSocket and uploads), and doing TCP stream for direct TLS-encrypted IRC connections.

HTTP configuration (reverse proxy)πŸ”—

# /etc/nginx/sites-available/chat.vit.am
location /soju/ {
    proxy_pass http://127.0.0.1:4002/;
    proxy_http_version 1.1;
    proxy_redirect ~^(/uploads/.*)$ /soju$1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_read_timeout 1d;
    client_max_body_size 15m;
}

The trailing slash on proxy_pass is important: it strips the /soju/ prefix before passing the request to Soju. Without it, Soju would receive requests on /soju/socket instead of /socket.

The proxy_redirect directive was the key to making file uploads work. When Soju receives a file, it responds with a Location header containing a path like /uploads/ololduck/myfile.png. Without rewriting, the client receives a redirect to chat.vit.am/uploads/... which is not served correctly. The rewrite turns it into /soju/uploads/....

I also set proxy_read_timeout 1d for WebSocket connections that stay open for a long time, and client_max_body_size 15m to allow uploading files up to 15 MB.

IRC stream configuration (TCP/TLS)πŸ”—

# /etc/nginx/nginx.conf (stream section)
stream {
    server {
        listen 6698 ssl;
        ssl_certificate     /etc/letsencrypt/live/chat.vit.am/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/chat.vit.am/privkey.pem;
        ssl_protocols       TLSv1.2 TLSv1.3;

        proxy_pass 127.0.0.1:6668;
    }
}

This block reuses the existing Let’s Encrypt certificate (same paths as the HTTPS server). No need to manage an additional certificate or configure TLS on Soju’s side.

The stream listens on port 6698 with TLS, and forwards traffic to port 6668 on Soju (itself mapped to port 6667 in the container). This allows connecting with any IRC client to chat.vit.am:6698 with TLS.

I left port 6697 for ZNC, which keeps running in parallel while everyone migrates.

Creating a userπŸ”—

Soju does not create a user on first launch. You need to do it with the sojuctl tool:

sojuctl create-user ololduck -admin

Since Soju runs in Docker, you need to go through the container:

docker exec -it soju sojuctl create-user ololduck -admin

The -admin flag grants administration privileges, needed to create other users and administer the bouncer.

Client connectionsπŸ”—

Clients with multi-network supportπŸ”—

If your client supports the soju.im/bouncer-networks extension (this is the case for Halloy which I use), simply connect to chat.vit.am:6698 with your Soju username and password. IRC networks are then configured directly in the client.

Other clientsπŸ”—

For clients that do not support multiple networks, you can specify the network directly in the username: <user>/<server>. For example, to connect to Libera Chat:

The bouncer will automatically create the network and connect to it.

If you use multiple clients at the same time (a desktop and a laptop), you can add a client name: ololduck/irc.libera.chat@desktop and ololduck/irc.libera.chat@laptop. Each client will have its own history buffer.

WebSocket connectionπŸ”—

For web clients that support IRC WebSocket, the URL is wss://chat.vit.am/soju/socket.

Managing networks with BouncerServπŸ”—

BouncerServ is Soju’s internal bot for configuring everything. You talk to it via private message: /msg BouncerServ <command>.

Commands can be abbreviated: network becomes net, create becomes c, etc.

Creating a networkπŸ”—

/msg BouncerServ network create -addr irc.libera.chat -name libera -nick ololduck

This creates the network and Soju connects to it directly. If the server requires a password (server password or NickServ):

/msg BouncerServ net create -addr irc.libera.chat -name libera -nick ololduck -connect-command "PRIVMSG NickServ :IDENTIFY mypassword"

-connect-command sends a raw IRC command right after connecting. Useful for servers that do not support SASL.

Certificates and non-standard serversπŸ”—

Not all IRC servers have a certificate signed by a recognized authority. For those, you can specify a certificate fingerprint (certfp) so that Soju verifies the certificate by fingerprint rather than by chain of trust.

To fetch a server’s fingerprint:

openssl s_client -connect ssl.netrusk.net:6697 -verify_quiet </dev/null | openssl x509 -fingerprint -sha512 -noout -in /dev/stdin

Then use it at creation time:

/msg BouncerServ net create -addr ssl.netrusk.net -name netrusk -nick ololduck -certfp 12:71:11:E2:E7:BC:ED:B4:38:74:74:49:FA:B1:42:20:4B:6C:9A:0F:31:14:69:BF:01:99:4B:BD:80:A5:4C:E8:8A:BC:27:AE:63:0C:56:33:51:72:73:A3:FE:90:63:D7:98:35:79:CC:B8:80:16:0A:A0:DF:10:45:C7:12:44:F2

You can also update an existing network instead of deleting and recreating it:

/msg BouncerServ net update netrusk -certfp <fingerprint>

Or change its address:

/msg BouncerServ net update netrusk -addr ssl.netrusk.net

When you modify a network, Soju disconnects and reconnects automatically.

Listing and deletingπŸ”—

/msg BouncerServ network status

Shows all configured networks, their status (connected, disconnected, error) and the number of channels.

/msg BouncerServ network delete libera

Deletes the network and disconnects from it.

SASLπŸ”—

For servers that support SASL (such as Libera Chat):

/msg BouncerServ sasl set-plain <username> <password>

If the network is not the default one (because you have multiple networks), specify with -network:

/msg BouncerServ sasl set-plain -network libera ololduck mypassword

Client certificate (CertFP)πŸ”—

If the network supports CertFP (like BitcoinNet), you can generate a key pair directly from Soju:

/msg BouncerServ certfp generate -network BitcoinNet

Soju generates a self-signed certificate and presents it to the server on connection. You then need to register the fingerprint with NickServ on that server.

The resultπŸ”—

In the end, I have:

The migration went well, and I can now enjoy IRCv3 features and file uploads. ZNC still runs in parallel for friends who have not migrated yet, but it will happen (or not).