Installing Soju, a modern IRC bouncer
2026-06-24
I swapped my old ZNC for Soju. Here is how it went.
Table of Contents
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:
6668for the IRC protocol (plain text, TLS is handled by nginx)4002for the HTTP interface (WebSocket and file uploads)
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:
db sqlite3stores users, networks and channels in a SQLite database.message-store dbkeeps message history in the same database.file-upload fs uploads/enables storing uploaded files on disk, in the mounted directory.listen irc+insecure://listens on port 6667 plain text (docker maps it to 6668).listen http+insecure://:80exposes the HTTP interface (WebSocket + uploads), docker maps it to 4002.http-ingressis the public URL under which Soju will build links. HOWEVER! there is currently a bug, for which I have not yet managed to write a patch, that causes Soju to ignore this directive when responding to a successful file upload. More details below.accept-proxy-ip localhostallows nginx (running on localhost) to pass proxy headers.
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:
- Server:
chat.vit.am:6698 - User:
ololduck/irc.libera.chat - Password: your Soju account password
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:
- Direct IRC:
chat.vit.am:6698(TLS) - WebSocket:
wss://chat.vit.am/soju/socket - File upload: via Halloy
- Administration interface:
BouncerServvia private message
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).